From 85bb728ca8ccbcac4d4f27921dcef57d82da457d Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Mon, 19 May 2025 02:56:03 +0000 Subject: [PATCH] feat(core): new docs list for tag detail (#12298) close AF-2583 ## 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. --- .../page-list/docs/page-list-header.tsx | 3 + .../desktop/pages/workspace/tag/header.tsx | 4 +- .../desktop/pages/workspace/tag/index.css.ts | 5 + .../src/desktop/pages/workspace/tag/index.tsx | 107 ++++++---- .../pages/workspace/tag/list-header.css.ts | 164 ++++++++++++++++ .../pages/workspace/tag/list-header.tsx | 185 ++++++++++++++++++ 6 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 packages/frontend/core/src/desktop/pages/workspace/tag/list-header.css.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/tag/list-header.tsx diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx index 094dffd3e3..e44e217ffa 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx @@ -206,6 +206,9 @@ export const CollectionPageListHeader = ({ ); }; +/** + * @deprecated + */ export const TagPageListHeader = ({ tag, workspaceId, diff --git a/packages/frontend/core/src/desktop/pages/workspace/tag/header.tsx b/packages/frontend/core/src/desktop/pages/workspace/tag/header.tsx index c235da1762..acc1eab42e 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/tag/header.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/tag/header.tsx @@ -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 (
} - right={} + right={} /> ); }; diff --git a/packages/frontend/core/src/desktop/pages/workspace/tag/index.css.ts b/packages/frontend/core/src/desktop/pages/workspace/tag/index.css.ts index a5f5542709..b8ea2677c7 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/tag/index.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/tag/index.css.ts @@ -7,3 +7,8 @@ export const body = style({ height: '100%', width: '100%', }); + +export const scrollArea = style({ + height: 0, + flexGrow: 1, +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/tag/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/tag/index.tsx index 1dd8a85fff..6dedca3e7d 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/tag/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/tag/index.tsx @@ -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 ; } return ( - <> + @@ -73,27 +118,17 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
- {filteredPageMetas.length > 0 ? ( - - ) : ( - - } - /> - )} + +
+ {isEmpty ? ( + + ) : ( + + )} +
- +
); }; diff --git a/packages/frontend/core/src/desktop/pages/workspace/tag/list-header.css.ts b/packages/frontend/core/src/desktop/pages/workspace/tag/list-header.css.ts new file mode 100644 index 0000000000..2b5c6c285e --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/tag/list-header.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/tag/list-header.tsx b/packages/frontend/core/src/desktop/pages/workspace/tag/list-header.tsx new file mode 100644 index 0000000000..5c83cef706 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/tag/list-header.tsx @@ -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 ( +
+
+
+ + {t['Tags']()} + +
+
/
+
+ +
+
+ +
+
+ ); +}; + +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 ( + } + > + + + ); +}; + +const TagSelectorTrigger = forwardRef< + HTMLDivElement, + HTMLProps & { currentTag: Tag } +>(function TagSelectorTrigger({ currentTag, className, ...props }, ref) { + const tagColor = useLiveData(currentTag.color$); + const tagName = useLiveData(currentTag.value$); + + return ( +
+
+
{tagName}
+
+ +
+
+ ); +}); + +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 ( + <> +
+ + +
+ + + + {filteredTags.map(tag => { + return ( + + ); + })} + {filteredTags.length === 0 ? ( +
+ {t['Find 0 result']()} +
+ ) : null} +
+ +
+ + ); +}; + +const TagLink = ({ + tag, + checked, + onClick, +}: { + tag: Tag; + checked: boolean; + onClick: () => void; +}) => { + const tagColor = useLiveData(tag.color$); + const tagTitle = useLiveData(tag.value$); + const aRef = useRef(null); + + const onSelect = useCallback(() => { + aRef.current?.click(); + }, []); + + return ( + + +
+
{tagTitle}
+ {checked ? ( + + ) : null} + + + ); +};