feat(core): new doc list for collection detail (#12278)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a new document list view with support for different layouts (list, grid, masonry) and improved multi-selection and batch deletion capabilities.
  - Added a view toggle control for switching between document list layouts across relevant pages.
  - Implemented a new collection list header with breadcrumb navigation and streamlined actions for editing collections and creating new pages.

- **Improvements**
  - Simplified and unified document list rendering by delegating logic to a shared component.
  - Enhanced document selection behavior, allowing toggling of individual items in select mode.
  - Updated styles for document lists, group headers, and collection pages for a more consistent appearance.
  - Refined wrapper component styling to prevent unintended HTML attribute forwarding.

- **Refactor**
  - Replaced local implementations of view toggles and document grouping with shared components for easier maintenance.
  - Removed deprecated and unused styles and props to streamline components and improve code clarity.
  - Refactored collection detail and header components to adopt new context-driven document explorer architecture.

- **Documentation**
  - Added deprecation notice to an outdated collection page list header component.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-19 01:59:38 +00:00
parent 4ecdfb1258
commit 8b669b725b
15 changed files with 575 additions and 295 deletions
@@ -46,6 +46,8 @@ export const Wrapper = styled('div', {
'marginLeft',
'marginRight',
'marginBottom',
'flexGrow',
'flexShrink',
].includes(prop as string);
},
})<WrapperProps>(({
@@ -0,0 +1,21 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const viewToggle = style({
backgroundColor: 'transparent',
});
export const viewToggleItem = style({
padding: 0,
fontSize: 16,
width: 24,
color: cssVarV2.icon.primary,
selectors: {
'&[data-state=checked]': {
color: cssVarV2.icon.primary,
},
},
});
export const viewToggleIndicator = style({
backgroundColor: cssVarV2.layer.background.hoverOverlay,
boxShadow: 'none',
});
@@ -0,0 +1,55 @@
import { RadioGroup, type RadioItem } from '@affine/component';
import { useLiveData } from '@toeverything/infra';
import { useCallback, useContext } from 'react';
import { DocExplorerContext } from '../context';
import {
type DocListItemView,
DocListViewIcon,
} from '../docs-view/doc-list-item';
import * as styles from './view-toggle.css';
const views = [
{
label: <DocListViewIcon view="masonry" />,
value: 'masonry',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="grid" />,
value: 'grid',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="list" />,
value: 'list',
className: styles.viewToggleItem,
},
] satisfies RadioItem[];
export const ViewToggle = () => {
const explorerContextValue = useContext(DocExplorerContext);
const view = useLiveData(explorerContextValue.view$);
const handleViewChange = useCallback(
(view: DocListItemView) => {
explorerContextValue.view$?.next(view);
},
[explorerContextValue.view$]
);
return (
<RadioGroup
itemHeight={24}
gap={8}
padding={0}
items={views}
value={view}
onChange={handleViewChange}
className={styles.viewToggle}
borderRadius={4}
indicatorClassName={styles.viewToggleIndicator}
/>
);
};
@@ -124,11 +124,16 @@ export const DocListItem = ({ ...props }: DocListItemProps) => {
// do multi select
handleMultiSelect(prevCheckAnchorId, currCursor);
} else {
contextValue.selectedDocIds$?.next([docId]);
contextValue.selectedDocIds$?.next(
contextValue.selectedDocIds$.value.includes(docId)
? contextValue.selectedDocIds$.value.filter(id => id !== docId)
: [...contextValue.selectedDocIds$.value, docId]
);
contextValue.prevCheckAnchorId$?.next(currCursor);
}
} else {
if (e.shiftKey) {
contextValue.selectMode$?.next(true);
contextValue.selectedDocIds$?.next([docId]);
contextValue.prevCheckAnchorId$?.next(currCursor);
return;
@@ -0,0 +1,10 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const groupHeader = style({
background: cssVarV2.layer.background.primary,
});
export const docItem = style({
transition: 'width 0.2s ease-in-out',
});
@@ -0,0 +1,223 @@
import { Masonry, type MasonryGroup, useConfirmModal } from '@affine/component';
import { DocsService } from '@affine/core/modules/doc';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { memo, useCallback, useContext, useEffect, useMemo } from 'react';
import { ListFloatingToolbar } from '../../page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '../../workspace-property-types';
import { DocExplorerContext } from '../context';
import { DocListItem } from './doc-list-item';
import * as styles from './docs-list.css';
const GroupHeader = memo(function GroupHeader({
groupId,
collapsed,
itemCount,
}: {
groupId: string;
collapsed?: boolean;
itemCount: number;
}) {
const contextValue = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$);
const groupBy = useLiveData(contextValue.groupBy$);
const groupType = groupBy?.type;
const groupKey = groupBy?.key;
const header = useMemo(() => {
if (groupType === 'property') {
const property = allProperties.find(p => p.id === groupKey);
if (!property) return null;
const config = WorkspacePropertyTypes[property.type];
if (!config?.groupHeader) return null;
return (
<config.groupHeader
groupId={groupId}
docCount={itemCount}
collapsed={!!collapsed}
/>
);
} else {
console.warn('unsupported group type', groupType);
return null;
}
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
if (!groupType) {
return null;
}
return header;
});
const calcCardHeightById = (id: string) => {
if (!id) {
return 250;
}
const max = 5;
const min = 1;
const code = id.charCodeAt(0);
const value = Math.floor((code % (max - min)) + min);
return 250 + value * 10;
};
const DocListItemComponent = memo(function DocListItemComponent({
itemId,
groupId,
}: {
groupId: string;
itemId: string;
}) {
return <DocListItem docId={itemId} groupId={groupId} />;
});
export const DocsExplorer = ({
className,
disableMultiDelete,
}: {
className?: string;
disableMultiDelete?: boolean;
}) => {
const t = useI18n();
const contextValue = useContext(DocExplorerContext);
const docsService = useService(DocsService);
const groups = useLiveData(contextValue.groups$);
const view = useLiveData(contextValue.view$);
const selectMode = useLiveData(contextValue.selectMode$);
const selectedDocIds = useLiveData(contextValue.selectedDocIds$);
const collapsedGroups = useLiveData(contextValue.collapsedGroups$);
const { openConfirmModal } = useConfirmModal();
const masonryItems = useMemo(() => {
const items = groups.map((group: any) => {
return {
id: group.key,
Component: groups.length > 1 ? GroupHeader : undefined,
height: groups.length > 1 ? 24 : 0,
className: styles.groupHeader,
items: group.items.map((docId: string) => {
return {
id: docId,
Component: DocListItemComponent,
height:
view === 'list'
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
'data-view': view,
className: styles.docItem,
};
}),
} satisfies MasonryGroup;
});
return items;
}, [groups, view]);
const handleCloseFloatingToolbar = useCallback(() => {
contextValue.selectMode$?.next(false);
contextValue.selectedDocIds$.next([]);
}, [contextValue]);
const handleMultiDelete = useCallback(() => {
if (disableMultiDelete) {
handleCloseFloatingToolbar();
return;
}
if (selectedDocIds.length === 0) {
return;
}
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
number: selectedDocIds.length.toString(),
}),
description: t[
'com.affine.moveToTrash.confirmModal.description.multiple'
]({
number: selectedDocIds.length.toString(),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
const selectedDocIds = contextValue.selectedDocIds$.value;
for (const docId of selectedDocIds) {
const doc = docsService.list.doc$(docId).value;
doc?.moveToTrash();
}
},
});
}, [
contextValue.selectedDocIds$,
disableMultiDelete,
docsService.list,
handleCloseFloatingToolbar,
openConfirmModal,
selectedDocIds.length,
t,
]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
contextValue.selectMode$?.next(false);
contextValue.selectedDocIds$.next([]);
contextValue.prevCheckAnchorId$?.next(null);
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [contextValue]);
return (
<>
<Masonry
className={className}
items={masonryItems}
gapY={12}
gapX={12}
groupsGap={12}
groupHeaderGapWithItems={12}
columns={view === 'list' ? 1 : undefined}
itemWidthMin={220}
preloadHeight={100}
itemWidth={'stretch'}
virtualScroll
collapsedGroups={collapsedGroups}
paddingX={useCallback(
(w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16),
[]
)}
/>
<ListFloatingToolbar
open={!!selectMode}
onDelete={handleMultiDelete}
onClose={handleCloseFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedDocIds.length}
>
<div style={{ color: cssVarV2.text.secondary }}>
{{ count: selectedDocIds.length } as any}
</div>
selected
</Trans>
}
/>
</>
);
};
@@ -105,6 +105,9 @@ export const PageListHeader = () => {
</div>
);
};
/**
* @deprecated
*/
export const CollectionPageListHeader = ({
collection,
workspaceId,
@@ -29,7 +29,3 @@ export const viewToggleItem = style({
},
},
});
export const viewToggleIndicator = style({
backgroundColor: cssVarV2.layer.background.hoverOverlay,
boxShadow: 'none',
});
@@ -1,61 +1,10 @@
import { type MenuProps, RadioGroup, type RadioItem } from '@affine/component';
import { DocExplorerContext } from '@affine/core/components/explorer/context';
import { type MenuProps } from '@affine/component';
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
import {
type DocListItemView,
DocListViewIcon,
} from '@affine/core/components/explorer/docs-view/doc-list-item';
import { ViewToggle } from '@affine/core/components/explorer/display-menu/view-toggle';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { useLiveData } from '@toeverything/infra';
import { useCallback, useContext } from 'react';
import * as styles from './all-page-header.css';
const views = [
{
label: <DocListViewIcon view="masonry" />,
value: 'masonry',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="grid" />,
value: 'grid',
className: styles.viewToggleItem,
},
{
label: <DocListViewIcon view="list" />,
value: 'list',
className: styles.viewToggleItem,
},
] satisfies RadioItem[];
const ViewToggle = () => {
const explorerContextValue = useContext(DocExplorerContext);
const view = useLiveData(explorerContextValue.view$);
const handleViewChange = useCallback(
(view: DocListItemView) => {
explorerContextValue.view$?.next(view);
},
[explorerContextValue.view$]
);
return (
<RadioGroup
itemHeight={24}
gap={8}
padding={0}
items={views}
value={view}
onChange={handleViewChange}
className={styles.viewToggle}
borderRadius={4}
indicatorClassName={styles.viewToggleIndicator}
/>
);
};
const menuProps: Partial<MenuProps> = {
contentOptions: {
side: 'bottom',
@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const scrollContainer = style({
flex: 1,
@@ -38,13 +37,6 @@ export const scrollArea = style({
});
// group
export const groupHeader = style({
background: cssVarV2.layer.background.primary,
});
export const docItem = style({
transition: 'width 0.2s ease-in-out',
});
export const pinnedCollection = style({
display: 'flex',
@@ -1,38 +1,20 @@
import {
Button,
Masonry,
type MasonryGroup,
useConfirmModal,
usePromptModal,
} from '@affine/component';
import { Button, usePromptModal } from '@affine/component';
import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocListItem } from '@affine/core/components/explorer/docs-view/doc-list-item';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import { Filters } from '@affine/core/components/filter';
import { ListFloatingToolbar } from '@affine/core/components/page-list/components/list-floating-toolbar';
import { WorkspacePropertyTypes } from '@affine/core/components/workspace-property-types';
import {
CollectionService,
PinnedCollectionService,
} from '@affine/core/modules/collection';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import type { FilterParams } from '@affine/core/modules/collection-rules/types';
import { DocsService } from '@affine/core/modules/doc';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
import { Trans, useI18n } from '@affine/i18n';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
ViewBody,
@@ -47,70 +29,9 @@ import { AllDocsHeader } from './all-page-header';
import { MigrationAllDocsDataNotification } from './migration-data';
import { PinnedCollections } from './pinned-collections';
const GroupHeader = memo(function GroupHeader({
groupId,
collapsed,
itemCount,
}: {
groupId: string;
collapsed?: boolean;
itemCount: number;
}) {
const contextValue = useContext(DocExplorerContext);
const propertyService = useService(WorkspacePropertyService);
const allProperties = useLiveData(propertyService.sortedProperties$);
const groupBy = useLiveData(contextValue.groupBy$);
const groupType = groupBy?.type;
const groupKey = groupBy?.key;
const header = useMemo(() => {
if (groupType === 'property') {
const property = allProperties.find(p => p.id === groupKey);
if (!property) return null;
const config = WorkspacePropertyTypes[property.type];
if (!config?.groupHeader) return null;
return (
<config.groupHeader
groupId={groupId}
docCount={itemCount}
collapsed={!!collapsed}
/>
);
} else {
return '// TODO: ' + groupType;
}
}, [allProperties, collapsed, groupId, groupKey, groupType, itemCount]);
if (!groupType) {
return null;
}
return header;
});
const calcCardHeightById = (id: string) => {
const max = 5;
const min = 1;
const code = id.charCodeAt(0);
const value = Math.floor((code % (max - min)) + min);
return 250 + value * 10;
};
const DocListItemComponent = memo(function DocListItemComponent({
itemId,
groupId,
}: {
groupId: string;
itemId: string;
}) {
return <DocListItem docId={itemId} groupId={groupId} />;
});
export const AllPage = () => {
const t = useI18n();
const docsService = useService(DocsService);
const collectionService = useService(CollectionService);
const pinnedCollectionService = useService(PinnedCollectionService);
@@ -138,41 +59,10 @@ export const AllPage = () => {
const [explorerContextValue] = useState(createDocExplorerContext);
const view = useLiveData(explorerContextValue.view$);
const groupBy = useLiveData(explorerContextValue.groupBy$);
const orderBy = useLiveData(explorerContextValue.orderBy$);
const groups = useLiveData(explorerContextValue.groups$);
const selectedDocIds = useLiveData(explorerContextValue.selectedDocIds$);
const collapsedGroups = useLiveData(explorerContextValue.collapsedGroups$);
const selectMode = useLiveData(explorerContextValue.selectMode$);
const { openPromptModal } = usePromptModal();
const { openConfirmModal } = useConfirmModal();
const masonryItems = useMemo(() => {
const items = groups.map((group: any) => {
return {
id: group.key,
Component: groups.length > 1 ? GroupHeader : undefined,
height: groups.length > 1 ? 24 : 0,
className: styles.groupHeader,
items: group.items.map((docId: string) => {
return {
id: docId,
Component: DocListItemComponent,
height:
view === 'list'
? 42
: view === 'grid'
? 280
: calcCardHeightById(docId),
'data-view': view,
className: styles.docItem,
};
}),
} satisfies MasonryGroup;
});
return items;
}, [groups, view]);
const collectionRulesService = useService(CollectionRulesService);
useEffect(() => {
@@ -270,39 +160,6 @@ export const AllPage = () => {
setTempFilters(filters);
}, []);
const handleCloseFloatingToolbar = useCallback(() => {
explorerContextValue.selectMode$.next(false);
explorerContextValue.selectedDocIds$.next([]);
}, [explorerContextValue]);
const handleMultiDelete = useCallback(() => {
if (selectedDocIds.length === 0) {
return;
}
openConfirmModal({
title: t['com.affine.moveToTrash.confirmModal.title.multiple']({
number: selectedDocIds.length.toString(),
}),
description: t[
'com.affine.moveToTrash.confirmModal.description.multiple'
]({
number: selectedDocIds.length.toString(),
}),
cancelText: t['com.affine.confirmModal.button.cancel'](),
confirmText: t.Delete(),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () => {
for (const docId of selectedDocIds) {
const doc = docsService.list.doc$(docId).value;
doc?.moveToTrash();
}
},
});
}, [docsService.list, openConfirmModal, selectedDocIds, t]);
const handleSaveFilters = useCallback(() => {
openPromptModal({
title: t['com.affine.editCollection.saveCollection'](),
@@ -383,41 +240,9 @@ export const AllPage = () => {
</div>
)}
<div className={styles.scrollArea}>
<Masonry
items={masonryItems}
gapY={12}
gapX={12}
groupsGap={12}
groupHeaderGapWithItems={12}
columns={view === 'list' ? 1 : undefined}
itemWidthMin={220}
preloadHeight={100}
itemWidth={'stretch'}
virtualScroll
collapsedGroups={collapsedGroups}
paddingX={useCallback(
(w: number) => (w > 500 ? 24 : w > 393 ? 20 : 16),
[]
)}
/>
<DocsExplorer />
</div>
</div>
<ListFloatingToolbar
open={selectMode}
onDelete={handleMultiDelete}
onClose={handleCloseFloatingToolbar}
content={
<Trans
i18nKey="com.affine.page.toolbar.selected"
count={selectedDocIds.length}
>
<div style={{ color: cssVarV2.text.secondary }}>
{{ count: selectedDocIds.length } as any}
</div>
selected
</Trans>
}
/>
</ViewBody>
<AllDocSidebarTabs />
</DocExplorerContext.Provider>
@@ -1,35 +1,17 @@
import { IconButton } from '@affine/component';
import { FlexWrapper } from '@affine/component';
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
import { ViewToggle } from '@affine/core/components/explorer/display-menu/view-toggle';
import { ExplorerNavigation } from '@affine/core/components/explorer/header/navigation';
import { PageDisplayMenu } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { PlusIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import * as styles from './collection.css';
export const CollectionDetailHeader = ({
showCreateNew,
onCreate,
}: {
showCreateNew: boolean;
onCreate: () => void;
}) => {
export const CollectionDetailHeader = () => {
return (
<Header
right={
<>
<IconButton
size="16"
icon={<PlusIcon />}
onClick={onCreate}
className={clsx(
styles.headerCreateNewButton,
styles.headerCreateNewCollectionIconButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
<PageDisplayMenu />
</>
<FlexWrapper gap={16}>
<ViewToggle />
<ExplorerDisplayMenuButton />
</FlexWrapper>
}
left={<ExplorerNavigation active="collections" />}
/>
@@ -0,0 +1,59 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const scrollArea = style({
width: '100%',
flexGrow: 1,
height: 0,
});
export const collectionHeader = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '24px',
});
export const breadcrumb = style({
fontSize: 14,
lineHeight: '22px',
color: cssVarV2.text.secondary,
display: 'flex',
alignItems: 'center',
});
export const breadcrumbItem = style({
display: 'flex',
alignItems: 'center',
gap: 2,
cursor: 'pointer',
selectors: {
'&[data-active="true"]': {
color: cssVarV2.text.primary,
cursor: 'default',
},
},
});
export const breadcrumbLink = style({
color: 'inherit',
textDecoration: 'none',
});
export const breadcrumbIcon = style({
fontSize: 20,
color: cssVarV2.icon.primary,
});
export const breadcrumbSeparator = style({
marginLeft: 4,
marginRight: 8,
});
export const headerActions = style({
display: 'flex',
alignItems: 'center',
gap: 16,
});
export const newPageButtonText = style({
fontSize: 12,
lineHeight: '20px',
fontWeight: 500,
});
@@ -1,10 +1,15 @@
import { FlexWrapper } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty/collection-detail';
import { VirtualizedPageList } from '@affine/core/components/page-list';
import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import {
type Collection,
CollectionService,
} from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
@@ -25,42 +30,82 @@ import {
import { PageNotFound } from '../../404';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import { CollectionDetailHeader } from './header';
import * as styles from './index.css';
import { CollectionListHeader } from './list-header';
export const CollectionDetail = ({
collection,
}: {
collection: Collection;
}) => {
const { workspaceDialogService } = useServices({
WorkspaceDialogService,
});
const [explorerContextValue] = useState(createDocExplorerContext);
const collectionRulesService = useService(CollectionRulesService);
const permissionService = useService(WorkspacePermissionService);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const handleEditCollection = useCallback(() => {
workspaceDialogService.open('collection-editor', {
collectionId: collection.id,
});
}, [collection, workspaceDialogService]);
const groupBy = useLiveData(explorerContextValue.groupBy$);
const orderBy = useLiveData(explorerContextValue.orderBy$);
const rules = useLiveData(collection.rules$);
const allowList = useLiveData(collection.allowList$);
useEffect(() => {
const subscription = collectionRulesService
.watch({
filters: rules.filters,
groupBy,
orderBy,
extraAllowList: allowList,
extraFilters: [
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
],
})
.subscribe({
next: result => {
explorerContextValue.groups$.next(result.groups);
},
error: error => {
console.error(error);
},
});
return () => {
subscription.unsubscribe();
};
}, [
allowList,
collectionRulesService,
explorerContextValue.groups$,
groupBy,
orderBy,
rules.filters,
]);
return (
<>
<DocExplorerContext.Provider value={explorerContextValue}>
<ViewHeader>
<CollectionDetailHeader
showCreateNew={!hideHeaderCreateNew}
onCreate={handleEditCollection}
/>
<CollectionDetailHeader />
</ViewHeader>
<ViewBody>
<VirtualizedPageList
collection={collection}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
disableMultiDelete={!isAdmin && !isOwner}
/>
<FlexWrapper flexDirection="column" alignItems="stretch" width="100%">
<CollectionListHeader collection={collection} />
<div className={styles.scrollArea}>
<DocsExplorer disableMultiDelete={!isAdmin && !isOwner} />
</div>
</FlexWrapper>
</ViewBody>
</>
</DocExplorerContext.Provider>
);
};
@@ -0,0 +1,113 @@
import { Button, useConfirmModal } from '@affine/component';
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import { PageListNewPageButton } from '@affine/core/components/page-list';
import {
type Collection,
CollectionService,
} from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { DocRecord } from '@affine/core/modules/doc';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/model';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback } from 'react';
import * as styles from './index.css';
export const CollectionListHeader = ({
collection,
}: {
collection: Collection;
}) => {
const t = useI18n();
const { collectionService, workspaceService, workspaceDialogService } =
useServices({
CollectionService,
WorkspaceService,
WorkspaceDialogService,
});
const handleEdit = useCallback(() => {
workspaceDialogService.open('collection-editor', {
collectionId: collection.id,
});
}, [collection, workspaceDialogService]);
const workspace = workspaceService.workspace;
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
const { openConfirmModal } = useConfirmModal();
const name = useLiveData(collection.name$);
const createAndAddDocument = useCallback(
(createDocumentFn: () => DocRecord) => {
const newDoc = createDocumentFn();
collectionService.addDocToCollection(collection.id, newDoc.id);
},
[collection.id, collectionService]
);
const onConfirmAddDocument = useCallback(
(createDocumentFn: () => DocRecord) => {
openConfirmModal({
title: t['com.affine.collection.add-doc.confirm.title'](),
description: t['com.affine.collection.add-doc.confirm.description'](),
cancelText: t['Cancel'](),
confirmText: t['Confirm'](),
confirmButtonOptions: {
variant: 'primary',
},
onConfirm: () => createAndAddDocument(createDocumentFn),
});
},
[openConfirmModal, t, createAndAddDocument]
);
const createPageModeDoc = useCallback(
() => createPage('page' as DocMode),
[createPage]
);
const onCreateEdgeless = useCallback(
() => onConfirmAddDocument(createEdgeless),
[createEdgeless, onConfirmAddDocument]
);
const onCreatePage = useCallback(() => {
onConfirmAddDocument(createPageModeDoc);
}, [createPageModeDoc, onConfirmAddDocument]);
const onCreateDoc = useCallback(() => {
onConfirmAddDocument(createPage);
}, [createPage, onConfirmAddDocument]);
return (
<header className={styles.collectionHeader}>
<div className={styles.breadcrumb}>
<div className={styles.breadcrumbItem}>
<WorkbenchLink to="/collection" className={styles.breadcrumbLink}>
{t['com.affine.collections.header']()}
</WorkbenchLink>
</div>
<div className={styles.breadcrumbSeparator}>/</div>
<div className={styles.breadcrumbItem} data-active={true}>
<ViewLayersIcon className={styles.breadcrumbIcon} />
{name}
</div>
</div>
<div className={styles.headerActions}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
<PageListNewPageButton
size="small"
testId="new-page-button-trigger"
onCreateDoc={onCreateDoc}
onCreateEdgeless={onCreateEdgeless}
onCreatePage={onCreatePage}
>
<div className={styles.newPageButtonText}>{t['New Page']()}</div>
</PageListNewPageButton>
</div>
</header>
);
};