mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
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:
@@ -206,6 +206,9 @@ export const CollectionPageListHeader = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const TagPageListHeader = ({
|
||||
tag,
|
||||
workspaceId,
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,3 +7,8 @@ export const body = style({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const scrollArea = style({
|
||||
height: 0,
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user