mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat: new collections (#4530)
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
@@ -11,11 +11,9 @@ import { useCurrentUser } from '../../hooks/affine/use-current-user';
|
||||
import { useIsWorkspaceOwner } from '../../hooks/affine/use-is-workspace-owner';
|
||||
import { useWorkspace } from '../../hooks/use-workspace';
|
||||
import {
|
||||
BlockSuitePageList,
|
||||
NewWorkspaceSettingDetail,
|
||||
PageDetailEditor,
|
||||
Provider,
|
||||
WorkspaceHeader,
|
||||
} from '../shared';
|
||||
|
||||
const LoginCard = lazy(() =>
|
||||
@@ -27,7 +25,6 @@ const LoginCard = lazy(() =>
|
||||
export const UI = {
|
||||
Provider,
|
||||
LoginCard,
|
||||
Header: WorkspaceHeader,
|
||||
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
||||
const workspace = useWorkspace(currentWorkspaceId);
|
||||
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
|
||||
@@ -61,16 +58,6 @@ export const UI = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
||||
return (
|
||||
<BlockSuitePageList
|
||||
listType="all"
|
||||
collection={collection}
|
||||
onOpenPage={onOpenPage}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
NewSettingsDetail: ({
|
||||
currentWorkspaceId,
|
||||
onTransformWorkspace,
|
||||
|
||||
@@ -29,11 +29,9 @@ import { useCallback } from 'react';
|
||||
|
||||
import { setPageModeAtom } from '../../atoms';
|
||||
import {
|
||||
BlockSuitePageList,
|
||||
NewWorkspaceSettingDetail,
|
||||
PageDetailEditor,
|
||||
Provider,
|
||||
WorkspaceHeader,
|
||||
} from '../shared';
|
||||
|
||||
const logger = new DebugLogger('use-create-first-workspace');
|
||||
@@ -85,7 +83,6 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
|
||||
},
|
||||
CRUD,
|
||||
UI: {
|
||||
Header: WorkspaceHeader,
|
||||
Provider,
|
||||
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
||||
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(currentWorkspaceId);
|
||||
@@ -105,16 +102,6 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
||||
return (
|
||||
<BlockSuitePageList
|
||||
listType="all"
|
||||
collection={collection}
|
||||
onOpenPage={onOpenPage}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
NewSettingsDetail: ({
|
||||
currentWorkspaceId,
|
||||
onTransformWorkspace,
|
||||
|
||||
@@ -5,13 +5,10 @@ import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useWorkspace } from '../../hooks/use-workspace';
|
||||
import { BlockSuitePageList, PageDetailEditor, Provider } from '../shared';
|
||||
import { PageDetailEditor, Provider } from '../shared';
|
||||
|
||||
export const UI = {
|
||||
Provider,
|
||||
Header: () => {
|
||||
return null;
|
||||
},
|
||||
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
||||
const workspace = useWorkspace(currentWorkspaceId);
|
||||
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
|
||||
@@ -29,16 +26,6 @@ export const UI = {
|
||||
</>
|
||||
);
|
||||
},
|
||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
||||
return (
|
||||
<BlockSuitePageList
|
||||
listType="all"
|
||||
collection={collection}
|
||||
onOpenPage={onOpenPage}
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
NewSettingsDetail: () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
|
||||
@@ -14,22 +14,8 @@ export const NewWorkspaceSettingDetail = lazy(() =>
|
||||
)
|
||||
);
|
||||
|
||||
export const BlockSuitePageList = lazy(() =>
|
||||
import('../components/blocksuite/block-suite-page-list').then(
|
||||
({ BlockSuitePageList }) => ({
|
||||
default: BlockSuitePageList,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export const PageDetailEditor = lazy(() =>
|
||||
import('../components/page-detail-editor').then(({ PageDetailEditor }) => ({
|
||||
default: PageDetailEditor,
|
||||
}))
|
||||
);
|
||||
|
||||
export const WorkspaceHeader = lazy(() =>
|
||||
import('../components/workspace-header').then(({ WorkspaceHeader }) => ({
|
||||
default: WorkspaceHeader,
|
||||
}))
|
||||
);
|
||||
|
||||
210
packages/frontend/core/src/atoms/collections.ts
Normal file
210
packages/frontend/core/src/atoms/collections.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { CollectionsCRUDAtom } from '@affine/component/page-list';
|
||||
import type { Collection, DeprecatedCollection } from '@affine/env/filter';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
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 { getUserSetting } from '../utils/user-setting';
|
||||
import { getWorkspaceSetting } from '../utils/workspace-setting';
|
||||
import { sessionAtom } from './cloud-user';
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export interface PageCollectionDBV1 extends DBSchema {
|
||||
view: {
|
||||
key: DeprecatedCollection['id'];
|
||||
value: DeprecatedCollection;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
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[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const collectionDBAtom = atom(
|
||||
openDB<PageCollectionDBV1>('page-view', 1, {
|
||||
upgrade(database) {
|
||||
database.createObjectStore('view', {
|
||||
keyPath: 'id',
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
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: DeprecatedCollection) => {
|
||||
const db = await get(collectionDBAtom);
|
||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||
await t.put(value);
|
||||
return key;
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
const db = await get(collectionDBAtom);
|
||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||
await t.delete(key);
|
||||
},
|
||||
list: async () => {
|
||||
const db = await get(collectionDBAtom);
|
||||
const t = db.transaction('view').objectStore('view');
|
||||
return t.getAllKeys();
|
||||
},
|
||||
}));
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const getCollections = async (
|
||||
storage: StorageCRUD<DeprecatedCollection>
|
||||
): Promise<DeprecatedCollection[]> => {
|
||||
return storage
|
||||
.list()
|
||||
.then(async keys => {
|
||||
return await Promise.all(keys.map(key => storage.get(key))).then(v =>
|
||||
v.filter((v): v is DeprecatedCollection => v !== null)
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load collections', error);
|
||||
return [];
|
||||
});
|
||||
};
|
||||
type BaseCollectionsDataType = {
|
||||
loading: boolean;
|
||||
collections: Collection[];
|
||||
};
|
||||
export const pageCollectionBaseAtom =
|
||||
atomWithObservable<BaseCollectionsDataType>(
|
||||
get => {
|
||||
const currentWorkspacePromise = get(currentWorkspaceAtom);
|
||||
const session = get(sessionAtom);
|
||||
const userId = session?.data?.user.id ?? null;
|
||||
const migrateCollectionsFromIdbData = async (
|
||||
workspace: Workspace
|
||||
): Promise<Collection[]> => {
|
||||
workspace.awarenessStore.awareness.emit('change log');
|
||||
const localCRUD = get(localCollectionCRUDAtom);
|
||||
const collections = await getCollections(localCRUD);
|
||||
const result = collections.filter(v => v.workspaceId === workspace.id);
|
||||
Promise.all(
|
||||
result.map(collection => {
|
||||
return localCRUD.delete(collection.id);
|
||||
})
|
||||
).catch(error => {
|
||||
console.error('Failed to delete collections from indexeddb', error);
|
||||
});
|
||||
return result.map(v => {
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
mode: 'rule',
|
||||
filterList: v.filterList,
|
||||
allowList: v.allowList ?? [],
|
||||
pages: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
const migrateCollectionsFromUserData = async (
|
||||
workspace: Workspace
|
||||
): Promise<Collection[]> => {
|
||||
if (userId == null) {
|
||||
return [];
|
||||
}
|
||||
const userSetting = getUserSetting(workspace, userId);
|
||||
await userSetting.loaded;
|
||||
const view = userSetting.view;
|
||||
if (view) {
|
||||
const collections: DeprecatedCollection[] = [...view.values()];
|
||||
//delete collections
|
||||
view.clear();
|
||||
return collections.map(v => {
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
mode: 'rule',
|
||||
filterList: v.filterList,
|
||||
allowList: v.allowList ?? [],
|
||||
pages: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return new Observable<BaseCollectionsDataType>(subscriber => {
|
||||
const group = new DisposableGroup();
|
||||
currentWorkspacePromise.then(async currentWorkspace => {
|
||||
const collectionsFromLocal =
|
||||
await migrateCollectionsFromIdbData(currentWorkspace);
|
||||
const collectionFromUserSetting =
|
||||
await migrateCollectionsFromUserData(currentWorkspace);
|
||||
const workspaceSetting = getWorkspaceSetting(currentWorkspace);
|
||||
if (collectionsFromLocal.length || collectionFromUserSetting.length) {
|
||||
// migrate collections
|
||||
workspaceSetting.addCollection(
|
||||
...collectionFromUserSetting,
|
||||
...collectionsFromLocal
|
||||
);
|
||||
}
|
||||
subscriber.next({
|
||||
loading: false,
|
||||
collections: workspaceSetting.collections,
|
||||
});
|
||||
if (group.disposed) {
|
||||
return;
|
||||
}
|
||||
const fn = () => {
|
||||
subscriber.next({
|
||||
loading: false,
|
||||
collections: workspaceSetting.collections,
|
||||
});
|
||||
};
|
||||
workspaceSetting.collectionsYArray.observe(fn);
|
||||
group.add(() => {
|
||||
workspaceSetting.collectionsYArray.unobserve(fn);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
group.dispose();
|
||||
};
|
||||
});
|
||||
},
|
||||
{ initialValue: { loading: true, collections: [] } }
|
||||
);
|
||||
export const collectionsCRUDAtom: CollectionsCRUDAtom = atom(get => {
|
||||
const workspacePromise = get(currentWorkspaceAtom);
|
||||
return {
|
||||
addCollection: async (...collections) => {
|
||||
const workspace = await workspacePromise;
|
||||
getWorkspaceSetting(workspace).addCollection(...collections);
|
||||
},
|
||||
collections: get(pageCollectionBaseAtom).collections,
|
||||
updateCollection: async (id, updater) => {
|
||||
const workspace = await workspacePromise;
|
||||
getWorkspaceSetting(workspace).updateCollection(id, updater);
|
||||
},
|
||||
deleteCollection: async (info, ...ids) => {
|
||||
const workspace = await workspacePromise;
|
||||
getWorkspaceSetting(workspace).deleteCollection(info, ...ids);
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -2,12 +2,12 @@ import { atom } from 'jotai';
|
||||
|
||||
export type TrashModal = {
|
||||
open: boolean;
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
pageIds: string[];
|
||||
pageTitles: string[];
|
||||
};
|
||||
|
||||
export const trashModalAtom = atom<TrashModal>({
|
||||
open: false,
|
||||
pageId: '',
|
||||
pageTitle: '',
|
||||
pageIds: [],
|
||||
pageTitles: [],
|
||||
});
|
||||
|
||||
@@ -70,8 +70,8 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
||||
const handleOpenTrashModal = useCallback(() => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageId,
|
||||
pageTitle: pageMeta.title,
|
||||
pageIds: [pageId],
|
||||
pageTitles: [pageMeta.title],
|
||||
});
|
||||
}, [pageId, pageMeta.title, setTrashModal]);
|
||||
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import type { ListData, TrashListData } from '@affine/component/page-list';
|
||||
import { PageList, PageListTrashView } from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import { type PageMeta, type Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { Suspense, useCallback, useMemo } from 'react';
|
||||
|
||||
import { allPageModeSelectAtom } from '../../../atoms';
|
||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
|
||||
import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { toast } from '../../../utils';
|
||||
import { filterPage } from '../../../utils/filter';
|
||||
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
||||
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
|
||||
import { usePageHelper } from './utils';
|
||||
|
||||
export interface BlockSuitePageListProps {
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
listType: 'all' | 'trash' | 'shared' | 'public';
|
||||
isPublic?: boolean;
|
||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
||||
collection?: Collection;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
all: (pageMeta: PageMeta) => !pageMeta.trash,
|
||||
public: (pageMeta: PageMeta) => !pageMeta.trash,
|
||||
trash: (pageMeta: PageMeta, allMetas: PageMeta[]) => {
|
||||
const parentMeta = allMetas.find(m => m.subpageIds?.includes(pageMeta.id));
|
||||
return !parentMeta?.trash && pageMeta.trash;
|
||||
},
|
||||
shared: (pageMeta: PageMeta) => pageMeta.isPublic && !pageMeta.trash,
|
||||
};
|
||||
|
||||
interface PagePreviewInnerProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
const PagePreviewInner = ({ workspace, pageId }: PagePreviewInnerProps) => {
|
||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||
assertExists(page);
|
||||
const previewAtom = useBlockSuitePagePreview(page);
|
||||
const preview = useAtomValue(previewAtom);
|
||||
return preview;
|
||||
};
|
||||
|
||||
interface PagePreviewProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
const PagePreview = ({ workspace, pageId }: PagePreviewProps) => {
|
||||
return (
|
||||
<Suspense>
|
||||
<PagePreviewInner workspace={workspace} pageId={pageId} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageListEmptyProps {
|
||||
createPage?: ReturnType<typeof usePageHelper>['createPage'];
|
||||
listType: BlockSuitePageListProps['listType'];
|
||||
}
|
||||
|
||||
const PageListEmpty = (props: PageListEmptyProps) => {
|
||||
const { listType, createPage } = props;
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const onCreatePage = useCallback(() => {
|
||||
createPage?.();
|
||||
}, [createPage]);
|
||||
|
||||
const getEmptyDescription = () => {
|
||||
if (listType === 'all') {
|
||||
const createNewPageButton = (
|
||||
<button className={emptyDescButton} onClick={onCreatePage}>
|
||||
New Page
|
||||
</button>
|
||||
);
|
||||
if (environment.isDesktop) {
|
||||
const shortcut = environment.isMacOs ? '⌘ + N' : 'Ctrl + N';
|
||||
return (
|
||||
<Trans i18nKey="emptyAllPagesClient">
|
||||
Click on the {createNewPageButton} button Or press
|
||||
<kbd className={emptyDescKbd}>{{ shortcut } as any}</kbd> to create
|
||||
your first page.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Trans i18nKey="emptyAllPages">
|
||||
Click on the
|
||||
{createNewPageButton}
|
||||
button to create your first page.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
if (listType === 'trash') {
|
||||
return t['emptyTrash']();
|
||||
}
|
||||
if (listType === 'shared') {
|
||||
return t['emptySharedPages']();
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={pageListEmptyStyle}>
|
||||
<Empty
|
||||
title={t['com.affine.emptyDesc']()}
|
||||
description={getEmptyDescription()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BlockSuitePageList = ({
|
||||
blockSuiteWorkspace,
|
||||
onOpenPage,
|
||||
listType,
|
||||
isPublic = false,
|
||||
collection,
|
||||
}: BlockSuitePageListProps) => {
|
||||
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
|
||||
const {
|
||||
toggleFavorite,
|
||||
restoreFromTrash,
|
||||
permanentlyDeletePage,
|
||||
cancelPublicPage,
|
||||
} = useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||
const [filterMode] = useAtom(allPageModeSelectAtom);
|
||||
const { createPage, createEdgeless, importFile, isPreferredEdgeless } =
|
||||
usePageHelper(blockSuiteWorkspace);
|
||||
const t = useAFFiNEI18N();
|
||||
const getPageInfo = useGetPageInfoById(blockSuiteWorkspace);
|
||||
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
||||
|
||||
const tagOptionMap = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(blockSuiteWorkspace.meta.properties.tags?.options ?? []).map(v => [
|
||||
v.id,
|
||||
v,
|
||||
])
|
||||
),
|
||||
[blockSuiteWorkspace.meta.properties.tags?.options]
|
||||
);
|
||||
const list = useMemo(
|
||||
() =>
|
||||
pageMetas
|
||||
.filter(pageMeta => {
|
||||
if (filterMode === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (filterMode === 'edgeless') {
|
||||
return isPreferredEdgeless(pageMeta.id);
|
||||
}
|
||||
if (filterMode === 'page') {
|
||||
return !isPreferredEdgeless(pageMeta.id);
|
||||
}
|
||||
console.error('unknown filter mode', pageMeta, filterMode);
|
||||
return true;
|
||||
})
|
||||
.filter(pageMeta => {
|
||||
if (!filter[listType](pageMeta, pageMetas)) {
|
||||
return false;
|
||||
}
|
||||
if (!collection) {
|
||||
return true;
|
||||
}
|
||||
return filterPage(collection, pageMeta);
|
||||
}),
|
||||
[pageMetas, filterMode, isPreferredEdgeless, listType, collection]
|
||||
);
|
||||
|
||||
if (listType === 'trash') {
|
||||
const pageList: TrashListData[] = list.map(pageMeta => {
|
||||
return {
|
||||
icon: isPreferredEdgeless(pageMeta.id) ? (
|
||||
<EdgelessIcon />
|
||||
) : (
|
||||
<PageIcon />
|
||||
),
|
||||
pageId: pageMeta.id,
|
||||
title: pageMeta.title,
|
||||
preview: (
|
||||
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||
),
|
||||
createDate: new Date(pageMeta.createDate),
|
||||
trashDate: pageMeta.trashDate
|
||||
? new Date(pageMeta.trashDate)
|
||||
: undefined,
|
||||
onClickPage: () => onOpenPage(pageMeta.id),
|
||||
onClickRestore: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
},
|
||||
onRestorePage: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
toast(
|
||||
t['com.affine.toastMessage.restored']({
|
||||
title: pageMeta.title || 'Untitled',
|
||||
})
|
||||
);
|
||||
},
|
||||
onPermanentlyDeletePage: () => {
|
||||
permanentlyDeletePage(pageMeta.id);
|
||||
toast(t['com.affine.toastMessage.permanentlyDeleted']());
|
||||
},
|
||||
};
|
||||
});
|
||||
return (
|
||||
<PageListTrashView
|
||||
list={pageList}
|
||||
fallback={<PageListEmpty listType={listType} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pageList: ListData[] = list.map(pageMeta => {
|
||||
const page = blockSuiteWorkspace.getPage(pageMeta.id);
|
||||
return {
|
||||
icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />,
|
||||
pageId: pageMeta.id,
|
||||
title: pageMeta.title,
|
||||
preview: (
|
||||
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||
),
|
||||
tags:
|
||||
page?.meta.tags?.map(id => tagOptionMap[id]).filter(v => v != null) ??
|
||||
[],
|
||||
favorite: !!pageMeta.favorite,
|
||||
isPublicPage: !!pageMeta.isPublic,
|
||||
createDate: new Date(pageMeta.createDate),
|
||||
updatedDate: new Date(pageMeta.updatedDate ?? pageMeta.createDate),
|
||||
onClickPage: () => onOpenPage(pageMeta.id),
|
||||
onOpenPageInNewTab: () => onOpenPage(pageMeta.id, true),
|
||||
onClickRestore: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
},
|
||||
removeToTrash: () =>
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageId: pageMeta.id,
|
||||
pageTitle: pageMeta.title,
|
||||
}),
|
||||
|
||||
onRestorePage: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
toast(
|
||||
t['com.affine.toastMessage.restored']({
|
||||
title: pageMeta.title || 'Untitled',
|
||||
})
|
||||
);
|
||||
},
|
||||
bookmarkPage: () => {
|
||||
const status = pageMeta.favorite;
|
||||
toggleFavorite(pageMeta.id);
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
},
|
||||
onDisablePublicSharing: () => {
|
||||
cancelPublicPage(pageMeta.id);
|
||||
toast('Successfully disabled', {
|
||||
portal: document.body,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PageList
|
||||
collectionsAtom={currentCollectionsAtom}
|
||||
propertiesMeta={blockSuiteWorkspace.meta.properties}
|
||||
getPageInfo={getPageInfo}
|
||||
onCreateNewPage={createPage}
|
||||
onCreateNewEdgeless={createEdgeless}
|
||||
onImportFile={importFile}
|
||||
isPublicWorkspace={isPublic}
|
||||
list={pageList}
|
||||
fallback={<PageListEmpty createPage={createPage} listType={listType} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,7 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const filterContainerStyle = style({
|
||||
padding: '12px',
|
||||
padding: '0 16px',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
'::after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
margin: '0 1px',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,17 +25,17 @@ import {
|
||||
} from '@toeverything/infra/command';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
openQuickSearchModalAtom,
|
||||
pageSettingsAtom,
|
||||
recentPageIdsBaseAtom,
|
||||
} from '../../../atoms';
|
||||
import { collectionsCRUDAtom } from '../../../atoms/collections';
|
||||
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||
import { WorkspaceSubPath } from '../../../shared';
|
||||
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||
import type { CMDKCommand, CommandContext } from './types';
|
||||
|
||||
@@ -295,7 +295,7 @@ export const collectionToCommand = (
|
||||
collection: Collection,
|
||||
store: ReturnType<typeof getCurrentStore>,
|
||||
navigationHelper: ReturnType<typeof useNavigateHelper>,
|
||||
selectCollection: ReturnType<typeof useCollectionManager>['selectCollection'],
|
||||
selectCollection: (id: string) => void,
|
||||
t: ReturnType<typeof useAFFiNEI18N>
|
||||
): CMDKCommand => {
|
||||
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
|
||||
@@ -329,14 +329,18 @@ export const collectionToCommand = (
|
||||
|
||||
export const useCollectionsCommands = () => {
|
||||
// todo: considering collections for searching pages
|
||||
const { savedCollections, selectCollection } = useCollectionManager(
|
||||
currentCollectionsAtom
|
||||
);
|
||||
const { savedCollections } = useCollectionManager(collectionsCRUDAtom);
|
||||
const store = getCurrentStore();
|
||||
const query = useAtomValue(cmdkQueryAtom);
|
||||
const navigationHelper = useNavigateHelper();
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const [workspace] = useCurrentWorkspace();
|
||||
const selectCollection = useCallback(
|
||||
(id: string) => {
|
||||
navigationHelper.jumpToCollection(workspace.id, id);
|
||||
},
|
||||
[navigationHelper, workspace.id]
|
||||
);
|
||||
return useMemo(() => {
|
||||
let results: CMDKCommand[] = [];
|
||||
if (query.trim() === '') {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
|
||||
import clsx from 'clsx';
|
||||
import { type Atom, useAtomValue } from 'jotai';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { forwardRef, useRef } from 'react';
|
||||
|
||||
import * as style from './style.css';
|
||||
@@ -14,17 +14,18 @@ import { TopTip } from './top-tip';
|
||||
import { WindowsAppControls } from './windows-app-controls';
|
||||
|
||||
interface HeaderPros {
|
||||
left?: ReactElement;
|
||||
right?: ReactElement;
|
||||
center?: ReactElement;
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
center?: ReactNode;
|
||||
mainContainerAtom: Atom<HTMLDivElement | null>;
|
||||
bottomBorder?: boolean;
|
||||
}
|
||||
|
||||
// The Header component is used to solve the following problems
|
||||
// 1. Manage layout issues independently of page or business logic
|
||||
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
|
||||
export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
|
||||
{ left, center, right, mainContainerAtom },
|
||||
{ left, center, right, mainContainerAtom, bottomBorder },
|
||||
ref
|
||||
) {
|
||||
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -51,7 +52,7 @@ export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
|
||||
<>
|
||||
<TopTip />
|
||||
<div
|
||||
className={style.header}
|
||||
className={clsx(style.header, bottomBorder && style.bottomBorder)}
|
||||
// data-has-warning={showWarning}
|
||||
data-open={open}
|
||||
data-sidebar-floating={appSidebarFloating}
|
||||
|
||||
@@ -8,7 +8,6 @@ export const header = style({
|
||||
padding: '0 16px',
|
||||
minHeight: '52px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
zIndex: 2,
|
||||
selectors: {
|
||||
'&[data-sidebar-floating="false"]': {
|
||||
@@ -25,6 +24,10 @@ export const header = style({
|
||||
},
|
||||
} as ComplexStyleRule);
|
||||
|
||||
export const bottomBorder = style({
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const headerItem = style({
|
||||
minHeight: '32px',
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import {
|
||||
EditCollectionModal,
|
||||
createEmptyCollection,
|
||||
useCollectionManager,
|
||||
useEditCollectionName,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { PlusIcon } from '@blocksuite/icons';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { IconButton } from '@toeverything/components/button';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
||||
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
||||
import { collectionsCRUDAtom } from '../../../../atoms/collections';
|
||||
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
||||
|
||||
type AddCollectionButtonProps = {
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
export const AddCollectionButton = ({
|
||||
workspace,
|
||||
}: AddCollectionButtonProps) => {
|
||||
const getPageInfo = useGetPageInfoById(workspace);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
export const AddCollectionButton = () => {
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const t = useAFFiNEI18N();
|
||||
const [show, showUpdateCollection] = useState(false);
|
||||
const [defaultCollection, setDefaultCollection] = useState<Collection>();
|
||||
const { node, open } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.createCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const [workspace] = useCurrentWorkspace();
|
||||
const handleClick = useCallback(() => {
|
||||
showUpdateCollection(true);
|
||||
setDefaultCollection({
|
||||
id: nanoid(),
|
||||
name: '',
|
||||
pinned: true,
|
||||
filterList: [],
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}, [showUpdateCollection, workspace.id]);
|
||||
|
||||
open('')
|
||||
.then(name => {
|
||||
const id = nanoid();
|
||||
return setting
|
||||
.createCollection(createEmptyCollection(id, { name }))
|
||||
.then(() => {
|
||||
navigateHelper.jumpToCollection(workspace.id, id);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [navigateHelper, open, setting, workspace.id]);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -45,16 +45,7 @@ export const AddCollectionButton = ({
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
|
||||
<EditCollectionModal
|
||||
propertiesMeta={workspace.meta.properties}
|
||||
getPageInfo={getPageInfo}
|
||||
onConfirm={setting.saveCollection}
|
||||
open={show}
|
||||
onOpenChange={showUpdateCollection}
|
||||
title={t['com.affine.editCollection.saveCollection']()}
|
||||
init={defaultCollection}
|
||||
/>
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
import { MenuItem as CollectionItem } from '@affine/component/app-sidebar';
|
||||
import { AnimatedCollectionsIcon } from '@affine/component';
|
||||
import {
|
||||
EditCollectionModal,
|
||||
MenuItem as SidebarMenuItem,
|
||||
MenuLinkItem as SidebarMenuLinkItem,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import {
|
||||
CollectionOperations,
|
||||
filterPage,
|
||||
stopPropagation,
|
||||
useCollectionManager,
|
||||
useSavedCollections,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeleteIcon,
|
||||
FilterIcon,
|
||||
InformationIcon,
|
||||
MoreHorizontalIcon,
|
||||
UnpinIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { InformationIcon, MoreHorizontalIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { IconButton } from '@toeverything/components/button';
|
||||
import {
|
||||
Menu,
|
||||
MenuIcon,
|
||||
MenuItem,
|
||||
type MenuItemProps,
|
||||
} from '@toeverything/components/menu';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
||||
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
||||
import { filterPage } from '../../../../utils/filter';
|
||||
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
||||
import { collectionsCRUDAtom } from '../../../../atoms/collections';
|
||||
import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config';
|
||||
import type { CollectionsListProps } from '../index';
|
||||
import { Page } from './page';
|
||||
import * as styles from './styles.css';
|
||||
@@ -51,111 +40,20 @@ export const processCollectionsDrag = (e: DragEndEvent) => {
|
||||
e.over?.data.current?.addToCollection?.(e.active.data.current?.pageId);
|
||||
}
|
||||
};
|
||||
const CollectionOperations = ({
|
||||
view,
|
||||
showUpdateCollection,
|
||||
setting,
|
||||
}: {
|
||||
view: Collection;
|
||||
showUpdateCollection: () => void;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const actions = useMemo<
|
||||
Array<
|
||||
| {
|
||||
icon: ReactElement;
|
||||
name: string;
|
||||
click: () => void;
|
||||
type?: MenuItemProps['type'];
|
||||
element?: undefined;
|
||||
}
|
||||
| {
|
||||
element: ReactElement;
|
||||
}
|
||||
>
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<FilterIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Edit Filter'](),
|
||||
click: showUpdateCollection,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<UnpinIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Unpin'](),
|
||||
click: () => {
|
||||
return setting.updateCollection({
|
||||
...view,
|
||||
pinned: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
element: <div key="divider" className={styles.menuDividerStyle}></div>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Delete'](),
|
||||
click: () => {
|
||||
return setting.deleteCollection(view.id);
|
||||
},
|
||||
type: 'danger',
|
||||
},
|
||||
],
|
||||
[setting, showUpdateCollection, t, view]
|
||||
);
|
||||
return (
|
||||
<div style={{ minWidth: 150 }}>
|
||||
{actions.map(action => {
|
||||
if (action.element) {
|
||||
return action.element;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="collection-option"
|
||||
key={action.name}
|
||||
type={action.type}
|
||||
preFix={action.icon}
|
||||
onClick={action.click}
|
||||
>
|
||||
{action.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CollectionRenderer = ({
|
||||
collection,
|
||||
pages,
|
||||
workspace,
|
||||
getPageInfo,
|
||||
info,
|
||||
}: {
|
||||
collection: Collection;
|
||||
pages: PageMeta[];
|
||||
workspace: Workspace;
|
||||
getPageInfo: GetPageInfoById;
|
||||
info: DeleteCollectionInfo;
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const { jumpToSubPath } = useNavigateHelper();
|
||||
const clickCollection = useCallback(() => {
|
||||
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
||||
setting.selectCollection(collection.id);
|
||||
}, [jumpToSubPath, workspace.id, setting, collection.id]);
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `${Collections_DROP_AREA_PREFIX}${collection.id}`,
|
||||
data: {
|
||||
@@ -166,19 +64,15 @@ const CollectionRenderer = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
const config = useAllPageListConfig();
|
||||
const allPagesMeta = useMemo(
|
||||
() => Object.fromEntries(pages.map(v => [v.id, v])),
|
||||
[pages]
|
||||
);
|
||||
const [show, showUpdateCollection] = useState(false);
|
||||
const allowList = useMemo(
|
||||
() => new Set(collection.allowList),
|
||||
[collection.allowList]
|
||||
);
|
||||
const excludeList = useMemo(
|
||||
() => new Set(collection.excludeList),
|
||||
[collection.excludeList]
|
||||
);
|
||||
const removeFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
return setting.updateCollection({
|
||||
@@ -188,57 +82,41 @@ const CollectionRenderer = ({
|
||||
},
|
||||
[collection, setting]
|
||||
);
|
||||
const addToExcludeList = useCallback(
|
||||
(id: string) => {
|
||||
return setting.updateCollection({
|
||||
...collection,
|
||||
excludeList: [id, ...(collection.excludeList ?? [])],
|
||||
});
|
||||
},
|
||||
[collection, setting]
|
||||
);
|
||||
const pagesToRender = pages.filter(
|
||||
page => filterPage(collection, page) && !page.trash
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname.split('?')[0];
|
||||
const path = `/workspace/${workspace.id}/collection/${collection.id}`;
|
||||
return (
|
||||
<Collapsible.Root open={!collapsed}>
|
||||
<EditCollectionModal
|
||||
propertiesMeta={workspace.meta.properties}
|
||||
getPageInfo={getPageInfo}
|
||||
init={collection}
|
||||
onConfirm={setting.saveCollection}
|
||||
open={show}
|
||||
onOpenChange={showUpdateCollection}
|
||||
/>
|
||||
<CollectionItem
|
||||
<SidebarMenuLinkItem
|
||||
data-testid="collection-item"
|
||||
data-type="collection-list-item"
|
||||
ref={setNodeRef}
|
||||
onCollapsedChange={setCollapsed}
|
||||
active={isOver}
|
||||
icon={<ViewLayersIcon />}
|
||||
active={isOver || currentPath === path}
|
||||
icon={<AnimatedCollectionsIcon closed={isOver} />}
|
||||
to={path}
|
||||
postfix={
|
||||
<Menu
|
||||
items={
|
||||
<CollectionOperations
|
||||
view={collection}
|
||||
showUpdateCollection={() => showUpdateCollection(true)}
|
||||
setting={setting}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-testid="collection-options"
|
||||
type="plain"
|
||||
withoutHoverStyle
|
||||
<div onClick={stopPropagation}>
|
||||
<CollectionOperations
|
||||
info={info}
|
||||
collection={collection}
|
||||
setting={setting}
|
||||
config={config}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
<IconButton
|
||||
data-testid="collection-options"
|
||||
type="plain"
|
||||
withoutHoverStyle
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</CollectionOperations>
|
||||
</div>
|
||||
}
|
||||
collapsed={pagesToRender.length > 0 ? collapsed : undefined}
|
||||
onClick={clickCollection}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -249,7 +127,7 @@ const CollectionRenderer = ({
|
||||
>
|
||||
<div>{collection.name}</div>
|
||||
</div>
|
||||
</CollectionItem>
|
||||
</SidebarMenuLinkItem>
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
<div style={{ marginLeft: 20, marginTop: -4 }}>
|
||||
{pagesToRender.map(page => {
|
||||
@@ -257,8 +135,6 @@ const CollectionRenderer = ({
|
||||
<Page
|
||||
inAllowList={allowList.has(page.id)}
|
||||
removeFromAllowList={removeFromAllowList}
|
||||
inExcludeList={excludeList.has(page.id)}
|
||||
addToExcludeList={addToExcludeList}
|
||||
allPageMeta={allPagesMeta}
|
||||
page={page}
|
||||
key={page.id}
|
||||
@@ -271,32 +147,27 @@ const CollectionRenderer = ({
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
export const CollectionsList = ({ workspace }: CollectionsListProps) => {
|
||||
export const CollectionsList = ({ workspace, info }: CollectionsListProps) => {
|
||||
const metas = useBlockSuitePageMeta(workspace);
|
||||
const { savedCollections } = useSavedCollections(currentCollectionsAtom);
|
||||
const getPageInfo = useGetPageInfoById(workspace);
|
||||
const pinedCollections = useMemo(
|
||||
() => savedCollections.filter(v => v.pinned),
|
||||
[savedCollections]
|
||||
);
|
||||
const { collections } = useSavedCollections(collectionsCRUDAtom);
|
||||
const t = useAFFiNEI18N();
|
||||
if (pinedCollections.length === 0) {
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<CollectionItem
|
||||
<SidebarMenuItem
|
||||
data-testid="slider-bar-collection-null-description"
|
||||
icon={<InformationIcon />}
|
||||
disabled
|
||||
>
|
||||
<span>{t['Create a collection']()}</span>
|
||||
</CollectionItem>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="collections" className={styles.wrapper}>
|
||||
{pinedCollections.map(view => {
|
||||
{collections.map(view => {
|
||||
return (
|
||||
<CollectionRenderer
|
||||
getPageInfo={getPageInfo}
|
||||
info={info}
|
||||
key={view.id}
|
||||
collection={view}
|
||||
pages={metas}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DeleteIcon,
|
||||
EdgelessIcon,
|
||||
FilterMinusIcon,
|
||||
FilterUndoIcon,
|
||||
MoreHorizontalIcon,
|
||||
PageIcon,
|
||||
} from '@blocksuite/icons';
|
||||
@@ -32,25 +31,21 @@ import * as styles from './styles.css';
|
||||
export const PageOperations = ({
|
||||
page,
|
||||
inAllowList,
|
||||
addToExcludeList,
|
||||
removeFromAllowList,
|
||||
inExcludeList,
|
||||
workspace,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
page: PageMeta;
|
||||
inAllowList: boolean;
|
||||
removeFromAllowList: (id: string) => void;
|
||||
inExcludeList: boolean;
|
||||
addToExcludeList: (id: string) => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { setTrashModal } = useTrashModalHelper(workspace);
|
||||
const onClickDelete = useCallback(() => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageId: page.id,
|
||||
pageTitle: page.title,
|
||||
pageIds: [page.id],
|
||||
pageTitles: [page.title],
|
||||
});
|
||||
}, [page.id, page.title, setTrashModal]);
|
||||
const actions = useMemo<
|
||||
@@ -79,24 +74,13 @@ export const PageOperations = ({
|
||||
name: t['Remove special filter'](),
|
||||
click: () => removeFromAllowList(page.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!inExcludeList
|
||||
? [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<FilterUndoIcon />
|
||||
</MenuIcon>
|
||||
element: (
|
||||
<div key="divider" className={styles.menuDividerStyle}></div>
|
||||
),
|
||||
name: t['Exclude from filter'](),
|
||||
click: () => addToExcludeList(page.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
element: <div key="divider" className={styles.menuDividerStyle}></div>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
@@ -108,15 +92,7 @@ export const PageOperations = ({
|
||||
type: 'danger',
|
||||
},
|
||||
],
|
||||
[
|
||||
inAllowList,
|
||||
t,
|
||||
inExcludeList,
|
||||
onClickDelete,
|
||||
removeFromAllowList,
|
||||
page.id,
|
||||
addToExcludeList,
|
||||
]
|
||||
[inAllowList, t, onClickDelete, removeFromAllowList, page.id]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
@@ -144,15 +120,11 @@ export const Page = ({
|
||||
workspace,
|
||||
allPageMeta,
|
||||
inAllowList,
|
||||
inExcludeList,
|
||||
removeFromAllowList,
|
||||
addToExcludeList,
|
||||
}: {
|
||||
page: PageMeta;
|
||||
inAllowList: boolean;
|
||||
removeFromAllowList: (id: string) => void;
|
||||
inExcludeList: boolean;
|
||||
addToExcludeList: (id: string) => void;
|
||||
workspace: Workspace;
|
||||
allPageMeta: Record<string, PageMeta>;
|
||||
}) => {
|
||||
@@ -186,8 +158,6 @@ export const Page = ({
|
||||
<PageOperations
|
||||
inAllowList={inAllowList}
|
||||
removeFromAllowList={removeFromAllowList}
|
||||
inExcludeList={inExcludeList}
|
||||
addToExcludeList={addToExcludeList}
|
||||
page={page}
|
||||
workspace={workspace}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
export const wrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
userSelect: 'none',
|
||||
// marginLeft:8,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
|
||||
export type FavoriteListProps = {
|
||||
@@ -6,4 +7,5 @@ export type FavoriteListProps = {
|
||||
|
||||
export type CollectionsListProps = {
|
||||
workspace: Workspace;
|
||||
info: DeleteCollectionInfo;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnimatedDeleteIcon } from '@affine/component';
|
||||
import {
|
||||
AddPageButton,
|
||||
AppSidebar,
|
||||
@@ -10,13 +11,10 @@ import {
|
||||
SidebarContainer,
|
||||
SidebarScrollableContainer,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import { MoveToTrash, useCollectionManager } from '@affine/component/page-list';
|
||||
import { MoveToTrash } from '@affine/component/page-list';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeleteTemporarilyIcon,
|
||||
FolderIcon,
|
||||
SettingsIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { FolderIcon, SettingsIcon } from '@blocksuite/icons';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
@@ -27,11 +25,12 @@ import { forwardRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { openWorkspaceListModalAtom } from '../../atoms';
|
||||
import { useHistoryAtom } from '../../atoms/history';
|
||||
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
||||
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
|
||||
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
|
||||
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/use-shortcut-commands';
|
||||
import type { AllWorkspace } from '../../shared';
|
||||
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
||||
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
||||
import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button';
|
||||
@@ -101,7 +100,6 @@ export const RootAppSidebar = ({
|
||||
}: RootAppSidebarProps): ReactElement => {
|
||||
const currentWorkspaceId = currentWorkspace.id;
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const { backToAll } = useCollectionManager(currentCollectionsAtom);
|
||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||
const t = useAFFiNEI18N();
|
||||
const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom(
|
||||
@@ -117,7 +115,7 @@ export const RootAppSidebar = ({
|
||||
|
||||
const { trashModal, setTrashModal, handleOnConfirm } =
|
||||
useTrashModalHelper(blockSuiteWorkspace);
|
||||
const deletePageTitle = trashModal.pageTitle;
|
||||
const deletePageTitles = trashModal.pageTitles;
|
||||
const trashConfirmOpen = trashModal.open;
|
||||
const onTrashConfirmOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -129,6 +127,10 @@ export const RootAppSidebar = ({
|
||||
[trashModal, setTrashModal]
|
||||
);
|
||||
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const backToAll = useCallback(() => {
|
||||
navigateHelper.jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
|
||||
}, [currentWorkspace.id, navigateHelper]);
|
||||
// Listen to the "New Page" action from the menu
|
||||
useEffect(() => {
|
||||
if (environment.isDesktop) {
|
||||
@@ -166,6 +168,7 @@ export const RootAppSidebar = ({
|
||||
setOpenUserWorkspaceList(false);
|
||||
}, [setOpenUserWorkspaceList]);
|
||||
useRegisterBlocksuiteEditorCommands(router.back, router.forward);
|
||||
const userInfo = useDeleteCollectionInfo();
|
||||
return (
|
||||
<>
|
||||
<AppSidebar
|
||||
@@ -183,7 +186,7 @@ export const RootAppSidebar = ({
|
||||
open={trashConfirmOpen}
|
||||
onConfirm={handleOnConfirm}
|
||||
onOpenChange={onTrashConfirmOpenChange}
|
||||
title={deletePageTitle}
|
||||
titles={deletePageTitles}
|
||||
/>
|
||||
<SidebarContainer>
|
||||
<Menu
|
||||
@@ -243,16 +246,16 @@ export const RootAppSidebar = ({
|
||||
</CategoryDivider>
|
||||
<FavoriteList workspace={blockSuiteWorkspace} />
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
|
||||
<AddCollectionButton workspace={blockSuiteWorkspace} />
|
||||
<AddCollectionButton />
|
||||
</CategoryDivider>
|
||||
<CollectionsList workspace={blockSuiteWorkspace} />
|
||||
<CollectionsList workspace={blockSuiteWorkspace} info={userInfo} />
|
||||
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
|
||||
{/* fixme: remove the following spacer */}
|
||||
<div style={{ height: '4px' }} />
|
||||
<RouteMenuLinkItem
|
||||
ref={trashDroppable.setNodeRef}
|
||||
isDraggedOver={trashDroppable.isOver}
|
||||
icon={<DeleteTemporarilyIcon />}
|
||||
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
|
||||
currentPath={currentPath}
|
||||
path={paths.trash(currentWorkspaceId)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const trashTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 8px',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const trashIcon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
});
|
||||
@@ -1,43 +1,50 @@
|
||||
import {
|
||||
CollectionList,
|
||||
FilterList,
|
||||
SaveCollectionButton,
|
||||
SaveAsCollectionButton,
|
||||
useCollectionManager,
|
||||
} from '@affine/component/page-list';
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type {
|
||||
WorkspaceFlavour,
|
||||
WorkspaceHeaderProps,
|
||||
} from '@affine/env/workspace';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DeleteIcon } from '@blocksuite/icons';
|
||||
import { useSetAtom } from 'jotai/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { collectionsCRUDAtom } from '../atoms/collections';
|
||||
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
|
||||
import { useGetPageInfoById } from '../hooks/use-get-page-info';
|
||||
import { useAllPageListConfig } from '../hooks/affine/use-all-page-list-config';
|
||||
import { useDeleteCollectionInfo } from '../hooks/affine/use-delete-collection-info';
|
||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||
import { useWorkspace } from '../hooks/use-workspace';
|
||||
import { currentCollectionsAtom } from '../utils/user-setting';
|
||||
import { SharePageModal } from './affine/share-page-modal';
|
||||
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
|
||||
import { filterContainerStyle } from './filter-container.css';
|
||||
import { Header } from './pure/header';
|
||||
import { PluginHeader } from './pure/plugin-header';
|
||||
import { WorkspaceModeFilterTab } from './pure/workspace-mode-filter-tab';
|
||||
import * as styles from './workspace-header.css';
|
||||
|
||||
const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
|
||||
const currentWorkspace = useWorkspace(workspaceId);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const navigateHelper = useNavigateHelper();
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const saveToCollection = useCallback(
|
||||
async (collection: Collection) => {
|
||||
await setting.saveCollection(collection);
|
||||
setting.selectCollection(collection.id);
|
||||
console.log(setting.currentCollection.filterList);
|
||||
await setting.createCollection({
|
||||
...collection,
|
||||
mode: 'rule',
|
||||
filterList: setting.currentCollection.filterList,
|
||||
});
|
||||
navigateHelper.jumpToCollection(workspaceId, collection.id);
|
||||
},
|
||||
[setting]
|
||||
);
|
||||
const getPageInfoById = useGetPageInfoById(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
[setting, navigateHelper, workspaceId]
|
||||
);
|
||||
if (!setting.isDefault || !setting.currentCollection.filterList.length) {
|
||||
return null;
|
||||
@@ -59,16 +66,9 @@ const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
|
||||
</div>
|
||||
<div>
|
||||
{setting.currentCollection.filterList.length > 0 ? (
|
||||
<SaveCollectionButton
|
||||
propertiesMeta={
|
||||
currentWorkspace.blockSuiteWorkspace.meta
|
||||
.properties as PropertiesMeta
|
||||
}
|
||||
getPageInfo={getPageInfoById}
|
||||
<SaveAsCollectionButton
|
||||
onConfirm={saveToCollection}
|
||||
filterList={setting.currentCollection.filterList}
|
||||
workspaceId={workspaceId}
|
||||
></SaveCollectionButton>
|
||||
></SaveAsCollectionButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,14 +78,17 @@ const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
|
||||
export function WorkspaceHeader({
|
||||
currentWorkspaceId,
|
||||
currentEntry,
|
||||
rightSlot,
|
||||
}: WorkspaceHeaderProps<WorkspaceFlavour>) {
|
||||
const setAppHeader = useSetAtom(appHeaderAtom);
|
||||
|
||||
const currentWorkspace = useWorkspace(currentWorkspaceId);
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const getPageInfoById = useGetPageInfoById(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const workspace = currentWorkspace.blockSuiteWorkspace;
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const config = useAllPageListConfig();
|
||||
const userInfo = useDeleteCollectionInfo();
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
// route in all page
|
||||
if (
|
||||
@@ -99,25 +102,24 @@ export function WorkspaceHeader({
|
||||
ref={setAppHeader}
|
||||
left={
|
||||
<CollectionList
|
||||
userInfo={userInfo}
|
||||
allPageListConfig={config}
|
||||
setting={setting}
|
||||
getPageInfo={getPageInfoById}
|
||||
propertiesMeta={
|
||||
currentWorkspace.blockSuiteWorkspace.meta.properties
|
||||
}
|
||||
propertiesMeta={workspace.meta.properties}
|
||||
/>
|
||||
}
|
||||
right={rightSlot}
|
||||
center={<WorkspaceModeFilterTab />}
|
||||
/>
|
||||
{<FilterContainer workspaceId={currentWorkspaceId} />}
|
||||
<FilterContainer workspaceId={currentWorkspaceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// route in shared or trash
|
||||
// route in shared
|
||||
if (
|
||||
'subPath' in currentEntry &&
|
||||
(currentEntry.subPath === WorkspaceSubPath.SHARED ||
|
||||
currentEntry.subPath === WorkspaceSubPath.TRASH)
|
||||
currentEntry.subPath === WorkspaceSubPath.SHARED
|
||||
) {
|
||||
return (
|
||||
<Header
|
||||
@@ -128,11 +130,28 @@ export function WorkspaceHeader({
|
||||
);
|
||||
}
|
||||
|
||||
// route in trash
|
||||
if (
|
||||
'subPath' in currentEntry &&
|
||||
currentEntry.subPath === WorkspaceSubPath.TRASH
|
||||
) {
|
||||
return (
|
||||
<Header
|
||||
mainContainerAtom={mainContainerAtom}
|
||||
ref={setAppHeader}
|
||||
left={
|
||||
<div className={styles.trashTitle}>
|
||||
<DeleteIcon className={styles.trashIcon} />
|
||||
{t['com.affine.workspaceSubPath.trash']()}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// route in edit page
|
||||
if ('pageId' in currentEntry) {
|
||||
const currentPage = currentWorkspace.blockSuiteWorkspace.getPage(
|
||||
currentEntry.pageId
|
||||
);
|
||||
const currentPage = workspace.getPage(currentEntry.pageId);
|
||||
const sharePageModal = currentPage ? (
|
||||
<SharePageModal workspace={currentWorkspace} page={currentPage} />
|
||||
) : null;
|
||||
@@ -152,6 +171,7 @@ export function WorkspaceHeader({
|
||||
<PluginHeader />
|
||||
</div>
|
||||
}
|
||||
bottomBorder
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { toast } from '@affine/component';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
FavoriteTag,
|
||||
} from '@affine/component/page-list';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import { useCurrentWorkspace } from '../current/use-current-workspace';
|
||||
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
|
||||
|
||||
export const useAllPageListConfig = () => {
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const workspace = currentWorkspace.blockSuiteWorkspace;
|
||||
const pageMetas = useBlockSuitePageMeta(workspace);
|
||||
const { isPreferredEdgeless } = usePageHelper(workspace);
|
||||
const pageMap = useMemo(
|
||||
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
|
||||
[pageMetas]
|
||||
);
|
||||
const { toggleFavorite } = useBlockSuiteMetaHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
const onToggleFavoritePage = useCallback(
|
||||
(page: PageMeta) => {
|
||||
const status = page.favorite;
|
||||
toggleFavorite(page.id);
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
},
|
||||
[t, toggleFavorite]
|
||||
);
|
||||
return useMemo<AllPageListConfig>(() => {
|
||||
return {
|
||||
allPages: pageMetas,
|
||||
isEdgeless: isPreferredEdgeless,
|
||||
workspace: currentWorkspace.blockSuiteWorkspace,
|
||||
getPage: id => pageMap[id],
|
||||
favoriteRender: page => {
|
||||
return (
|
||||
<FavoriteTag
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={() => onToggleFavoritePage(page)}
|
||||
active={!!page.favorite}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [
|
||||
currentWorkspace.blockSuiteWorkspace,
|
||||
isPreferredEdgeless,
|
||||
pageMetas,
|
||||
pageMap,
|
||||
onToggleFavoritePage,
|
||||
]);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { useCallback } from 'react';
|
||||
import { setPageModeAtom } from '../../atoms';
|
||||
import { currentModeAtom } from '../../atoms/mode';
|
||||
import type { BlockSuiteWorkspace } from '../../shared';
|
||||
import { getWorkspaceSetting } from '../../utils/workspace-setting';
|
||||
import { useReferenceLinkHelper } from './use-reference-link-helper';
|
||||
|
||||
export function useBlockSuiteMetaHelper(
|
||||
@@ -82,8 +83,9 @@ export function useBlockSuiteMetaHelper(
|
||||
trashRelate: isRoot ? parentMeta?.id : undefined,
|
||||
});
|
||||
setPageReadonly(pageId, true);
|
||||
getWorkspaceSetting(blockSuiteWorkspace).deletePages([pageId]);
|
||||
},
|
||||
[getPageMeta, metas, setPageMeta, setPageReadonly]
|
||||
[blockSuiteWorkspace, getPageMeta, metas, setPageMeta, setPageReadonly]
|
||||
);
|
||||
|
||||
const restoreFromTrash = useCallback(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useDeleteCollectionInfo = () => {
|
||||
const user = useSession().data?.user;
|
||||
return useMemo(
|
||||
() => (user ? { userName: user.name ?? '', userId: user.id } : null),
|
||||
[user]
|
||||
);
|
||||
};
|
||||
@@ -35,8 +35,8 @@ export function useRegisterBlocksuiteEditorCommands(
|
||||
const onClickDelete = useCallback(() => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageId: pageId,
|
||||
pageTitle: pageMeta.title,
|
||||
pageIds: [pageId],
|
||||
pageTitles: [pageMeta.title],
|
||||
});
|
||||
}, [pageId, pageMeta.title, setTrashModal]);
|
||||
|
||||
|
||||
@@ -10,14 +10,16 @@ import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
|
||||
export function useTrashModalHelper(blocksuiteWorkspace: Workspace) {
|
||||
const t = useAFFiNEI18N();
|
||||
const [trashModal, setTrashModal] = useAtom(trashModalAtom);
|
||||
const { pageId } = trashModal;
|
||||
const { pageIds } = trashModal;
|
||||
const { removeToTrash } = useBlockSuiteMetaHelper(blocksuiteWorkspace);
|
||||
|
||||
const handleOnConfirm = useCallback(() => {
|
||||
removeToTrash(pageId);
|
||||
pageIds.forEach(pageId => {
|
||||
removeToTrash(pageId);
|
||||
});
|
||||
toast(t['com.affine.toastMessage.movedTrash']());
|
||||
setTrashModal({ ...trashModal, open: false });
|
||||
}, [pageId, removeToTrash, setTrashModal, t, trashModal]);
|
||||
}, [pageIds, removeToTrash, setTrashModal, t, trashModal]);
|
||||
|
||||
return {
|
||||
trashModal,
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum RouteLogic {
|
||||
PUSH = 'push',
|
||||
}
|
||||
|
||||
// todo: add a name -> path helper in the results
|
||||
export function useNavigateHelper() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -28,6 +29,18 @@ export function useNavigateHelper() {
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const jumpToCollection = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
collectionId: string,
|
||||
logic: RouteLogic = RouteLogic.PUSH
|
||||
) => {
|
||||
return navigate(`/workspace/${workspaceId}/collection/${collectionId}`, {
|
||||
replace: logic === RouteLogic.REPLACE,
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const jumpToPublicWorkspacePage = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
@@ -116,6 +129,7 @@ export function useNavigateHelper() {
|
||||
openPage,
|
||||
jumpToExpired,
|
||||
jumpToSignIn,
|
||||
jumpToCollection,
|
||||
}),
|
||||
[
|
||||
jumpTo404,
|
||||
@@ -126,6 +140,7 @@ export function useNavigateHelper() {
|
||||
jumpToSignIn,
|
||||
jumpToSubPath,
|
||||
openPage,
|
||||
jumpToCollection,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Content, displayFlex } from '@affine/component';
|
||||
import {
|
||||
AppSidebarFallback,
|
||||
appSidebarResizingAtom,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import { BlockHubWrapper } from '@affine/component/block-hub';
|
||||
import type { DraggableTitleCellData } from '@affine/component/page-list';
|
||||
import { StyledTitleLink } from '@affine/component/page-list';
|
||||
import {
|
||||
type DraggableTitleCellData,
|
||||
PageListDragOverlay,
|
||||
} from '@affine/component/page-list';
|
||||
import {
|
||||
MainContainer,
|
||||
ToolContainer,
|
||||
@@ -197,12 +198,9 @@ export const WorkspaceLayoutInner = ({
|
||||
const resizing = useAtomValue(appSidebarResizingAtom);
|
||||
|
||||
const sensors = useSensors(
|
||||
// Delay 10ms after mousedown
|
||||
// Otherwise clicks would be intercepted
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
delay: 500,
|
||||
tolerance: 10,
|
||||
distance: 10,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -288,34 +286,18 @@ export const WorkspaceLayoutInner = ({
|
||||
};
|
||||
|
||||
function PageListTitleCellDragOverlay() {
|
||||
const { active } = useDndContext();
|
||||
|
||||
const { active, over } = useDndContext();
|
||||
const renderChildren = useCallback(
|
||||
({ icon, pageTitle }: DraggableTitleCellData) => {
|
||||
({ pageTitle }: DraggableTitleCellData) => {
|
||||
return (
|
||||
<StyledTitleLink>
|
||||
{icon}
|
||||
<Content ellipsis={true} color="inherit">
|
||||
{pageTitle}
|
||||
</Content>
|
||||
</StyledTitleLink>
|
||||
<PageListDragOverlay over={!!over}>{pageTitle}</PageListDragOverlay>
|
||||
);
|
||||
},
|
||||
[]
|
||||
[over]
|
||||
);
|
||||
|
||||
return (
|
||||
<DragOverlay
|
||||
style={{
|
||||
zIndex: 1001,
|
||||
backgroundColor: 'var(--affine-black-10)',
|
||||
padding: '0 30px',
|
||||
cursor: 'default',
|
||||
borderRadius: 10,
|
||||
...displayFlex('flex-start', 'center'),
|
||||
}}
|
||||
dropAnimation={null}
|
||||
>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{active
|
||||
? renderChildren(active.data.current as DraggableTitleCellData)
|
||||
: null}
|
||||
|
||||
66
packages/frontend/core/src/pages/workspace/all-page.css.ts
Normal file
66
packages/frontend/core/src/pages/workspace/all-page.css.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexFlow: 'column',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const scrollContainer = style({
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
paddingBottom: '32px',
|
||||
});
|
||||
|
||||
export const allPagesHeader = style({
|
||||
padding: '48px 16px 20px 24px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const allPagesHeaderTitle = style({
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const titleIcon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const titleCollectionName = style({
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const floatingToolbar = style({
|
||||
position: 'absolute',
|
||||
bottom: 26,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const toolbarSelectedNumber = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const headerCreateNewButton = style({
|
||||
transition: 'opacity 0.1s ease-in-out',
|
||||
});
|
||||
|
||||
export const newPageButtonLabel = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const headerCreateNewButtonHidden = style({
|
||||
opacity: 0,
|
||||
});
|
||||
@@ -1,16 +1,50 @@
|
||||
import { useCollectionManager } from '@affine/component/page-list';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { toast } from '@affine/component';
|
||||
import {
|
||||
currentCollectionAtom,
|
||||
FloatingToolbar,
|
||||
NewPageButton as PureNewPageButton,
|
||||
OperationCell,
|
||||
PageList,
|
||||
type PageListHandle,
|
||||
PageListScrollContainer,
|
||||
useCollectionManager,
|
||||
} from '@affine/component/page-list';
|
||||
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
CloseIcon,
|
||||
DeleteIcon,
|
||||
PlusIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
|
||||
import { getCurrentStore } from '@toeverything/infra/atom';
|
||||
import { useCallback } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { LoaderFunction } from 'react-router-dom';
|
||||
import { redirect } from 'react-router-dom';
|
||||
import { NIL } from 'uuid';
|
||||
|
||||
import { getUIAdapter } from '../../adapters/workspace';
|
||||
import { collectionsCRUDAtom } from '../../atoms/collections';
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import { WorkspaceHeader } from '../../components/workspace-header';
|
||||
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
|
||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||
import * as styles from './all-page.css';
|
||||
import { EmptyPageList } from './page-list-empty';
|
||||
import { useFilteredPageMetas } from './pages';
|
||||
|
||||
export const loader: LoaderFunction = async args => {
|
||||
const rootStore = getCurrentStore();
|
||||
@@ -27,39 +61,274 @@ export const loader: LoaderFunction = async args => {
|
||||
return redirect(`/workspace/${workspace.id}/${page.id}`);
|
||||
}
|
||||
}
|
||||
rootStore.set(currentCollectionAtom, NIL);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const AllPage = () => {
|
||||
const { jumpToPage } = useNavigateHelper();
|
||||
const PageListHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const title = useMemo(() => {
|
||||
if (setting.isDefault) {
|
||||
return t['com.affine.all-pages.header']();
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{t['com.affine.collections.header']()} /
|
||||
<div className={styles.titleIcon}>
|
||||
<ViewLayersIcon />
|
||||
</div>
|
||||
<div className={styles.titleCollectionName}>
|
||||
{setting.currentCollection.name}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [setting.currentCollection.name, setting.isDefault, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.allPagesHeader}>
|
||||
<div className={styles.allPagesHeaderTitle}>{title}</div>
|
||||
<NewPageButton>{t['New Page']()}</NewPageButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const usePageOperationsRenderer = () => {
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const setting = useCollectionManager(currentCollectionsAtom);
|
||||
const onClickPage = useCallback(
|
||||
(pageId: string, newTab?: boolean) => {
|
||||
assertExists(currentWorkspace);
|
||||
if (newTab) {
|
||||
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
|
||||
} else {
|
||||
jumpToPage(currentWorkspace.id, pageId);
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const { toggleFavorite } = useBlockSuiteMetaHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => {
|
||||
const onDisablePublicSharing = () => {
|
||||
toast('Successfully disabled', {
|
||||
portal: document.body,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<OperationCell
|
||||
favorite={!!page.favorite}
|
||||
isPublic={!!page.isPublic}
|
||||
onDisablePublicSharing={onDisablePublicSharing}
|
||||
link={`/workspace/${currentWorkspace.id}/${page.id}`}
|
||||
onRemoveToTrash={() =>
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: [page.id],
|
||||
pageTitles: [page.title],
|
||||
})
|
||||
}
|
||||
onToggleFavoritePage={() => {
|
||||
const status = page.favorite;
|
||||
toggleFavorite(page.id);
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
|
||||
);
|
||||
|
||||
return pageOperationsRenderer;
|
||||
};
|
||||
|
||||
const PageListFloatingToolbar = ({
|
||||
selectedIds,
|
||||
onClose,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const open = selectedIds.length > 0;
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[currentWorkspace, jumpToPage]
|
||||
[onClose]
|
||||
);
|
||||
const { PageList, Header } = getUIAdapter(currentWorkspace.flavour);
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const handleMultiDelete = useCallback(() => {
|
||||
const pageNameMapping = Object.fromEntries(
|
||||
pageMetas.map(meta => [meta.id, meta.title])
|
||||
);
|
||||
|
||||
const pageNames = selectedIds.map(id => pageNameMapping[id] ?? '');
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageIds: selectedIds,
|
||||
pageTitles: pageNames,
|
||||
});
|
||||
}, [pageMetas, selectedIds, setTrashModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
subPath: WorkspaceSubPath.ALL,
|
||||
}}
|
||||
<FloatingToolbar
|
||||
className={styles.floatingToolbar}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<FloatingToolbar.Item>
|
||||
<Trans
|
||||
i18nKey="com.affine.page.toolbar.selected"
|
||||
count={selectedIds.length}
|
||||
>
|
||||
<div className={styles.toolbarSelectedNumber}>
|
||||
{{ count: selectedIds.length } as any}
|
||||
</div>
|
||||
pages selected
|
||||
</Trans>
|
||||
</FloatingToolbar.Item>
|
||||
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
|
||||
<FloatingToolbar.Separator />
|
||||
<FloatingToolbar.Button
|
||||
onClick={handleMultiDelete}
|
||||
icon={<DeleteIcon />}
|
||||
type="danger"
|
||||
/>
|
||||
<PageList
|
||||
collection={setting.currentCollection}
|
||||
onOpenPage={onClickPage}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
</>
|
||||
</FloatingToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const NewPageButton = ({
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
}: PropsWithChildren<{
|
||||
className?: string;
|
||||
size?: 'small' | 'default';
|
||||
}>) => {
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const { importFile, createEdgeless, createPage } = usePageHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
return (
|
||||
<div className={className}>
|
||||
<PureNewPageButton
|
||||
size={size}
|
||||
importFile={importFile}
|
||||
createNewEdgeless={createEdgeless}
|
||||
createNewPage={createPage}
|
||||
>
|
||||
<div className={styles.newPageButtonLabel}>{children}</div>
|
||||
</PureNewPageButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// even though it is called all page, it is also being used for collection route as well
|
||||
export const AllPage = () => {
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const { isPreferredEdgeless } = usePageHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const pageOperationsRenderer = usePageOperationsRenderer();
|
||||
const filteredPageMetas = useFilteredPageMetas(
|
||||
'all',
|
||||
pageMetas,
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const pageListRef = useRef<PageListHandle>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const deselectAllAndToggleSelect = useCallback(() => {
|
||||
setSelectedPageIds([]);
|
||||
pageListRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
|
||||
// make sure selected id is in the filtered list
|
||||
const filteredSelectedPageIds = useMemo(() => {
|
||||
const ids = filteredPageMetas.map(page => page.id);
|
||||
return selectedPageIds.filter(id => ids.includes(id));
|
||||
}, [filteredPageMetas, selectedPageIds]);
|
||||
|
||||
const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false);
|
||||
// when PageListScrollContainer scrolls above 40px, show the create new page button on header
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const handleScroll = () => {
|
||||
setTimeout(() => {
|
||||
const scrollTop = container.scrollTop ?? 0;
|
||||
setShowHeaderCreateNewPage(scrollTop > 40);
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{currentWorkspace.flavour !== WorkspaceFlavour.AFFINE_PUBLIC ? (
|
||||
<WorkspaceHeader
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
subPath: WorkspaceSubPath.ALL,
|
||||
}}
|
||||
rightSlot={
|
||||
<NewPageButton
|
||||
size="small"
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
!showHeaderCreateNewPage && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NewPageButton>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<PageListScrollContainer
|
||||
ref={containerRef}
|
||||
className={styles.scrollContainer}
|
||||
>
|
||||
<PageListHeader />
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<>
|
||||
<PageList
|
||||
ref={pageListRef}
|
||||
selectable="toggle"
|
||||
draggable
|
||||
selectedPageIds={filteredSelectedPageIds}
|
||||
onSelectedPageIdsChange={setSelectedPageIds}
|
||||
pages={filteredPageMetas}
|
||||
clickMode="link"
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
<PageListFloatingToolbar
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onClose={deselectAllAndToggleSelect}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</PageListScrollContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
25
packages/frontend/core/src/pages/workspace/collection.css.ts
Normal file
25
packages/frontend/core/src/pages/workspace/collection.css.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const placeholderButton = style({
|
||||
padding: '8px 18px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontSize: 15,
|
||||
lineHeight: '24px',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const button = style({
|
||||
userSelect: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
275
packages/frontend/core/src/pages/workspace/collection.tsx
Normal file
275
packages/frontend/core/src/pages/workspace/collection.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import {
|
||||
AffineShapeIcon,
|
||||
currentCollectionAtom,
|
||||
useCollectionManager,
|
||||
useEditCollection,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import {
|
||||
CloseIcon,
|
||||
FilterIcon,
|
||||
PageIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { getCurrentStore } from '@toeverything/infra/atom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { type LoaderFunction, redirect, useParams } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
collectionsCRUDAtom,
|
||||
pageCollectionBaseAtom,
|
||||
} from '../../atoms/collections';
|
||||
import { useAllPageListConfig } from '../../hooks/affine/use-all-page-list-config';
|
||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { WorkspaceSubPath } from '../../shared';
|
||||
import { getWorkspaceSetting } from '../../utils/workspace-setting';
|
||||
import { AllPage } from './all-page';
|
||||
import * as styles from './collection.css';
|
||||
|
||||
export const loader: LoaderFunction = async args => {
|
||||
const rootStore = getCurrentStore();
|
||||
if (!args.params.collectionId) {
|
||||
return redirect('/404');
|
||||
}
|
||||
rootStore.set(currentCollectionAtom, args.params.collectionId);
|
||||
return null;
|
||||
};
|
||||
|
||||
export const Component = function CollectionPage() {
|
||||
const { collections, loading } = useAtomValue(pageCollectionBaseAtom);
|
||||
const navigate = useNavigateHelper();
|
||||
const params = useParams();
|
||||
const [workspace] = useCurrentWorkspace();
|
||||
const collection = collections.find(v => v.id === params.collectionId);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
useEffect(() => {
|
||||
if (!loading && !collection) {
|
||||
navigate.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
||||
const collection = getWorkspaceSetting(
|
||||
workspace.blockSuiteWorkspace
|
||||
).collectionsTrash.find(v => v.collection.id === params.collectionId);
|
||||
let text = 'Collection is not exist';
|
||||
if (collection) {
|
||||
if (collection.userId) {
|
||||
text = `${collection.collection.name} is deleted by ${collection.userName}`;
|
||||
} else {
|
||||
text = `${collection.collection.name} is deleted`;
|
||||
}
|
||||
}
|
||||
pushNotification({
|
||||
type: 'error',
|
||||
title: text,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
collection,
|
||||
loading,
|
||||
navigate,
|
||||
params.collectionId,
|
||||
pushNotification,
|
||||
workspace.blockSuiteWorkspace,
|
||||
workspace.id,
|
||||
]);
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
return isEmpty(collection) ? (
|
||||
<Placeholder collection={collection} />
|
||||
) : (
|
||||
<AllPage />
|
||||
);
|
||||
};
|
||||
|
||||
const Placeholder = ({ collection }: { collection: Collection }) => {
|
||||
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
|
||||
const { node, open } = useEditCollection(useAllPageListConfig());
|
||||
const openPageEdit = useCallback(() => {
|
||||
open({ ...collection, mode: 'page' }).then(updateCollection);
|
||||
}, [open, collection, updateCollection]);
|
||||
const openRuleEdit = useCallback(() => {
|
||||
open({ ...collection, mode: 'rule' }).then(updateCollection);
|
||||
}, [collection, open, updateCollection]);
|
||||
const [showTips, setShowTips] = useState(false);
|
||||
useEffect(() => {
|
||||
setShowTips(!localStorage.getItem('hide-empty-collection-help-info'));
|
||||
}, []);
|
||||
const hideTips = useCallback(() => {
|
||||
setShowTips(false);
|
||||
localStorage.setItem('hide-empty-collection-help-info', 'true');
|
||||
}, []);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '12px 24px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
}}
|
||||
>
|
||||
<ViewLayersIcon style={{ color: 'var(--affine-icon-color)' }} />
|
||||
All Collections
|
||||
<div>/</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="collection-name"
|
||||
style={{ fontWeight: 600, color: 'var(--affine-text-primary-color)' }}
|
||||
>
|
||||
{collection.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 64,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 432,
|
||||
marginTop: 118,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 18,
|
||||
margin: '118px 12px 0',
|
||||
}}
|
||||
>
|
||||
<AffineShapeIcon />
|
||||
<div
|
||||
style={{
|
||||
fontSize: 20,
|
||||
lineHeight: '28px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
}}
|
||||
>
|
||||
Empty Collection
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Collection is a smart folder where you can manually add pages or
|
||||
automatically add pages through rules.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px 32px',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div onClick={openPageEdit} className={styles.placeholderButton}>
|
||||
<PageIcon
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: 'var(--affine-icon-color)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ padding: '0 4px' }}>Add Pages</span>
|
||||
</div>
|
||||
<div onClick={openRuleEdit} className={styles.placeholderButton}>
|
||||
<FilterIcon
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: 'var(--affine-icon-color)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ padding: '0 4px' }}>Add Rules</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showTips ? (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 452,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'var(--affine-background-overlay-panel-color)',
|
||||
padding: 10,
|
||||
gap: 14,
|
||||
margin: '0 12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>HELP INFO</div>
|
||||
<CloseIcon
|
||||
className={styles.button}
|
||||
style={{ width: 16, height: 16 }}
|
||||
onClick={hideTips}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600 }}>Add pages:</span> You can
|
||||
freely select pages and add them to the collection.
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600 }}>Add rules:</span> Rules are
|
||||
based on filtering. After adding rules, pages that meet the
|
||||
requirements will be automatically added to the current
|
||||
collection.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{node}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isEmpty = (collection: Collection) => {
|
||||
return (
|
||||
(collection.mode === 'page' && collection.pages.length === 0) ||
|
||||
(collection.mode === 'rule' &&
|
||||
collection.allowList.length === 0 &&
|
||||
collection.filterList.length === 0)
|
||||
);
|
||||
};
|
||||
@@ -23,11 +23,12 @@ import type { Map as YMap } from 'yjs';
|
||||
|
||||
import { getUIAdapter } from '../../adapters/workspace';
|
||||
import { setPageModeAtom } from '../../atoms';
|
||||
import { collectionsCRUDAtom } from '../../atoms/collections';
|
||||
import { currentModeAtom } from '../../atoms/mode';
|
||||
import { WorkspaceHeader } from '../../components/workspace-header';
|
||||
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
|
||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||
|
||||
const DetailPageImpl = (): ReactElement => {
|
||||
const { openPage, jumpToSubPath } = useNavigateHelper();
|
||||
@@ -36,7 +37,7 @@ const DetailPageImpl = (): ReactElement => {
|
||||
assertExists(currentWorkspace);
|
||||
assertExists(currentPageId);
|
||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||
const collectionManager = useCollectionManager(currentCollectionsAtom);
|
||||
const collectionManager = useCollectionManager(collectionsCRUDAtom);
|
||||
const mode = useAtomValue(currentModeAtom);
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode);
|
||||
@@ -66,7 +67,6 @@ const DetailPageImpl = (): ReactElement => {
|
||||
});
|
||||
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
|
||||
jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
|
||||
collectionManager.backToAll();
|
||||
collectionManager.setTemporaryFilter([createTagFilter(tagId)]);
|
||||
});
|
||||
return () => {
|
||||
@@ -86,10 +86,10 @@ const DetailPageImpl = (): ReactElement => {
|
||||
]
|
||||
);
|
||||
|
||||
const { PageDetail, Header } = getUIAdapter(currentWorkspace.flavour);
|
||||
const { PageDetail } = getUIAdapter(currentWorkspace.flavour);
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
<WorkspaceHeader
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
pageId: currentPageId,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const pageListEmptyStyle = style({
|
||||
height: 'calc(100% - 52px)',
|
||||
});
|
||||
|
||||
export const emptyDescButton = style({
|
||||
cursor: 'pointer',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
background: 'var(--affine-background-code-block)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderRadius: '4px',
|
||||
padding: '0 6px',
|
||||
boxSizing: 'border-box',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const emptyDescKbd = style([
|
||||
emptyDescButton,
|
||||
{
|
||||
cursor: 'text',
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import * as styles from './page-list-empty.css';
|
||||
|
||||
export const EmptyPageList = ({
|
||||
type,
|
||||
blockSuiteWorkspace,
|
||||
}: {
|
||||
type: 'all' | 'trash' | 'shared' | 'public';
|
||||
blockSuiteWorkspace: Workspace;
|
||||
}) => {
|
||||
const { createPage } = usePageHelper(blockSuiteWorkspace);
|
||||
const t = useAFFiNEI18N();
|
||||
const onCreatePage = useCallback(() => {
|
||||
createPage?.();
|
||||
}, [createPage]);
|
||||
|
||||
const getEmptyDescription = () => {
|
||||
if (type === 'all') {
|
||||
const createNewPageButton = (
|
||||
<button className={styles.emptyDescButton} onClick={onCreatePage}>
|
||||
New Page
|
||||
</button>
|
||||
);
|
||||
if (environment.isDesktop) {
|
||||
const shortcut = environment.isMacOs ? '⌘ + N' : 'Ctrl + N';
|
||||
return (
|
||||
<Trans i18nKey="emptyAllPagesClient">
|
||||
Click on the {createNewPageButton} button Or press
|
||||
<kbd className={styles.emptyDescKbd}>{{ shortcut } as any}</kbd> to
|
||||
create your first page.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Trans i18nKey="emptyAllPages">
|
||||
Click on the
|
||||
{createNewPageButton}
|
||||
button to create your first page.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
if (type === 'trash') {
|
||||
return t['emptyTrash']();
|
||||
}
|
||||
if (type === 'shared') {
|
||||
return t['emptySharedPages']();
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.pageListEmptyStyle}>
|
||||
<Empty
|
||||
title={t['com.affine.emptyDesc']()}
|
||||
description={getEmptyDescription()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
packages/frontend/core/src/pages/workspace/pages.tsx
Normal file
52
packages/frontend/core/src/pages/workspace/pages.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { filterPage, useCollectionManager } from '@affine/component/page-list';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { allPageModeSelectAtom } from '../../atoms';
|
||||
import { collectionsCRUDAtom } from '../../atoms/collections';
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import type { BlockSuiteWorkspace } from '../../shared';
|
||||
|
||||
export const useFilteredPageMetas = (
|
||||
route: 'all' | 'trash',
|
||||
pageMetas: PageMeta[],
|
||||
workspace: BlockSuiteWorkspace
|
||||
) => {
|
||||
const { isPreferredEdgeless } = usePageHelper(workspace);
|
||||
const pageMode = useAtomValue(allPageModeSelectAtom);
|
||||
const { currentCollection } = useCollectionManager(collectionsCRUDAtom);
|
||||
|
||||
const filteredPageMetas = useMemo(
|
||||
() =>
|
||||
pageMetas
|
||||
.filter(pageMeta => {
|
||||
if (pageMode === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (pageMode === 'edgeless') {
|
||||
return isPreferredEdgeless(pageMeta.id);
|
||||
}
|
||||
if (pageMode === 'page') {
|
||||
return !isPreferredEdgeless(pageMeta.id);
|
||||
}
|
||||
console.error('unknown filter mode', pageMeta, pageMode);
|
||||
return true;
|
||||
})
|
||||
.filter(pageMeta => {
|
||||
if (
|
||||
(route === 'trash' && !pageMeta.trash) ||
|
||||
(route === 'all' && pageMeta.trash)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!currentCollection) {
|
||||
return true;
|
||||
}
|
||||
return filterPage(currentCollection, pageMeta);
|
||||
}),
|
||||
[pageMetas, pageMode, isPreferredEdgeless, route, currentCollection]
|
||||
);
|
||||
|
||||
return filteredPageMetas;
|
||||
};
|
||||
@@ -1,43 +1,91 @@
|
||||
import { toast } from '@affine/component';
|
||||
import {
|
||||
PageList,
|
||||
PageListScrollContainer,
|
||||
TrashOperationCell,
|
||||
} from '@affine/component/page-list';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { getUIAdapter } from '../../adapters/workspace';
|
||||
import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page-list';
|
||||
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
|
||||
import { WorkspaceHeader } from '../../components/workspace-header';
|
||||
import { useBlockSuiteMetaHelper } from '../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||
import * as styles from './all-page.css';
|
||||
import { EmptyPageList } from './page-list-empty';
|
||||
import { useFilteredPageMetas } from './pages';
|
||||
|
||||
export const TrashPage = () => {
|
||||
const { jumpToPage } = useNavigateHelper();
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const onClickPage = useCallback(
|
||||
(pageId: string, newTab?: boolean) => {
|
||||
assertExists(currentWorkspace);
|
||||
if (newTab) {
|
||||
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
|
||||
} else {
|
||||
jumpToPage(currentWorkspace.id, pageId);
|
||||
}
|
||||
},
|
||||
[currentWorkspace, jumpToPage]
|
||||
);
|
||||
// todo(himself65): refactor to plugin
|
||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||
assertExists(blockSuiteWorkspace);
|
||||
const { Header } = getUIAdapter(currentWorkspace.flavour);
|
||||
const pageMetas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
const filteredPageMetas = useFilteredPageMetas(
|
||||
'trash',
|
||||
pageMetas,
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const { restoreFromTrash, permanentlyDeletePage } =
|
||||
useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||
const { isPreferredEdgeless } = usePageHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => {
|
||||
const onRestorePage = () => {
|
||||
restoreFromTrash(page.id);
|
||||
toast(
|
||||
t['com.affine.toastMessage.restored']({
|
||||
title: page.title || 'Untitled',
|
||||
})
|
||||
);
|
||||
};
|
||||
const onPermanentlyDeletePage = () => {
|
||||
permanentlyDeletePage(page.id);
|
||||
toast(t['com.affine.toastMessage.permanentlyDeleted']());
|
||||
};
|
||||
return (
|
||||
<TrashOperationCell
|
||||
onPermanentlyDeletePage={onPermanentlyDeletePage}
|
||||
onRestorePage={onRestorePage}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[permanentlyDeletePage, restoreFromTrash, t]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
<WorkspaceHeader
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
subPath: WorkspaceSubPath.TRASH,
|
||||
}}
|
||||
/>
|
||||
<BlockSuitePageList
|
||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
||||
onOpenPage={onClickPage}
|
||||
listType="trash"
|
||||
/>
|
||||
<div className={styles.root}>
|
||||
<PageListScrollContainer className={styles.scrollContainer}>
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<PageList
|
||||
pages={filteredPageMetas}
|
||||
clickMode="link"
|
||||
groupBy={false}
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="trash"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</PageListScrollContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ export const routes = [
|
||||
path: 'all',
|
||||
lazy: () => import('./pages/workspace/all-page'),
|
||||
},
|
||||
{
|
||||
path: 'collection/:collectionId',
|
||||
lazy: () => import('./pages/workspace/collection'),
|
||||
},
|
||||
{
|
||||
path: 'trash',
|
||||
lazy: () => import('./pages/workspace/trash-page'),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { filterByFilterList } from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
export const filterPage = (collection: Collection, page: PageMeta) => {
|
||||
if (collection.excludeList?.includes(page.id)) {
|
||||
return false;
|
||||
}
|
||||
if (collection.allowList?.includes(page.id)) {
|
||||
return true;
|
||||
}
|
||||
return filterByFilterList(collection.filterList, {
|
||||
'Is Favourited': !!page.favorite,
|
||||
Created: page.createDate,
|
||||
Updated: page.updatedDate ?? page.createDate,
|
||||
Tags: page.tags,
|
||||
});
|
||||
};
|
||||
@@ -1,208 +1,43 @@
|
||||
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 type { Workspace } from '@blocksuite/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Map as YMap } from 'yjs';
|
||||
import { Doc as YDoc } from 'yjs';
|
||||
export class UserSetting {
|
||||
constructor(
|
||||
private workspace: Workspace,
|
||||
private userId: string
|
||||
) {}
|
||||
|
||||
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)
|
||||
get setting(): YDoc {
|
||||
const rootDoc = this.workspace.doc;
|
||||
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
|
||||
if (!settingMap.has(this.userId)) {
|
||||
settingMap.set(
|
||||
this.userId,
|
||||
new YDoc({
|
||||
guid: nanoid(),
|
||||
})
|
||||
);
|
||||
})
|
||||
.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) {
|
||||
const fn = () => {
|
||||
getCollections(localCRUD).then(async collections => {
|
||||
const workspaceId = (await currentWorkspacePromise).id;
|
||||
subscriber.next(
|
||||
collections.filter(c => c.workspaceId === workspaceId)
|
||||
);
|
||||
});
|
||||
};
|
||||
fn();
|
||||
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: nanoid(),
|
||||
})
|
||||
);
|
||||
}
|
||||
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, _, 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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return settingMap.get(this.userId) as YDoc;
|
||||
}
|
||||
);
|
||||
|
||||
get loaded(): Promise<void> {
|
||||
if (!this.setting.isLoaded) {
|
||||
this.setting.load();
|
||||
}
|
||||
return this.setting.whenLoaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
get view() {
|
||||
return this.setting.getMap('view') as YMap<Collection>;
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserSetting = (workspace: Workspace, userId: string) => {
|
||||
return new UserSetting(workspace, userId);
|
||||
};
|
||||
|
||||
125
packages/frontend/core/src/utils/workspace-setting.ts
Normal file
125
packages/frontend/core/src/utils/workspace-setting.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
Collection,
|
||||
DeleteCollectionInfo,
|
||||
DeletedCollection,
|
||||
} from '@affine/env/filter';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { Array as YArray } from 'yjs';
|
||||
|
||||
import { updateFirstOfYArray } from './yjs-utils';
|
||||
|
||||
const COLLECTIONS_KEY = 'collections';
|
||||
const COLLECTIONS_TRASH_KEY = 'collections_trash';
|
||||
const SETTING_KEY = 'setting';
|
||||
|
||||
export class WorkspaceSetting {
|
||||
constructor(private workspace: Workspace) {}
|
||||
|
||||
get doc() {
|
||||
return this.workspace.doc;
|
||||
}
|
||||
|
||||
get setting() {
|
||||
return this.workspace.doc.getMap(SETTING_KEY);
|
||||
}
|
||||
|
||||
get collectionsYArray() {
|
||||
if (!this.setting.has(COLLECTIONS_KEY)) {
|
||||
this.setting.set(COLLECTIONS_KEY, new YArray());
|
||||
}
|
||||
return this.setting.get(COLLECTIONS_KEY) as YArray<Collection>;
|
||||
}
|
||||
|
||||
get collectionsTrashYArray() {
|
||||
if (!this.setting.has(COLLECTIONS_TRASH_KEY)) {
|
||||
this.setting.set(COLLECTIONS_TRASH_KEY, new YArray());
|
||||
}
|
||||
return this.setting.get(COLLECTIONS_TRASH_KEY) as YArray<DeletedCollection>;
|
||||
}
|
||||
|
||||
get collections(): Collection[] {
|
||||
return this.collectionsYArray.toArray() ?? [];
|
||||
}
|
||||
|
||||
get collectionsTrash(): DeletedCollection[] {
|
||||
return this.collectionsTrashYArray.toArray() ?? [];
|
||||
}
|
||||
|
||||
updateCollection(id: string, updater: (value: Collection) => Collection) {
|
||||
updateFirstOfYArray(
|
||||
this.collectionsYArray,
|
||||
v => v.id === id,
|
||||
v => {
|
||||
return updater(v);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
addCollection(...collections: Collection[]) {
|
||||
this.doc.transact(() => {
|
||||
this.collectionsYArray.insert(0, collections);
|
||||
});
|
||||
}
|
||||
|
||||
deleteCollection(info: DeleteCollectionInfo, ...ids: string[]) {
|
||||
const set = new Set(ids);
|
||||
this.workspace.doc.transact(() => {
|
||||
const indexList: number[] = [];
|
||||
const list: Collection[] = [];
|
||||
this.collectionsYArray.forEach((collection, i) => {
|
||||
if (set.has(collection.id)) {
|
||||
set.delete(collection.id);
|
||||
indexList.unshift(i);
|
||||
list.push(JSON.parse(JSON.stringify(collection)));
|
||||
}
|
||||
});
|
||||
indexList.forEach(i => {
|
||||
this.collectionsYArray.delete(i);
|
||||
});
|
||||
this.collectionsTrashYArray.insert(
|
||||
0,
|
||||
list.map(collection => ({
|
||||
userId: info?.userId,
|
||||
userName: info ? info.userName : 'Local User',
|
||||
collection,
|
||||
}))
|
||||
);
|
||||
if (this.collectionsTrashYArray.length > 10) {
|
||||
this.collectionsTrashYArray.delete(
|
||||
10,
|
||||
this.collectionsTrashYArray.length - 10
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deletePagesFromCollection(collection: Collection, idSet: Set<string>) {
|
||||
const newAllowList = collection.allowList.filter(id => !idSet.has(id));
|
||||
const newPages = collection.pages.filter(id => !idSet.has(id));
|
||||
if (
|
||||
newAllowList.length !== collection.allowList.length ||
|
||||
newPages.length !== collection.pages.length
|
||||
) {
|
||||
this.updateCollection(collection.id, old => {
|
||||
return {
|
||||
...old,
|
||||
allowList: newAllowList,
|
||||
pages: newPages,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deletePages(ids: string[]) {
|
||||
const idSet = new Set(ids);
|
||||
this.workspace.doc.transact(() => {
|
||||
this.collections.forEach(collection => {
|
||||
this.deletePagesFromCollection(collection, idSet);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const getWorkspaceSetting = (workspace: Workspace) => {
|
||||
return new WorkspaceSetting(workspace);
|
||||
};
|
||||
18
packages/frontend/core/src/utils/yjs-utils.ts
Normal file
18
packages/frontend/core/src/utils/yjs-utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Array as YArray } from 'yjs';
|
||||
|
||||
export const updateFirstOfYArray = <T>(
|
||||
array: YArray<T>,
|
||||
p: (value: T) => boolean,
|
||||
update: (value: T) => T
|
||||
) => {
|
||||
array.doc?.transact(() => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const ele = array.get(i);
|
||||
if (p(ele)) {
|
||||
array.delete(i);
|
||||
array.insert(i, [update(ele)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user