mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat: new collections (#4530)
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user