feat(core): new docs list for tag detail (#12298)

close AF-2583

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

## Summary by CodeRabbit

- **New Features**
  - Introduced a new tag list header with breadcrumb navigation and a tag selector dropdown for improved navigation and tag management.
  - Added a searchable dropdown menu for selecting and switching between tags.

- **Improvements**
  - Updated the tag detail page to use a more dynamic, subscription-based document explorer for displaying tagged content.
  - Enhanced header controls with an updated display menu button.

- **Style**
  - Added comprehensive new styles for the tag list header and tag selector components.
  - Introduced a new scroll area style for flexible layout.

- **Documentation**
  - Marked an older page list header component as deprecated.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-19 02:56:03 +00:00
parent 8b669b725b
commit 85bb728ca8
6 changed files with 430 additions and 38 deletions

View File

@@ -206,6 +206,9 @@ export const CollectionPageListHeader = ({
);
};
/**
* @deprecated
*/
export const TagPageListHeader = ({
tag,
workspaceId,

View File

@@ -1,12 +1,12 @@
import { ExplorerDisplayMenuButton } from '@affine/core/components/explorer/display-menu';
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';
export const TagDetailHeader = () => {
return (
<Header
left={<ExplorerNavigation active={'tags'} />}
right={<PageDisplayMenu />}
right={<ExplorerDisplayMenuButton />}
/>
);
};

View File

@@ -7,3 +7,8 @@ export const body = style({
height: '100%',
width: '100%',
});
export const scrollArea = style({
height: 0,
flexGrow: 1,
});

View File

@@ -1,8 +1,9 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
TagPageListHeader,
VirtualizedPageList,
} from '@affine/core/components/page-list';
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { TagService } from '@affine/core/modules/tag';
@@ -13,9 +14,8 @@ import {
ViewIcon,
ViewTitle,
} from '@affine/core/modules/workbench';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { PageNotFound } from '../../404';
@@ -23,11 +23,12 @@ import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import { EmptyPageList } from '../page-list-empty';
import { TagDetailHeader } from './header';
import * as styles from './index.css';
import { TagListHeader } from './list-header';
export const TagDetail = ({ tagId }: { tagId?: string }) => {
const [explorerContextValue] = useState(createDocExplorerContext);
const collectionRulesService = useService(CollectionRulesService);
const globalContext = useService(GlobalContextService).globalContext;
const currentWorkspace = useService(WorkspaceService).workspace;
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const permissionService = useService(WorkspacePermissionService);
const isAdmin = useLiveData(permissionService.permission.isAdmin$);
const isOwner = useLiveData(permissionService.permission.isOwner$);
@@ -35,14 +36,13 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
const tagList = useService(TagService).tagList;
const currentTag = useLiveData(tagList.tagByTagId$(tagId));
const pageIds = useLiveData(currentTag?.pageIds$);
const groupBy = useLiveData(explorerContextValue.groupBy$);
const orderBy = useLiveData(explorerContextValue.orderBy$);
const groups = useLiveData(explorerContextValue.groups$);
const filteredPageMetas = useMemo(() => {
const pageIdsSet = new Set(pageIds);
return pageMetas
.filter(page => pageIdsSet.has(page.id))
.filter(page => !page.trash);
}, [pageIds, pageMetas]);
const isEmpty =
groups.length === 0 ||
(groups.length && groups.every(group => group.items.length === 0));
const isActiveView = useIsActiveView();
const tagName = useLiveData(currentTag?.value$);
@@ -60,12 +60,57 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
return;
}, [currentTag, globalContext, isActiveView]);
if (!currentTag) {
useEffect(() => {
const subscription = collectionRulesService
.watch({
filters: [
{
type: 'system',
key: 'empty-journal',
method: 'is',
value: 'false',
},
{
type: 'system',
key: 'trash',
method: 'is',
value: 'false',
},
{
type: 'property',
key: 'tags',
method: 'include-all',
value: tagId,
},
],
groupBy,
orderBy,
})
.subscribe({
next: result => {
explorerContextValue.groups$.next(result.groups);
},
error: error => {
console.error(error);
},
});
return () => {
subscription.unsubscribe();
};
}, [
collectionRulesService,
explorerContextValue.groups$,
groupBy,
orderBy,
tagId,
]);
if (!currentTag || !tagId) {
return <PageNotFound />;
}
return (
<>
<DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={tagName ?? 'Untitled'} />
<ViewIcon icon="tag" />
<ViewHeader>
@@ -73,27 +118,17 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
tag={currentTag}
listItem={filteredPageMetas}
disableMultiDelete={!isAdmin && !isOwner}
/>
) : (
<EmptyPageList
type="all"
tagId={tagId}
heading={
<TagPageListHeader
tag={currentTag}
workspaceId={currentWorkspace.id}
/>
}
/>
)}
<TagListHeader tag={currentTag} />
<div className={styles.scrollArea}>
{isEmpty ? (
<EmptyPageList type="all" tagId={tagId} />
) : (
<DocsExplorer disableMultiDelete={!isAdmin && !isOwner} />
)}
</div>
</div>
</ViewBody>
</>
</DocExplorerContext.Provider>
);
};

View File

@@ -0,0 +1,164 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const header = 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 tagSelectorTrigger = style({
display: 'flex',
alignItems: 'center',
gap: 2,
cursor: 'pointer',
padding: '0px 2px',
borderRadius: 100,
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
});
export const tagSelectorTriggerIcon = style({
width: 18,
height: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
':after': {
width: 7,
height: 7,
borderRadius: '50%',
content: '',
display: 'block',
backgroundColor: 'currentColor',
},
});
export const tagSelectorTriggerName = style({
fontSize: 14,
lineHeight: '22px',
color: cssVarV2.text.primary,
});
export const tagSelectorTriggerDropdown = style({
width: 24,
height: 24,
fontSize: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2.icon.primary,
});
export const tagSelectorMenuRoot = style({
padding: 0,
maxHeight: 400,
display: 'flex',
flexDirection: 'column',
gap: 2,
});
export const tagSelectorMenuHeader = style({
padding: '12px 12px 0 12px',
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const tagSelectorMenuSearchIcon = style({
fontSize: 16,
color: cssVarV2.icon.secondary,
});
export const tagSelectorMenuScrollArea = style({
height: 'fit-content',
flexGrow: 1,
flexShrink: 1,
display: 'flex',
flexDirection: 'column',
});
export const tagSelectorMenuViewport = style({
padding: '1px 8px 12px 8px',
display: 'flex',
flexDirection: 'column',
gap: 2,
height: 'fit-content',
flexGrow: 1,
});
export const tagSelectorMenuEmpty = style({
color: cssVarV2.text.secondary,
fontSize: 12,
lineHeight: '20px',
});
export const tagSelectorMenuItem = style({
padding: 0,
});
export const tagSelectorItem = style({
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 0px',
});
export const tagSelectorItemIcon = style({
width: 24,
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
':after': {
width: 7,
height: 7,
borderRadius: '50%',
content: '',
display: 'block',
backgroundColor: 'currentColor',
},
});
export const tagSelectorItemText = style({
fontSize: 14,
lineHeight: '22px',
color: cssVarV2.text.primary,
width: 0,
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const tagSelectorItemCheckedIcon = style({
fontSize: 16,
color: cssVarV2.button.primary,
});

View File

@@ -0,0 +1,185 @@
import {
Divider,
Menu,
MenuItem,
type MenuProps,
RowInput,
Scrollable,
} from '@affine/component';
import { type Tag, TagService } from '@affine/core/modules/tag';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { ArrowDownSmallIcon, DoneIcon, SearchIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type HTMLProps,
useCallback,
useRef,
useState,
} from 'react';
import * as styles from './list-header.css';
export const TagListHeader = ({ tag }: { tag: Tag }) => {
const t = useI18n();
return (
<header className={styles.header}>
<div className={styles.breadcrumb}>
<div className={styles.breadcrumbItem}>
<WorkbenchLink to="/tag" className={styles.breadcrumbLink}>
{t['Tags']()}
</WorkbenchLink>
</div>
<div className={styles.breadcrumbSeparator}>/</div>
<div className={styles.breadcrumbItem} data-active={true}>
<TagSelector currentTag={tag} />
</div>
</div>
<div className={styles.headerActions}></div>
</header>
);
};
const contentMenuOptions: MenuProps['contentOptions'] = {
align: 'start',
side: 'bottom',
sideOffset: 4,
className: styles.tagSelectorMenuRoot,
};
const TagSelector = ({ currentTag }: { currentTag: Tag }) => {
const [isOpen, setIsOpen] = useState(false);
const onClose = useCallback(() => {
setIsOpen(false);
}, []);
return (
<Menu
rootOptions={{
open: isOpen,
onOpenChange: setIsOpen,
}}
contentOptions={contentMenuOptions}
items={<TagSelectorMenu currentTagId={currentTag.id} onClose={onClose} />}
>
<TagSelectorTrigger currentTag={currentTag} />
</Menu>
);
};
const TagSelectorTrigger = forwardRef<
HTMLDivElement,
HTMLProps<HTMLDivElement> & { currentTag: Tag }
>(function TagSelectorTrigger({ currentTag, className, ...props }, ref) {
const tagColor = useLiveData(currentTag.color$);
const tagName = useLiveData(currentTag.value$);
return (
<div
className={clsx(styles.tagSelectorTrigger, className)}
ref={ref}
{...props}
>
<div
className={styles.tagSelectorTriggerIcon}
style={{ color: tagColor }}
/>
<div className={styles.tagSelectorTriggerName}>{tagName}</div>
<div className={styles.tagSelectorTriggerDropdown}>
<ArrowDownSmallIcon />
</div>
</div>
);
});
const TagSelectorMenu = ({
currentTagId,
onClose,
}: {
currentTagId: string;
onClose: () => void;
}) => {
const t = useI18n();
const [inputValue, setInputValue] = useState('');
const tagList = useService(TagService).tagList;
const filteredTags = useLiveData(
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
);
return (
<>
<header className={styles.tagSelectorMenuHeader}>
<SearchIcon className={styles.tagSelectorMenuSearchIcon} />
<RowInput
value={inputValue}
onChange={setInputValue}
placeholder={t['Search tags']()}
/>
</header>
<Divider size="thinner" />
<Scrollable.Root className={styles.tagSelectorMenuScrollArea}>
<Scrollable.Viewport className={styles.tagSelectorMenuViewport}>
{filteredTags.map(tag => {
return (
<TagLink
key={tag.id}
tag={tag}
checked={tag.id === currentTagId}
onClick={onClose}
/>
);
})}
{filteredTags.length === 0 ? (
<div className={styles.tagSelectorMenuEmpty}>
{t['Find 0 result']()}
</div>
) : null}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</>
);
};
const TagLink = ({
tag,
checked,
onClick,
}: {
tag: Tag;
checked: boolean;
onClick: () => void;
}) => {
const tagColor = useLiveData(tag.color$);
const tagTitle = useLiveData(tag.value$);
const aRef = useRef<HTMLAnchorElement>(null);
const onSelect = useCallback(() => {
aRef.current?.click();
}, []);
return (
<MenuItem onSelect={onSelect} className={styles.tagSelectorMenuItem}>
<WorkbenchLink
ref={aRef}
key={tag.id}
className={styles.tagSelectorItem}
data-tag-id={tag.id}
data-tag-value={tagTitle}
to={`/tag/${tag.id}`}
onClick={onClick}
>
<div
className={styles.tagSelectorItemIcon}
style={{ color: tagColor }}
/>
<div className={styles.tagSelectorItemText}>{tagTitle}</div>
{checked ? (
<DoneIcon className={styles.tagSelectorItemCheckedIcon} />
) : null}
</WorkbenchLink>
</MenuItem>
);
};