feat(component, mobile): masonry layout with virtual scroll support, adapted with all docs

This commit is contained in:
Cats Juice 2024-12-19 17:02:24 +08:00
parent 4c48aee88b
commit 6f1c9ad69d
No known key found for this signature in database
GPG Key ID: 6A72BCCD064BD97F
20 changed files with 588 additions and 153 deletions

View File

@ -17,6 +17,7 @@ export * from './ui/loading';
export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/lottie/folder-icon';
export * from './ui/masonry';
export * from './ui/menu';
export * from './ui/modal';
export * from './ui/notification';

View File

@ -0,0 +1,29 @@
import { createContext } from 'react';
export interface MasonryItem {
id: string;
height: number;
}
export interface LayoutInfo {
x: number;
y: number;
w: number;
h: number;
}
export interface MasonryContext {
addItem: (item: MasonryItem, index?: number) => void;
removeItem: (id: string) => void;
items: MasonryItem[];
layoutMap: Map<MasonryItem['id'], LayoutInfo>;
sleepMap: Map<MasonryItem['id'], boolean>;
}
export const MasonryContext = createContext<MasonryContext>({
addItem: () => {},
removeItem: () => {},
items: [],
layoutMap: new Map(),
sleepMap: new Map(),
});

View File

@ -0,0 +1,7 @@
import { MasonryItem } from './masonry-item';
import { MasonryRoot } from './masonry-root';
export const Masonry = {
Item: MasonryItem,
Root: MasonryRoot,
};

View File

@ -0,0 +1,55 @@
import clsx from 'clsx';
import { useContext, useEffect, useMemo } from 'react';
import { MasonryContext, type MasonryItem as MasonryItemType } from './context';
import * as styles from './styles.css';
export interface MasonryItemProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'>,
MasonryItemType {}
export const MasonryItem = ({
id,
height,
className,
children,
style: styleProp,
...props
}: MasonryItemProps) => {
const { addItem, removeItem, layoutMap, sleepMap } =
useContext(MasonryContext);
useEffect(() => {
addItem({ id, height });
return () => {
removeItem(id);
};
}, [addItem, height, id, removeItem]);
const style = useMemo(() => {
const info = layoutMap.get(id);
if (!info) return { display: 'none' };
const { x, y, w, h } = info;
return {
...styleProp,
left: `${x}px`,
top: `${y}px`,
width: `${w}px`,
height: `${h}px`,
};
}, [id, layoutMap, styleProp]);
if (sleepMap.get(id)) return null;
if (!layoutMap.get(id)) return null;
return (
<div
data-masonry-item
className={clsx(styles.item, className)}
style={style}
{...props}
>
{children}
</div>
);
};

View File

@ -0,0 +1,187 @@
import clsx from 'clsx';
import { debounce } from 'lodash-es';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { observeResize } from '../../utils';
import { Scrollable } from '../scrollbar';
import { type LayoutInfo, MasonryContext, type MasonryItem } from './context';
import * as styles from './styles.css';
import { calcColumns, calcLayout, calcSleep } from './utils';
export interface MasonryRootProps extends React.HTMLAttributes<HTMLDivElement> {
gapX: number;
gapY: number;
paddingX?: number;
paddingY?: number;
/**
* Specify the width of the item.
* - `number`: The width of the item in pixels.
* - `'stretch'`: The item will stretch to fill the container.
* @default 'stretch'
*/
itemWidth?: number | 'stretch';
/**
* The minimum width of the item in pixels.
* @default 100
*/
itemWidthMin?: number;
virtualScroll?: boolean;
}
export const MasonryRoot = ({
gapX,
gapY,
itemWidth = 'stretch',
itemWidthMin = 100,
paddingX = 0,
paddingY = 0,
children,
style,
className,
virtualScroll = false,
...props
}: MasonryRootProps) => {
const rootRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<MasonryItem[]>([]);
const [height, setHeight] = useState(0);
const [layoutMap, setLayoutMap] = useState<
Map<MasonryItem['id'], LayoutInfo>
>(new Map());
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>(
new Map()
);
const addItem = useCallback((item: MasonryItem, index?: number) => {
if (index === undefined) {
setItems(prev => [...prev, item]);
} else {
setItems(prev => {
const limitedIndex = Math.max(0, Math.min(index, prev.length));
return [
...prev.slice(0, limitedIndex),
item,
...prev.slice(limitedIndex),
];
});
}
}, []);
const removeItem = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
const contextValue = useMemo(
() => ({
addItem,
removeItem,
items,
layoutMap,
sleepMap,
}),
[items, addItem, removeItem, layoutMap, sleepMap]
);
const updateSleepMap = useCallback(
(layoutMap: Map<MasonryItem['id'], LayoutInfo>, _scrollY?: number) => {
if (!virtualScroll) return;
const rootEl = rootRef.current;
if (!rootEl) return;
const scrollY = _scrollY ?? rootEl.scrollTop;
const sleepMap = calcSleep({
viewportHeight: rootEl.clientHeight,
scrollY,
layoutMap,
preloadHeight: 50,
});
setSleepMap(sleepMap);
},
[virtualScroll]
);
const calculateLayout = useCallback(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const totalWidth = rootEl.clientWidth;
const { columns, width } = calcColumns(
totalWidth,
itemWidth,
itemWidthMin,
gapX,
paddingX
);
const { layout, height } = calcLayout(items, {
columns,
width,
gapX,
gapY,
paddingX,
paddingY,
});
setLayoutMap(layout);
setHeight(height);
updateSleepMap(layout);
}, [
gapX,
gapY,
itemWidth,
itemWidthMin,
items,
paddingX,
paddingY,
updateSleepMap,
]);
useEffect(() => {
calculateLayout();
if (rootRef.current) {
return observeResize(rootRef.current, calculateLayout);
}
return;
}, [calculateLayout]);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
if (virtualScroll) {
const handler = debounce((e: Event) => {
const scrollY = (e.target as HTMLElement).scrollTop;
updateSleepMap(layoutMap, scrollY);
}, 50);
rootEl.addEventListener('scroll', handler);
return () => {
rootEl.removeEventListener('scroll', handler);
};
}
return;
}, [layoutMap, updateSleepMap, virtualScroll]);
return (
<MasonryContext.Provider value={contextValue}>
<Scrollable.Root>
<Scrollable.Viewport
ref={rootRef}
data-masonry-root
className={clsx('scrollable', styles.root, className)}
style={{
...style,
paddingLeft: paddingX,
paddingRight: paddingX,
paddingTop: paddingY,
paddingBottom: paddingY,
}}
{...props}
>
{children}
<div style={{ height }} />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</MasonryContext.Provider>
);
};

View File

@ -0,0 +1,55 @@
import { ResizePanel } from '../resize-panel/resize-panel';
import { MasonryItem } from './masonry-item';
import { MasonryRoot } from './masonry-root';
export default {
title: 'UI/Masonry',
};
const Card = ({ children }: { children: React.ReactNode }) => {
return (
<div
style={{
width: '100%',
height: '100%',
borderRadius: 10,
border: `1px solid rgba(100, 100, 100, 0.2)`,
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)',
padding: 10,
}}
>
{children}
</div>
);
};
const cards = Array.from({ length: 10000 }, (_, i) => {
return {
id: 'card-' + i,
height: Math.round(100 + Math.random() * 100),
};
});
export const BasicVirtualScroll = () => {
return (
<ResizePanel width={800} height={600}>
<MasonryRoot
gapX={10}
gapY={10}
style={{ width: '100%', height: '100%' }}
paddingX={12}
paddingY={12}
>
{cards.map(card => (
<MasonryItem key={card.id} id={card.id} height={card.height}>
<Card>
<h1>Hello</h1>
<p>World</p>
{card.id}
</Card>
</MasonryItem>
))}
</MasonryRoot>
</ResizePanel>
);
};

View File

@ -0,0 +1,14 @@
import { style } from '@vanilla-extract/css';
export const root = style({
position: 'relative',
selectors: {
'&.scrollable': {
overflowY: 'auto',
},
},
});
export const item = style({
position: 'absolute',
});

View File

@ -0,0 +1,87 @@
import type { LayoutInfo, MasonryItem } from './context';
export const calcColumns = (
totalWidth: number,
itemWidth: number | 'stretch',
itemWidthMin: number,
gapX: number,
paddingX: number
) => {
const availableWidth = totalWidth - paddingX * 2;
if (itemWidth === 'stretch') {
let columns = 1;
while (columns * itemWidthMin + (columns - 1) * gapX < availableWidth) {
columns++;
}
const finalColumns = columns - 1;
const finalWidth =
(availableWidth - (finalColumns - 1) * gapX) / finalColumns;
return {
columns: finalColumns,
width: finalWidth,
};
} else {
let columns = 1;
while (columns * itemWidth + (columns - 1) * gapX < availableWidth) {
columns++;
}
return {
columns: columns - 1,
width: itemWidth,
};
}
};
export const calcLayout = (
items: MasonryItem[],
options: {
columns: number;
width: number;
gapX: number;
gapY: number;
paddingX: number;
paddingY: number;
}
) => {
const { columns, width, gapX, gapY, paddingX, paddingY } = options;
const layoutMap = new Map<MasonryItem['id'], LayoutInfo>();
const heightStack = Array.from({ length: columns }, () => paddingY);
items.forEach(item => {
const minHeight = Math.min(...heightStack);
const minHeightIndex = heightStack.indexOf(minHeight);
const x = minHeightIndex * (width + gapX) + paddingX;
const y = minHeight + gapY;
heightStack[minHeightIndex] = y + item.height;
layoutMap.set(item.id, { x, y, w: width, h: item.height });
});
const finalHeight = Math.max(...heightStack) + paddingY;
return { layout: layoutMap, height: finalHeight };
};
export const calcSleep = (options: {
viewportHeight: number;
scrollY: number;
layoutMap: Map<MasonryItem['id'], LayoutInfo>;
preloadHeight: number;
}) => {
const { viewportHeight, scrollY, layoutMap, preloadHeight } = options;
const sleepMap = new Map<MasonryItem['id'], boolean>();
layoutMap.forEach((layout, id) => {
const { y, h } = layout;
const isInView =
y + h + preloadHeight > scrollY &&
y - preloadHeight < scrollY + viewportHeight;
sleepMap.set(id, !isInView);
});
return sleepMap;
};

View File

@ -10,15 +10,23 @@ import { type AppTabLink, tabs } from './data';
import * as styles from './styles.css';
import { TabItem } from './tab-item';
export const AppTabs = ({ background }: { background?: string }) => {
export const AppTabs = ({
background,
fixed = true,
}: {
background?: string;
fixed?: boolean;
}) => {
const virtualKeyboardService = useService(VirtualKeyboardService);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
return createPortal(
const tab = (
<SafeArea
id="app-tabs"
bottom
className={styles.appTabs}
bottomOffset={2}
data-fixed={fixed}
style={{
...assignInlineVars({
[styles.appTabsBackground]: background,
@ -26,7 +34,7 @@ export const AppTabs = ({ background }: { background?: string }) => {
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
}}
>
<ul className={styles.appTabsInner} id="app-tabs" role="tablist">
<ul className={styles.appTabsInner} role="tablist">
{tabs.map(tab => {
if ('to' in tab) {
return <AppTabLink route={tab} key={tab.key} />;
@ -39,9 +47,10 @@ export const AppTabs = ({ background }: { background?: string }) => {
}
})}
</ul>
</SafeArea>,
document.body
</SafeArea>
);
return fixed ? createPortal(tab, document.body) : tab;
};
const AppTabLink = ({ route }: { route: AppTabLink }) => {

View File

@ -14,9 +14,16 @@ export const appTabs = style({
width: '100dvw',
zIndex: 1,
marginBottom: -2,
selectors: {
'&[data-fixed="true"]': {
position: 'fixed',
bottom: -2,
zIndex: 1,
marginBottom: 0,
},
},
});
export const appTabsInner = style({
display: 'flex',

View File

@ -1,4 +1,4 @@
import { IconButton, observeIntersection, Skeleton } from '@affine/component';
import { IconButton, Skeleton } from '@affine/component';
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
@ -11,14 +11,7 @@ import {
import type { DocMeta } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { forwardRef, type ReactNode, useMemo, useRef } from 'react';
import * as styles from './styles.css';
import { DocCardTags } from './tag';
@ -66,20 +59,6 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
return { height: `${rows * 18}px` };
}, [autoHeightById, meta.id]);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!containerRef.current) return;
const dispose = observeIntersection(containerRef.current, entry => {
setVisible(entry.isIntersecting);
});
return () => {
dispose();
};
}, []);
return (
<WorkbenchLink
to={`/${meta.id}`}
@ -94,20 +73,14 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
className={clsx(styles.card, className)}
data-testid="doc-card"
data-doc-id={meta.id}
data-visible={visible}
{...attrs}
>
{visible && (
<>
<header className={styles.head} data-testid="doc-card-header">
<h3 className={styles.title}>{title}</h3>
<IconButton
aria-label="favorite"
icon={
<IsFavoriteIcon
onClick={toggleFavorite}
favorite={favorited}
/>
<IsFavoriteIcon onClick={toggleFavorite} favorite={favorited} />
}
/>
</header>
@ -124,8 +97,6 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
/>
</main>
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
</>
)}
</WorkbenchLink>
);
}

View File

@ -0,0 +1,31 @@
import { type HTMLAttributes, type ReactNode, useEffect } from 'react';
import { AppTabs } from '../app-tabs';
import * as styles from './styles.css';
interface PageProps extends HTMLAttributes<HTMLDivElement> {
tab?: boolean;
header?: ReactNode;
}
/**
* A Page is a full-screen container that will not scroll on document.
*/
export const Page = ({ children, tab = true, header, ...attrs }: PageProps) => {
// disable scroll on body
useEffect(() => {
const prevOverflowY = document.body.style.overflowY;
document.body.style.overflowY = 'hidden';
return () => {
document.body.style.overflowY = prevOverflowY;
};
}, []);
return (
<main className={styles.page} {...attrs} data-tab={tab}>
{header}
{children}
{tab ? <AppTabs fixed={false} /> : null}
</main>
);
};

View File

@ -0,0 +1,13 @@
import { style } from '@vanilla-extract/css';
export const page = style({
width: '100dvw',
height: '100dvh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
selectors: {
'&[data-tab="true"]': {},
},
});

View File

@ -1,18 +1,14 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../components';
import { Page } from '../../components/page';
import { AllDocList, AllDocsHeader, AllDocsMenu } from '../../views';
export const Component = () => {
useThemeColorV2('layer/background/mobile/primary');
return (
<>
<AllDocsHeader operations={<AllDocsMenu />} />
<SafeArea bottom>
<Page header={<AllDocsHeader operations={<AllDocsMenu />} />} tab>
<AllDocList />
</SafeArea>
<AppTabs />
</>
</Page>
);
};

View File

@ -10,7 +10,6 @@ import {
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { CollectionDetail } from '../../../views';
export const Component = () => {
@ -68,10 +67,5 @@ export const Component = () => {
return null;
}
return (
<>
<CollectionDetail collection={collection} />
<AppTabs />
</>
);
return <CollectionDetail collection={collection} />;
};

View File

@ -3,11 +3,13 @@ import { createVar, globalStyle } from '@vanilla-extract/css';
export const globalVars = {
appTabHeight: createVar('appTabHeight'),
appTabSafeArea: createVar('appTabSafeArea'),
};
globalStyle(':root', {
vars: {
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
},
userSelect: 'none',
WebkitUserSelect: 'none',
@ -18,17 +20,17 @@ globalStyle('body', {
minHeight: '100dvh',
overflowY: 'unset',
});
globalStyle('body:has(#app-tabs)', {
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
globalStyle('body:has(> #app-tabs)', {
paddingBottom: globalVars.appTabSafeArea,
});
globalStyle('body:has(#app-tabs) affine-keyboard-toolbar[data-shrink="true"]', {
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
paddingBottom: globalVars.appTabSafeArea,
});
globalStyle('body:has(#app-tabs) affine-keyboard-tool-panel', {
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`,
});
globalStyle('body:has(#app-tabs) edgeless-toolbar-widget', {
bottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
bottom: globalVars.appTabSafeArea,
});
globalStyle('html', {
height: '100dvh',

View File

@ -1,7 +1,8 @@
import { IconButton, MobileMenu } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { isEmptyCollection } from '@affine/core/desktop/pages/workspace/collection';
import { PageHeader } from '@affine/core/mobile/components';
import { AppTabs, PageHeader } from '@affine/core/mobile/components';
import { Page } from '@affine/core/mobile/components/page';
import type { Collection } from '@affine/env/filter';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
@ -42,14 +43,14 @@ export const CollectionDetail = ({
<>
<DetailHeader collection={collection} />
<EmptyCollectionDetail collection={collection} absoluteCenter />
<AppTabs />
</>
);
}
return (
<>
<DetailHeader collection={collection} />
<Page header={<DetailHeader collection={collection} />}>
<AllDocList collection={collection} />
</>
</Page>
);
};

View File

@ -1,13 +1,10 @@
import { EmptyDocs } from '@affine/core/components/affine/empty';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
type ItemGroupDefinition,
type ItemGroupProps,
useAllDocDisplayProperties,
useFilteredPageMetas,
usePageItemGroupDefinitions,
} from '@affine/core/components/page-list';
import { itemsToItemGroups } from '@affine/core/components/page-list/items-to-item-group';
import type { Tag } from '@affine/core/modules/tag';
import type { Collection, Filter } from '@affine/env/filter';
import type { DocMeta } from '@blocksuite/affine/store';
@ -19,7 +16,7 @@ import { useMemo } from 'react';
import * as styles from './list.css';
import { MasonryDocs } from './masonry';
const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
export const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
const [properties] = useAllDocDisplayProperties();
const showTags = properties.displayProperties.tags;
@ -53,6 +50,7 @@ export const AllDocList = ({
tag,
filters = [],
}: AllDocListProps) => {
const [properties] = useAllDocDisplayProperties();
const workspace = useService(WorkspaceService).workspace;
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
@ -72,22 +70,29 @@ export const AllDocList = ({
return filteredPageMetas;
}, [filteredPageMetas, tag, tagPageIds]);
const groupDefs =
usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
// const groupDefs =
// usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
const groups = useMemo(() => {
return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
}, [finalPageMetas, groupDefs]);
// const groups = useMemo(() => {
// return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
// }, [finalPageMetas, groupDefs]);
if (!groups.length) {
if (!finalPageMetas.length) {
return <EmptyDocs absoluteCenter tagId={tag?.id} />;
}
// return (
// <div className={styles.groups}>
// {groups.map(group => (
// <DocGroup key={group.id} group={group} />
// ))}
// </div>
// );
return (
<div className={styles.groups}>
{groups.map(group => (
<DocGroup key={group.id} group={group} />
))}
</div>
<MasonryDocs
items={finalPageMetas}
showTags={properties.displayProperties.tags}
/>
);
};

View File

@ -1,31 +1,11 @@
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
import { Masonry } from '@affine/component';
import type { DocMeta } from '@blocksuite/affine/store';
import { useCallback, useMemo, useState } from 'react';
import { calcRowsById, DocCard } from '../../../components';
import * as styles from './masonry.css';
const calcColumnCount = () => {
const maxCardWidth = 220;
const windowWidth = window.innerWidth;
const newColumnCount = Math.floor(
(windowWidth - styles.paddingX * 2 - styles.columnGap) / maxCardWidth
);
return Math.max(newColumnCount, 2);
};
const calcColumns = (items: DocMeta[], length: number) => {
const columns = Array.from({ length }, () => [] as DocMeta[]);
const heights = Array.from({ length }, () => 0);
items.forEach(item => {
const itemHeight = calcRowsById(item.id);
const minHeightIndex = heights.indexOf(Math.min(...heights));
heights[minHeightIndex] += itemHeight;
columns[minHeightIndex].push(item);
});
return columns;
const fullStyle = {
width: '100%',
height: '100%',
};
export const MasonryDocs = ({
@ -35,32 +15,25 @@ export const MasonryDocs = ({
items: DocMeta[];
showTags?: boolean;
}) => {
const [columnCount, setColumnCount] = useState(calcColumnCount);
const updateColumnCount = useCallback(() => {
setColumnCount(calcColumnCount());
}, []);
useGlobalEvent('resize', updateColumnCount);
const columns = useMemo(
() => calcColumns(items, columnCount),
[items, columnCount]
);
return (
<div className={styles.columns}>
{columns.map((col, index) => (
<div key={`${columnCount}-${index}`} className={styles.column}>
{col.map(item => (
<DocCard
<Masonry.Root
style={fullStyle}
itemWidthMin={130}
gapX={17}
gapY={10}
paddingX={16}
paddingY={16}
virtualScroll
>
{items.map(item => (
<Masonry.Item
key={item.id}
showTags={showTags}
meta={item}
autoHeightById
/>
id={item.id}
height={calcRowsById(item.id) * 18 + 95}
>
<DocCard style={fullStyle} meta={item} showTags={showTags} />
</Masonry.Item>
))}
</div>
))}
</div>
</Masonry.Root>
);
};

View File

@ -1,15 +1,13 @@
import { Page } from '@affine/core/mobile/components/page';
import type { Tag } from '@affine/core/modules/tag';
import { AppTabs } from '../../../components';
import { AllDocList } from '../doc';
import { TagDetailHeader } from './detail-header';
export const TagDetail = ({ tag }: { tag: Tag }) => {
return (
<>
<TagDetailHeader tag={tag} />
<Page header={<TagDetailHeader tag={tag} />} tab>
<AllDocList tag={tag} />
<AppTabs />
</>
</Page>
);
};