feat(core): new empty states for doc/collection/tag (#8197)

AF-1329, AF-1330
This commit is contained in:
CatsJuice 2024-09-11 10:48:52 +00:00
parent f12655655e
commit b7d05d2078
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
49 changed files with 656 additions and 456 deletions

View File

@ -28,6 +28,7 @@ export * from './ui/slider';
export * from './ui/switch';
export * from './ui/table';
export * from './ui/tabs';
export * from './ui/themed-img';
export * from './ui/toast';
export * from './ui/tooltip';
export * from './utils';

View File

@ -16,6 +16,9 @@ export type EmptyContentProps = {
descriptionStyle?: CSSProperties;
};
/**
* @deprecated use different empty components for different use cases, like `EmptyDocs` for documentation empty state
*/
export const Empty = ({
containerStyle,
title,

View File

@ -0,0 +1,17 @@
import { useTheme } from 'next-themes';
import { forwardRef, type HTMLAttributes } from 'react';
export interface ThemedImgProps
extends Omit<HTMLAttributes<HTMLImageElement>, 'src'> {
lightSrc: string;
darkSrc?: string;
}
export const ThemedImg = forwardRef<HTMLImageElement, ThemedImgProps>(
function ThemedImg({ lightSrc, darkSrc, ...attrs }, ref) {
const { resolvedTheme } = useTheme();
const src = resolvedTheme === 'dark' && darkSrc ? darkSrc : lightSrc;
return <img ref={ref} src={src} {...attrs} />;
}
);

View File

@ -1,2 +1,3 @@
export * from './observe-resize';
export { startScopedViewTransition } from './view-transition';
export * from './with-unit';

View File

@ -0,0 +1,30 @@
import { Button, type ButtonProps } from '@affine/component';
import clsx from 'clsx';
import {
actionButton,
actionContent,
mobileActionButton,
mobileActionContent,
} from './style.css';
export const ActionButton = ({
className,
contentClassName,
...props
}: ButtonProps) => {
return (
<Button
size="large"
className={clsx(
environment.isMobileEdition ? mobileActionButton : actionButton,
className
)}
contentClassName={clsx(
environment.isMobileEdition ? mobileActionContent : actionContent,
contentClassName
)}
{...props}
/>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,66 @@
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { CollectionService } from '@affine/core/modules/collection';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon, FilterIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useEditCollection } from '../../page-list';
import { ActionButton } from './action-button';
import collectionDetailDark from './assets/collection-detail.dark.png';
import collectionDetailLight from './assets/collection-detail.light.png';
import { EmptyLayout } from './layout';
import { actionGroup } from './style.css';
import type { UniversalEmptyProps } from './types';
export interface EmptyCollectionDetailProps extends UniversalEmptyProps {
collection: Collection;
}
export const EmptyCollectionDetail = ({
collection,
...props
}: EmptyCollectionDetailProps) => {
const t = useI18n();
return (
<EmptyLayout
illustrationLight={collectionDetailLight}
illustrationDark={collectionDetailDark}
title={t['com.affine.empty.collection-detail.title']()}
description={t['com.affine.empty.collection-detail.description']()}
action={
environment.isMobileEdition ? null : <Actions collection={collection} />
}
{...props}
/>
);
};
const Actions = ({ collection }: { collection: Collection }) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const { open } = useEditCollection();
const openAddDocs = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
collectionService.updateCollection(ret.id, () => ret);
}, [open, collection, collectionService]);
const openAddRules = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'rule');
collectionService.updateCollection(ret.id, () => ret);
}, [collection, open, collectionService]);
return (
<div className={actionGroup}>
<ActionButton prefix={<AllDocsIcon />} onClick={openAddDocs}>
{t['com.affine.empty.collection-detail.action.add-doc']()}
</ActionButton>
<ActionButton prefix={<FilterIcon />} onClick={openAddRules}>
{t['com.affine.empty.collection-detail.action.add-rule']()}
</ActionButton>
</div>
);
};

View File

@ -0,0 +1,60 @@
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { useI18n } from '@affine/i18n';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { createEmptyCollection, useEditCollectionName } from '../../page-list';
import { ActionButton } from './action-button';
import collectionListDark from './assets/collection-list.dark.png';
import collectionListLight from './assets/collection-list.light.png';
import { EmptyLayout } from './layout';
import type { UniversalEmptyProps } from './types';
export const EmptyCollections = (props: UniversalEmptyProps) => {
const t = useI18n();
const collectionService = useService(CollectionService);
const currentWorkspace = useService(WorkspaceService).workspace;
const navigateHelper = useNavigateHelper();
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const showAction = true;
const handleCreateCollection = useCallback(() => {
open('')
.then(name => {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
navigateHelper.jumpToCollection(currentWorkspace.id, id);
})
.catch(err => {
console.error(err);
});
}, [collectionService, currentWorkspace, navigateHelper, open]);
return (
<EmptyLayout
illustrationLight={collectionListLight}
illustrationDark={collectionListDark}
title={t['com.affine.empty.collections.title']()}
description={t['com.affine.empty.collections.description']()}
action={
showAction ? (
<ActionButton
prefix={<ViewLayersIcon />}
onClick={handleCreateCollection}
>
{t['com.affine.empty.collections.action.new-collection']()}
</ActionButton>
) : null
}
{...props}
/>
);
};

View File

@ -0,0 +1,69 @@
import { TagService } from '@affine/core/modules/tag';
import { isNewTabTrigger } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { ActionButton } from './action-button';
import docsIllustrationDark from './assets/docs.dark.png';
import docsIllustrationLight from './assets/docs.light.png';
import { EmptyLayout } from './layout';
import type { UniversalEmptyProps } from './types';
export interface EmptyDocsProps extends UniversalEmptyProps {
type?: 'all' | 'trash';
/**
* Used for "New doc", if provided, new doc will be created with this tag.
*/
tagId?: string;
}
export const EmptyDocs = ({
type = 'all',
tagId,
...props
}: EmptyDocsProps) => {
const t = useI18n();
const tagService = useService(TagService);
const currentWorkspace = useService(WorkspaceService).workspace;
const pageHelper = usePageHelper(currentWorkspace.docCollection);
const tag = useLiveData(tagService.tagList.tagByTagId$(tagId));
const showActionButton = type !== 'trash'; // && !environment.isMobileEdition;
const onCreate = useCallback(
(e: MouseEvent) => {
const doc = pageHelper.createPage(
undefined,
isNewTabTrigger(e) ? 'new-tab' : true
);
doc.load();
if (tag) tag.tag(doc.id);
},
[pageHelper, tag]
);
return (
<EmptyLayout
illustrationLight={docsIllustrationLight}
illustrationDark={docsIllustrationDark}
title={t['com.affine.empty.docs.title']()}
description={
type === 'trash'
? t['com.affine.empty.docs.trash-description']()
: t['com.affine.empty.docs.all-description']()
}
action={
showActionButton ? (
<ActionButton onClick={onCreate} prefix={<AllDocsIcon />}>
{t['com.affine.empty.docs.action.new-doc']()}
</ActionButton>
) : null
}
{...props}
/>
);
};

View File

@ -0,0 +1,4 @@
export * from './collection-detail';
export * from './collections';
export * from './docs';
export * from './tags';

View File

@ -0,0 +1,45 @@
import { ThemedImg, withUnit } from '@affine/component';
import clsx from 'clsx';
import * as styles from './style.css';
import type { EmptyLayoutProps } from './types';
export const EmptyLayout = ({
className,
illustrationLight,
illustrationDark,
illustrationWidth = 300,
title,
description,
action,
absoluteCenter,
...attrs
}: EmptyLayoutProps) => {
return (
<div
className={clsx(
styles.root,
absoluteCenter ? styles.absoluteCenter : null,
className
)}
{...attrs}
>
<ThemedImg
style={{ width: withUnit(illustrationWidth, 'px') }}
draggable={false}
className={styles.illustration}
lightSrc={illustrationLight}
darkSrc={illustrationDark}
/>
{title || description ? (
<div>
<p className={styles.title}>{title}</p>
<p className={styles.description}>{description}</p>
</div>
) : null}
{action}
</div>
);
};

View File

@ -0,0 +1,64 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
// SVG illustration has a space from top, add some padding bottom to compensate
paddingBottom: 35,
});
export const illustration = style({
maxWidth: '100%',
width: 300,
});
export const title = style({
textAlign: 'center',
fontSize: 15,
lineHeight: '24px',
fontWeight: 500,
color: cssVarV2('text/primary'),
marginBottom: 4,
});
export const description = style({
textAlign: 'center',
fontSize: 14,
fontWeight: 400,
lineHeight: '22px',
color: cssVarV2('text/secondary'),
marginBottom: 0,
maxWidth: 300,
});
export const absoluteCenter = style({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
});
export const actionGroup = style({
display: 'flex',
gap: 16,
});
export const actionButton = style({});
export const mobileActionButton = style({
padding: '8px 18px',
height: 'auto',
fontWeight: 600,
});
export const actionContent = style({
padding: '0 4px',
});
export const mobileActionContent = style({
padding: '0 4px',
});

View File

@ -0,0 +1,20 @@
import { useI18n } from '@affine/i18n';
import tagsDark from './assets/tag-list.dark.png';
import tagsLight from './assets/tag-list.light.png';
import { EmptyLayout } from './layout';
import type { UniversalEmptyProps } from './types';
export const EmptyTags = (props: UniversalEmptyProps) => {
const t = useI18n();
return (
<EmptyLayout
illustrationLight={tagsLight}
illustrationDark={tagsDark}
title={t['com.affine.empty.tags.title']()}
description={t['com.affine.empty.tags.description']()}
{...props}
/>
);
};

View File

@ -0,0 +1,18 @@
import type { HTMLAttributes, ReactNode } from 'react';
export interface EmptyLayoutProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
illustrationLight: string;
illustrationDark?: string;
illustrationWidth?: number | string;
title?: ReactNode;
description?: ReactNode;
action?: ReactNode;
/**
* Absolute center the content, useful for full screen empty states (e.g. mobile page)
*/
absoluteCenter?: boolean;
}
export type UniversalEmptyProps = Partial<EmptyLayoutProps>;

View File

@ -1,33 +1,27 @@
import { Button } from '@affine/component';
import { useI18n } from '@affine/i18n';
import type { ReactElement } from 'react';
import * as styles from './collection-list-header.css';
export const CollectionListHeader = ({
node,
onCreate,
}: {
node: ReactElement | null;
onCreate: () => void;
}) => {
const t = useI18n();
return (
<>
<div className={styles.collectionListHeader}>
<div className={styles.collectionListHeaderTitle}>
{t['com.affine.collections.header']()}
</div>
<Button
className={styles.newCollectionButton}
onClick={onCreate}
data-testid="all-collection-new-button"
>
{t['com.affine.collections.empty.new-collection-button']()}
</Button>
<div className={styles.collectionListHeader}>
<div className={styles.collectionListHeaderTitle}>
{t['com.affine.collections.header']()}
</div>
{node}
</>
<Button
className={styles.newCollectionButton}
onClick={onCreate}
data-testid="all-collection-new-button"
>
{t['com.affine.collections.empty.new-collection-button']()}
</Button>
</div>
);
};

View File

@ -2,7 +2,6 @@ import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-co
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useService, WorkspaceService } from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { CollectionService } from '../../../modules/collection';
@ -42,12 +41,10 @@ export const VirtualizedCollectionList = ({
collections,
collectionMetas,
setHideHeaderCreateNewCollection,
node,
handleCreateCollection,
}: {
collections: Collection[];
collectionMetas: CollectionMeta[];
node: ReactElement | null;
handleCreateCollection: () => void;
setHideHeaderCreateNewCollection: (hide: boolean) => void;
}) => {
@ -107,9 +104,7 @@ export const VirtualizedCollectionList = ({
atTopThreshold={80}
atTopStateChange={setHideHeaderCreateNewCollection}
onSelectionActiveChange={setShowFloatingToolbar}
heading={
<CollectionListHeader node={node} onCreate={handleCreateCollection} />
}
heading={<CollectionListHeader onCreate={handleCreateCollection} />}
selectedIds={filteredSelectedCollectionIds}
onSelectedIdsChange={setSelectedCollectionIds}
items={collectionMetas}

View File

@ -110,7 +110,7 @@ export const CollectionPageListHeader = ({
jumpToCollections(workspaceId);
}, [jumpToCollections, workspaceId]);
const { node, open } = useEditCollection();
const { open } = useEditCollection();
const handleEdit = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
@ -162,32 +162,29 @@ export const CollectionPageListHeader = ({
}, [createPage, onConfirmAddDocument]);
return (
<>
{node}
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
{t['com.affine.collections.header']()} /
</div>
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>{collection.name}</div>
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div style={{ cursor: 'pointer' }} onClick={handleJumpToCollections}>
{t['com.affine.collections.header']()} /
</div>
<div className={styles.rightButtonGroup}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
<PageListNewPageButton
size="small"
testId="new-page-button-trigger"
onCreateDoc={onCreateDoc}
onCreateEdgeless={onCreateEdgeless}
onCreatePage={onCreatePage}
>
<div className={styles.buttonText}>{t['New Page']()}</div>
</PageListNewPageButton>
<div className={styles.titleIcon}>
<ViewLayersIcon />
</div>
<div className={styles.titleCollectionName}>{collection.name}</div>
</div>
</>
<div className={styles.rightButtonGroup}>
<Button onClick={handleEdit}>{t['Edit']()}</Button>
<PageListNewPageButton
size="small"
testId="new-page-button-trigger"
onCreateDoc={onCreateDoc}
onCreateEdgeless={onCreateEdgeless}
onCreatePage={onCreatePage}
>
<div className={styles.buttonText}>{t['New Page']()}</div>
</PageListNewPageButton>
</div>
</div>
);
};
@ -205,7 +202,7 @@ export const TagPageListHeader = ({
const { jumpToTags, jumpToCollection } = useNavigateHelper();
const collectionService = useService(CollectionService);
const [openMenu, setOpenMenu] = useState(false);
const { open, node } = useEditCollectionName({
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
});
@ -235,47 +232,44 @@ export const TagPageListHeader = ({
}, [open, saveToCollection]);
return (
<>
{node}
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div
style={{ cursor: 'pointer', lineHeight: '1.4em' }}
onClick={handleJumpToTags}
>
{t['Tags']()} /
</div>
<Menu
rootOptions={{
open: openMenu,
onOpenChange: setOpenMenu,
}}
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 18,
avoidCollisions: false,
className: styles.tagsMenu,
}}
items={<SwitchTag onClick={setOpenMenu} />}
>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColor,
}}
/>
<div className={styles.tagLabel}>{tagTitle}</div>
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
</div>
</Menu>
<div className={styles.docListHeader}>
<div className={styles.docListHeaderTitle}>
<div
style={{ cursor: 'pointer', lineHeight: '1.4em' }}
onClick={handleJumpToTags}
>
{t['Tags']()} /
</div>
<Button onClick={handleClick}>
{t['com.affine.editCollection.saveCollection']()}
</Button>
<Menu
rootOptions={{
open: openMenu,
onOpenChange: setOpenMenu,
}}
contentOptions={{
side: 'bottom',
align: 'start',
sideOffset: 18,
avoidCollisions: false,
className: styles.tagsMenu,
}}
items={<SwitchTag onClick={setOpenMenu} />}
>
<div className={styles.tagSticky}>
<div
className={styles.tagIndicator}
style={{
backgroundColor: tagColor,
}}
/>
<div className={styles.tagLabel}>{tagTitle}</div>
<ArrowDownSmallIcon className={styles.arrowDownSmallIcon} />
</div>
</Menu>
</div>
</>
<Button onClick={handleClick}>
{t['com.affine.editCollection.saveCollection']()}
</Button>
</div>
);
};

View File

@ -317,13 +317,11 @@ export const CollectionOperationCell = ({
favAdapter.isFavorite$(collection.id, 'collection')
);
const { open: openEditCollectionModal, node: editModal } =
useEditCollection();
const { open: openEditCollectionModal } = useEditCollection();
const { open: openEditCollectionNameModal, node: editNameModal } =
useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const { open: openEditCollectionNameModal } = useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const handlePropagation = useCallback((event: MouseEvent) => {
event.preventDefault();
@ -402,8 +400,6 @@ export const CollectionOperationCell = ({
return (
<>
{editModal}
{editNameModal}
<ColWrapper
hideInSmallContainer
data-testid="page-list-item-favorite"

View File

@ -51,13 +51,11 @@ export const CollectionOperations = ({
});
const deleteInfo = useDeleteCollectionInfo();
const workbench = workbenchService.workbench;
const { open: openEditCollectionModal, node: editModal } =
useEditCollection();
const { open: openEditCollectionModal } = useEditCollection();
const t = useI18n();
const { open: openEditCollectionNameModal, node: editNameModal } =
useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const { open: openEditCollectionNameModal } = useEditCollectionName({
title: t['com.affine.editCollection.renameCollection'](),
});
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
@ -193,33 +191,29 @@ export const CollectionOperations = ({
]
);
return (
<>
{editModal}
{editNameModal}
<Menu
items={
<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}
prefixIcon={action.icon}
onClick={action.click}
>
{action.name}
</MenuItem>
);
})}
</div>
}
>
{children}
</Menu>
</>
<Menu
items={
<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}
prefixIcon={action.icon}
onClick={action.click}
>
{action.name}
</MenuItem>
);
})}
</div>
}
>
{children}
</Menu>
);
};

View File

@ -17,7 +17,7 @@ export const SaveAsCollectionButton = ({
onConfirm,
}: SaveAsCollectionButtonProps) => {
const t = useI18n();
const { open, node } = useEditCollectionName({
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.saveCollection'](),
showTips: true,
});
@ -31,16 +31,13 @@ export const SaveAsCollectionButton = ({
});
}, [open, onConfirm]);
return (
<>
<Button
onClick={handleClick}
data-testid="save-as-collection"
prefix={<SaveIcon />}
className={styles.button}
>
{t['com.affine.editCollection.saveCollection']()}
</Button>
{node}
</>
<Button
onClick={handleClick}
data-testid="save-as-collection"
prefix={<SaveIcon />}
className={styles.button}
>
{t['com.affine.editCollection.saveCollection']()}
</Button>
);
};

View File

@ -1,5 +1,6 @@
import type { Collection } from '@affine/env/filter';
import { useCallback, useState } from 'react';
import { useMount } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { CreateCollectionModal } from './create-collection';
import type { EditCollectionMode } from './edit-collection/edit-collection';
@ -16,9 +17,11 @@ export const useEditCollection = () => {
setData(undefined);
}
}, []);
const { mount } = useMount('useEditCollection');
return {
node: (
useEffect(() => {
if (!data) return;
return mount(
<EditCollectionModal
init={data?.collection}
open={!!data}
@ -26,7 +29,10 @@ export const useEditCollection = () => {
onOpenChange={close}
onConfirm={data?.onConfirm ?? (() => {})}
/>
),
);
}, [close, data, mount]);
return {
open: (
collection: Collection,
mode?: EditCollectionMode
@ -59,9 +65,11 @@ export const useEditCollectionName = ({
setData(undefined);
}
}, []);
const { mount } = useMount('useEditCollectionName');
return {
node: (
useEffect(() => {
if (!data) return;
return mount(
<CreateCollectionModal
showTips={showTips}
title={title}
@ -70,7 +78,10 @@ export const useEditCollectionName = ({
onOpenChange={close}
onConfirm={data?.onConfirm ?? (() => {})}
/>
),
);
}, [close, data, mount, showTips, title]);
return {
open: (name: string): Promise<string> =>
new Promise<string>(res => {
setData({

View File

@ -62,8 +62,7 @@ export const ExplorerCollectionNode = ({
const { globalContextService } = useServices({
GlobalContextService,
});
const { open: openEditCollectionModal, node: editModal } =
useEditCollection();
const { open: openEditCollectionModal } = useEditCollection();
const active =
useLiveData(globalContextService.globalContext.collectionId.$) ===
collectionId;
@ -211,29 +210,26 @@ export const ExplorerCollectionNode = ({
}
return (
<>
<ExplorerTreeNode
icon={CollectionIcon}
name={collection.name || t['Untitled']()}
dndData={dndData}
onDrop={handleDropOnCollection}
renameable
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/collection/${collection.id}`}
active={active}
canDrop={handleCanDrop}
reorderable={reorderable}
onRename={handleRename}
childrenPlaceholder={<Empty onDrop={handleDropOnPlaceholder} />}
operations={finalOperations}
dropEffect={handleDropEffectOnCollection}
data-testid={`explorer-collection-${collectionId}`}
>
<ExplorerCollectionNodeChildren collection={collection} />
</ExplorerTreeNode>
{editModal}
</>
<ExplorerTreeNode
icon={CollectionIcon}
name={collection.name || t['Untitled']()}
dndData={dndData}
onDrop={handleDropOnCollection}
renameable
collapsed={collapsed}
setCollapsed={setCollapsed}
to={`/collection/${collection.id}`}
active={active}
canDrop={handleCanDrop}
reorderable={reorderable}
onRename={handleRename}
childrenPlaceholder={<Empty onDrop={handleDropOnPlaceholder} />}
operations={finalOperations}
dropEffect={handleDropEffectOnCollection}
data-testid={`explorer-collection-${collectionId}`}
>
<ExplorerCollectionNodeChildren collection={collection} />
</ExplorerTreeNode>
);
};

View File

@ -25,7 +25,7 @@ export const ExplorerCollections = () => {
});
const explorerSection = explorerService.sections.collections;
const collections = useLiveData(collectionService.collections$);
const { node, open: openCreateCollectionModel } = useEditCollectionName({
const { open: openCreateCollectionModel } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
@ -52,40 +52,37 @@ export const ExplorerCollections = () => {
]);
return (
<>
<CollapsibleSection
name="collections"
testId="explorer-collections"
title={t['com.affine.rootAppSidebar.collections']()}
actions={
<IconButton
data-testid="explorer-bar-add-collection-button"
onClick={handleCreateCollection}
size="16"
tooltip={t[
'com.affine.rootAppSidebar.explorer.collection-section-add-tooltip'
]()}
>
<PlusIcon />
</IconButton>
}
>
<ExplorerTreeRoot
placeholder={<RootEmpty onClickCreate={handleCreateCollection} />}
<CollapsibleSection
name="collections"
testId="explorer-collections"
title={t['com.affine.rootAppSidebar.collections']()}
actions={
<IconButton
data-testid="explorer-bar-add-collection-button"
onClick={handleCreateCollection}
size="16"
tooltip={t[
'com.affine.rootAppSidebar.explorer.collection-section-add-tooltip'
]()}
>
{collections.map(collection => (
<ExplorerCollectionNode
key={collection.id}
collectionId={collection.id}
reorderable={false}
location={{
at: 'explorer:collection:list',
}}
/>
))}
</ExplorerTreeRoot>
</CollapsibleSection>
{node}
</>
<PlusIcon />
</IconButton>
}
>
<ExplorerTreeRoot
placeholder={<RootEmpty onClickCreate={handleCreateCollection} />}
>
{collections.map(collection => (
<ExplorerCollectionNode
key={collection.id}
collectionId={collection.id}
reorderable={false}
location={{
at: 'explorer:collection:list',
}}
/>
))}
</ExplorerTreeRoot>
</CollapsibleSection>
);
};

View File

@ -40,7 +40,7 @@ export const AllCollection = () => {
}, [collections]);
const navigateHelper = useNavigateHelper();
const { open, node } = useEditCollectionName({
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
@ -74,16 +74,12 @@ export const AllCollection = () => {
collections={collections}
collectionMetas={collectionMetas}
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
node={node}
handleCreateCollection={handleCreateCollection}
/>
) : (
<EmptyCollectionList
heading={
<CollectionListHeader
node={node}
onCreate={handleCreateCollection}
/>
<CollectionListHeader onCreate={handleCreateCollection} />
}
/>
)}

View File

@ -72,11 +72,7 @@ export const AllPage = () => {
filters={filters}
/>
) : (
<EmptyPageList
type="all"
heading={<PageListHeader />}
docCollection={currentWorkspace.docCollection}
/>
<EmptyPageList type="all" heading={<PageListHeader />} />
)}
</div>
</ViewBody>

View File

@ -1,19 +1,14 @@
import { notify } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty/collection-detail';
import {
AffineShapeIcon,
useEditCollection,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { CollectionService } from '@affine/core/modules/collection';
import type { Collection } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import {
CloseIcon,
FilterIcon,
PageIcon,
ViewLayersIcon,
} from '@blocksuite/icons/rc';
import { useI18n } from '@affine/i18n';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import {
GlobalContextService,
useLiveData,
@ -33,7 +28,6 @@ import {
ViewTitle,
} from '../../../modules/workbench';
import { WorkspaceSubPath } from '../../../shared';
import * as styles from './collection.css';
import { CollectionDetailHeader } from './header';
export const CollectionDetail = ({
@ -41,7 +35,7 @@ export const CollectionDetail = ({
}: {
collection: Collection;
}) => {
const { node, open } = useEditCollection();
const { open } = useEditCollection();
const collectionService = useService(CollectionService);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
@ -64,7 +58,6 @@ export const CollectionDetail = ({
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
/>
</ViewBody>
{node}
</>
);
};
@ -138,25 +131,7 @@ export const Component = function CollectionPage() {
const Placeholder = ({ collection }: { collection: Collection }) => {
const workspace = useService(WorkspaceService).workspace;
const collectionService = useService(CollectionService);
const { node, open } = useEditCollection();
const { jumpToCollections } = useNavigateHelper();
const openPageEdit = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
collectionService.updateCollection(ret.id, () => ret);
}, [open, collection, collectionService]);
const openRuleEdit = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'rule');
collectionService.updateCollection(ret.id, () => ret);
}, [collection, open, collectionService]);
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');
}, []);
const t = useI18n();
const handleJumpToCollections = useCallback(() => {
@ -206,142 +181,11 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
</div>
</ViewHeader>
<ViewBody>
<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)',
}}
>
{t['com.affine.collection.emptyCollection']()}
</div>
<div
style={{
fontSize: 12,
lineHeight: '20px',
color: 'var(--affine-text-secondary-color)',
textAlign: 'center',
}}
>
{t['com.affine.collection.emptyCollectionDescription']()}
</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' }}>
{t['com.affine.collection.addPages']()}
</span>
</div>
<div onClick={openRuleEdit} className={styles.placeholderButton}>
<FilterIcon
style={{
width: 20,
height: 20,
color: 'var(--affine-icon-color)',
}}
/>
<span style={{ padding: '0 4px' }}>
{t['com.affine.collection.addRules']()}
</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>{t['com.affine.collection.helpInfo']()}</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>
<Trans i18nKey="com.affine.collection.addPages.tips">
<span style={{ fontWeight: 600 }}>Add pages:</span> You can
freely select pages and add them to the collection.
</Trans>
</div>
<div>
<Trans i18nKey="com.affine.collection.addRules.tips">
<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.
</Trans>
</div>
</div>
</div>
) : null}
</div>
<EmptyCollectionDetail
collection={collection}
style={{ height: '100%' }}
/>
</ViewBody>
{node}
</>
);
};

View File

@ -2,7 +2,13 @@ import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const pageListEmptyStyle = style({
height: 'calc(100% - 52px)',
height: '100%',
display: 'flex',
flexDirection: 'column',
});
export const pageListEmptyBody = style({
height: 0,
flex: 1,
});
export const emptyDescButton = style({
cursor: 'pointer',

View File

@ -1,7 +1,5 @@
import { Empty, IconButton } from '@affine/component';
import { Trans, useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import type { DocCollection } from '@blocksuite/store';
import { EmptyDocs, EmptyTags } from '@affine/core/components/affine/empty';
import { EmptyCollections } from '@affine/core/components/affine/empty/collections';
import type { ReactNode } from 'react';
import * as styles from './page-list-empty.css';
@ -9,79 +7,38 @@ import * as styles from './page-list-empty.css';
export const EmptyPageList = ({
type,
heading,
tagId,
}: {
type: 'all' | 'trash' | 'shared' | 'public';
docCollection: DocCollection;
type: 'all' | 'trash';
heading?: ReactNode;
tagId?: string;
}) => {
const t = useI18n();
const getEmptyDescription = () => {
if (type === 'all') {
const createNewPageButton = (
<IconButton
withoutHover
className={styles.plusButton}
variant="solid"
icon={<PlusIcon />}
/>
);
if (environment.isElectron) {
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['com.affine.workspaceSubPath.trash.empty-description']();
}
if (type === 'shared') {
return t['emptySharedPages']();
}
return;
};
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<Empty
title={t['com.affine.emptyDesc']()}
description={
<span className={styles.descWrapper}>{getEmptyDescription()}</span>
}
<EmptyDocs
tagId={tagId}
type={type}
className={styles.pageListEmptyBody}
/>
</div>
);
};
export const EmptyCollectionList = ({ heading }: { heading: ReactNode }) => {
const t = useI18n();
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<Empty title={t['com.affine.emptyDesc.collection']()} />
<EmptyCollections className={styles.pageListEmptyBody} />
</div>
);
};
export const EmptyTagList = ({ heading }: { heading: ReactNode }) => {
const t = useI18n();
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<Empty title={t['com.affine.emptyDesc.tag']()} />
<EmptyTags className={styles.pageListEmptyBody} />
</div>
);
};

View File

@ -79,13 +79,13 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
) : (
<EmptyPageList
type="all"
tagId={tagId}
heading={
<TagPageListHeader
tag={currentTag}
workspaceId={currentWorkspace.id}
/>
}
docCollection={currentWorkspace.docCollection}
/>
)}
</div>

View File

@ -75,10 +75,7 @@ export const TrashPage = () => {
{filteredPageMetas.length > 0 ? (
<VirtualizedTrashList />
) : (
<EmptyPageList
type="trash"
docCollection={currentWorkspace.docCollection}
/>
<EmptyPageList type="trash" />
)}
</div>
</ViewBody>

View File

@ -686,6 +686,19 @@
"com.affine.emptyDesc": "There's no doc here yet",
"com.affine.emptyDesc.collection": "There's no collection here yet",
"com.affine.emptyDesc.tag": "There's no tag here yet",
"com.affine.empty.docs.title": "Docs management",
"com.affine.empty.docs.all-description": "Create your first doc here.",
"com.affine.empty.docs.trash-description": "Deleted docs will appear here.",
"com.affine.empty.docs.action.new-doc": "New doc",
"com.affine.empty.collections.title": "Collection management",
"com.affine.empty.collections.description": "Create your first collection here.",
"com.affine.empty.collections.action.new-collection": "Add collection",
"com.affine.empty.tags.title": "Tag management",
"com.affine.empty.tags.description": "Create a new tag for your documents.",
"com.affine.empty.collection-detail.title": "Empty collection",
"com.affine.empty.collection-detail.description": "Collection is a smart folder where you can manually add docs or automatically add docs through rules.",
"com.affine.empty.collection-detail.action.add-doc": "Add docs",
"com.affine.empty.collection-detail.action.add-rule": "Add rules",
"com.affine.enableAffineCloudModal.button.cancel": "Cancel",
"com.affine.error.contact.description": "If you are still experiencing this issue, please <1>contact us through the community.</1>",
"com.affine.error.no-page-root.title": "Doc content is missing",

View File

@ -1,7 +1,6 @@
import { notify, useThemeColorV2 } from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { isEmptyCollection } from '@affine/core/pages/workspace/collection';
import { WorkspaceSubPath } from '@affine/core/shared';
import {
GlobalContextService,
@ -13,7 +12,7 @@ import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { CollectionDetail, EmptyCollection } from '../../../views';
import { CollectionDetail } from '../../../views';
export const Component = () => {
useThemeColorV2('layer/background/secondary');
@ -70,10 +69,6 @@ export const Component = () => {
return null;
}
if (isEmptyCollection(collection)) {
return <EmptyCollection collection={collection} />;
}
return (
<>
<CollectionDetail collection={collection} />

View File

@ -1,4 +1,6 @@
import { IconButton, MobileMenu } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { isEmptyCollection } from '@affine/core/pages/workspace/collection';
import type { Collection } from '@affine/env/filter';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
@ -34,6 +36,15 @@ export const CollectionDetail = ({
}: {
collection: Collection;
}) => {
if (isEmptyCollection(collection)) {
return (
<>
<DetailHeader collection={collection} />
<EmptyCollectionDetail collection={collection} absoluteCenter />
</>
);
}
return (
<>
<DetailHeader collection={collection} />

View File

@ -1,3 +1,4 @@
import { EmptyCollections } from '@affine/core/components/affine/empty';
import type { CollectionMeta } from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import { useLiveData, useService } from '@toeverything/infra';
@ -19,6 +20,10 @@ export const CollectionList = () => {
[collections]
);
if (!collectionMetas.length) {
return <EmptyCollections absoluteCenter />;
}
return (
<ul className={list}>
{collectionMetas.map(meta => (

View File

@ -1,3 +1,4 @@
import { EmptyDocs } from '@affine/core/components/affine/empty';
import {
type ItemGroupDefinition,
type ItemGroupProps,
@ -78,6 +79,10 @@ export const AllDocList = ({
return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
}, [finalPageMetas, groupDefs]);
if (!groups.length) {
return <EmptyDocs absoluteCenter tagId={tag?.id} />;
}
return (
<div className={styles.groups}>
{groups.map(group => (

View File

@ -1,17 +1,10 @@
import type { Tag } from '@affine/core/modules/tag';
import { useLiveData } from '@toeverything/infra';
import { AppTabs } from '../../../components';
import { AllDocList } from '../doc';
import { TagDetailHeader } from './detail-header';
import { TagEmpty } from './empty';
export const TagDetail = ({ tag }: { tag: Tag }) => {
const pageIds = useLiveData(tag?.pageIds$);
if (!pageIds.length) {
return <TagEmpty tag={tag} />;
}
return (
<>
<TagDetailHeader tag={tag} />

View File

@ -1,3 +1,4 @@
import { EmptyTags } from '@affine/core/components/affine/empty';
import { TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
@ -8,6 +9,10 @@ export const TagList = () => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
if (!tags.length) {
return <EmptyTags absoluteCenter />;
}
return (
<ul className={list}>
{tags.map(tag => (

View File

@ -12,10 +12,15 @@ export const RecentDocs = ({ max = 5 }: { max?: number }) => {
const cardMetas = useMemo(() => {
return [...allPageMetas]
.filter(meta => !meta.trash)
.sort((a, b) => (b.updatedDate ?? 0) - (a.updatedDate ?? 0))
.slice(0, max);
}, [allPageMetas, max]);
if (!cardMetas.length) {
return null;
}
return (
<CollapsibleSection
name="recent"