mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-04 02:49:57 +08:00
feat(component, mobile): masonry layout with virtual scroll support, adapted with all docs (#9208)
### Preview  ### Render when scrolling  ### api ```tsx const items = useMemo(() => { return { id: '', height: 100, children: <div></div> } }, []) <Masonry items={items} /> ```
This commit is contained in:
@@ -17,6 +17,7 @@ export * from './ui/loading';
|
|||||||
export * from './ui/lottie/collections-icon';
|
export * from './ui/lottie/collections-icon';
|
||||||
export * from './ui/lottie/delete-icon';
|
export * from './ui/lottie/delete-icon';
|
||||||
export * from './ui/lottie/folder-icon';
|
export * from './ui/lottie/folder-icon';
|
||||||
|
export * from './ui/masonry';
|
||||||
export * from './ui/menu';
|
export * from './ui/menu';
|
||||||
export * from './ui/modal';
|
export * from './ui/modal';
|
||||||
export * from './ui/notification';
|
export * from './ui/notification';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './masonry';
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ResizePanel } from '../resize-panel/resize-panel';
|
||||||
|
import { Masonry } from './masonry';
|
||||||
|
|
||||||
|
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,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const basicCards = Array.from({ length: 10000 }, (_, i) => {
|
||||||
|
return {
|
||||||
|
id: 'card-' + i,
|
||||||
|
height: Math.round(100 + Math.random() * 100),
|
||||||
|
children: (
|
||||||
|
<Card>
|
||||||
|
<h1>Hello</h1>
|
||||||
|
<p>World</p>
|
||||||
|
{i}
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BasicVirtualScroll = () => {
|
||||||
|
return (
|
||||||
|
<ResizePanel width={800} height={600}>
|
||||||
|
<Masonry
|
||||||
|
gapX={10}
|
||||||
|
gapY={10}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
paddingX={12}
|
||||||
|
paddingY={12}
|
||||||
|
virtualScroll
|
||||||
|
items={basicCards}
|
||||||
|
/>
|
||||||
|
</ResizePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionCards = Array.from({ length: 10000 }, (_, i) => {
|
||||||
|
return {
|
||||||
|
id: 'card-' + i,
|
||||||
|
height: Math.round(100 + Math.random() * 100),
|
||||||
|
children: <Card>{i}</Card>,
|
||||||
|
style: { transition: 'transform 0.2s ease' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CustomTransition = () => {
|
||||||
|
return (
|
||||||
|
<ResizePanel width={800} height={600}>
|
||||||
|
<Masonry
|
||||||
|
gapX={10}
|
||||||
|
gapY={10}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
paddingX={12}
|
||||||
|
paddingY={12}
|
||||||
|
virtualScroll
|
||||||
|
items={transitionCards}
|
||||||
|
locateMode="transform3d"
|
||||||
|
/>
|
||||||
|
</ResizePanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { throttle } from '@blocksuite/affine/global/utils';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { observeResize } from '../../utils';
|
||||||
|
import { Scrollable } from '../scrollbar';
|
||||||
|
import * as styles from './styles.css';
|
||||||
|
import type { MasonryItem, MasonryItemXYWH } from './type';
|
||||||
|
import { calcColumns, calcLayout, calcSleep } from './utils';
|
||||||
|
|
||||||
|
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
items: MasonryItem[];
|
||||||
|
|
||||||
|
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;
|
||||||
|
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Masonry = ({
|
||||||
|
items,
|
||||||
|
gapX = 12,
|
||||||
|
gapY = 12,
|
||||||
|
itemWidth = 'stretch',
|
||||||
|
itemWidthMin = 100,
|
||||||
|
paddingX = 0,
|
||||||
|
paddingY = 0,
|
||||||
|
className,
|
||||||
|
virtualScroll = false,
|
||||||
|
locateMode = 'leftTop',
|
||||||
|
...props
|
||||||
|
}: MasonryProps) => {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
const [layoutMap, setLayoutMap] = useState<
|
||||||
|
Map<MasonryItem['id'], MasonryItemXYWH>
|
||||||
|
>(new Map());
|
||||||
|
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSleepMap = useCallback(
|
||||||
|
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => {
|
||||||
|
if (!virtualScroll) return;
|
||||||
|
|
||||||
|
const rootEl = rootRef.current;
|
||||||
|
if (!rootEl) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// handle resize
|
||||||
|
useEffect(() => {
|
||||||
|
calculateLayout();
|
||||||
|
if (rootRef.current) {
|
||||||
|
return observeResize(rootRef.current, calculateLayout);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [calculateLayout]);
|
||||||
|
|
||||||
|
// handle scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const rootEl = rootRef.current;
|
||||||
|
if (!rootEl) return;
|
||||||
|
|
||||||
|
if (virtualScroll) {
|
||||||
|
const handler = throttle((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 (
|
||||||
|
<Scrollable.Root>
|
||||||
|
<Scrollable.Viewport
|
||||||
|
ref={rootRef}
|
||||||
|
data-masonry-root
|
||||||
|
className={clsx('scrollable', styles.root, className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{items.map(item => {
|
||||||
|
return (
|
||||||
|
<MasonryItem
|
||||||
|
key={item.id}
|
||||||
|
{...item}
|
||||||
|
locateMode={locateMode}
|
||||||
|
xywh={layoutMap.get(item.id)}
|
||||||
|
sleep={sleepMap.get(item.id)}
|
||||||
|
>
|
||||||
|
{item.children}
|
||||||
|
</MasonryItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div data-masonry-placeholder style={{ height }} />
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
<Scrollable.Scrollbar />
|
||||||
|
</Scrollable.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MasonryItemProps
|
||||||
|
extends MasonryItem,
|
||||||
|
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> {
|
||||||
|
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
||||||
|
sleep?: boolean;
|
||||||
|
xywh?: MasonryItemXYWH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MasonryItem = memo(function MasonryItem({
|
||||||
|
id,
|
||||||
|
xywh,
|
||||||
|
locateMode = 'leftTop',
|
||||||
|
sleep = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
style: styleProp,
|
||||||
|
...props
|
||||||
|
}: MasonryItemProps) {
|
||||||
|
const style = useMemo(() => {
|
||||||
|
if (!xywh) return { display: 'none' };
|
||||||
|
|
||||||
|
const { x, y, w, h } = xywh;
|
||||||
|
|
||||||
|
const posStyle =
|
||||||
|
locateMode === 'transform'
|
||||||
|
? { transform: `translate(${x}px, ${y}px)` }
|
||||||
|
: locateMode === 'leftTop'
|
||||||
|
? { left: `${x}px`, top: `${y}px` }
|
||||||
|
: { transform: `translate3d(${x}px, ${y}px, 0)` };
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
...styleProp,
|
||||||
|
...posStyle,
|
||||||
|
width: `${w}px`,
|
||||||
|
height: `${h}px`,
|
||||||
|
};
|
||||||
|
}, [locateMode, styleProp, xywh]);
|
||||||
|
|
||||||
|
if (sleep || !xywh) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-masonry-item
|
||||||
|
data-masonry-item-id={id}
|
||||||
|
className={clsx(styles.item, className)}
|
||||||
|
style={style}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
id: string;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasonryItemXYWH {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { MasonryItem, MasonryItemXYWH } from './type';
|
||||||
|
|
||||||
|
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'], MasonryItemXYWH>();
|
||||||
|
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'], MasonryItemXYWH>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -10,15 +10,23 @@ import { type AppTabLink, tabs } from './data';
|
|||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import { TabItem } from './tab-item';
|
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 virtualKeyboardService = useService(VirtualKeyboardService);
|
||||||
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
|
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
|
||||||
|
|
||||||
return createPortal(
|
const tab = (
|
||||||
<SafeArea
|
<SafeArea
|
||||||
|
id="app-tabs"
|
||||||
bottom
|
bottom
|
||||||
className={styles.appTabs}
|
className={styles.appTabs}
|
||||||
bottomOffset={2}
|
bottomOffset={2}
|
||||||
|
data-fixed={fixed}
|
||||||
style={{
|
style={{
|
||||||
...assignInlineVars({
|
...assignInlineVars({
|
||||||
[styles.appTabsBackground]: background,
|
[styles.appTabsBackground]: background,
|
||||||
@@ -26,7 +34,7 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
|||||||
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
|
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ul className={styles.appTabsInner} id="app-tabs" role="tablist">
|
<ul className={styles.appTabsInner} role="tablist">
|
||||||
{tabs.map(tab => {
|
{tabs.map(tab => {
|
||||||
if ('to' in tab) {
|
if ('to' in tab) {
|
||||||
return <AppTabLink route={tab} key={tab.key} />;
|
return <AppTabLink route={tab} key={tab.key} />;
|
||||||
@@ -39,9 +47,10 @@ export const AppTabs = ({ background }: { background?: string }) => {
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</SafeArea>,
|
</SafeArea>
|
||||||
document.body
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return fixed ? createPortal(tab, document.body) : tab;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppTabLink = ({ route }: { route: AppTabLink }) => {
|
const AppTabLink = ({ route }: { route: AppTabLink }) => {
|
||||||
|
|||||||
@@ -14,9 +14,16 @@ export const appTabs = style({
|
|||||||
|
|
||||||
width: '100dvw',
|
width: '100dvw',
|
||||||
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: -2,
|
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
|
||||||
|
marginBottom: -2,
|
||||||
|
selectors: {
|
||||||
|
'&[data-fixed="true"]': {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: -2,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
export const appTabsInner = style({
|
export const appTabsInner = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -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 { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
|
||||||
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
|
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
|
||||||
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
|
||||||
@@ -11,14 +11,7 @@ import {
|
|||||||
import type { DocMeta } from '@blocksuite/affine/store';
|
import type { DocMeta } from '@blocksuite/affine/store';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import { forwardRef, type ReactNode, useMemo, useRef } from 'react';
|
||||||
forwardRef,
|
|
||||||
type ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import { DocCardTags } from './tag';
|
import { DocCardTags } from './tag';
|
||||||
@@ -66,20 +59,6 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
|||||||
return { height: `${rows * 18}px` };
|
return { height: `${rows * 18}px` };
|
||||||
}, [autoHeightById, meta.id]);
|
}, [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 (
|
return (
|
||||||
<WorkbenchLink
|
<WorkbenchLink
|
||||||
to={`/${meta.id}`}
|
to={`/${meta.id}`}
|
||||||
@@ -94,38 +73,30 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
|
|||||||
className={clsx(styles.card, className)}
|
className={clsx(styles.card, className)}
|
||||||
data-testid="doc-card"
|
data-testid="doc-card"
|
||||||
data-doc-id={meta.id}
|
data-doc-id={meta.id}
|
||||||
data-visible={visible}
|
|
||||||
{...attrs}
|
{...attrs}
|
||||||
>
|
>
|
||||||
{visible && (
|
<header className={styles.head} data-testid="doc-card-header">
|
||||||
<>
|
<h3 className={styles.title}>{title}</h3>
|
||||||
<header className={styles.head} data-testid="doc-card-header">
|
<IconButton
|
||||||
<h3 className={styles.title}>{title}</h3>
|
aria-label="favorite"
|
||||||
<IconButton
|
icon={
|
||||||
aria-label="favorite"
|
<IsFavoriteIcon onClick={toggleFavorite} favorite={favorited} />
|
||||||
icon={
|
}
|
||||||
<IsFavoriteIcon
|
/>
|
||||||
onClick={toggleFavorite}
|
</header>
|
||||||
favorite={favorited}
|
<main className={styles.content} style={contentStyle}>
|
||||||
/>
|
<PagePreview
|
||||||
}
|
fallback={
|
||||||
/>
|
<>
|
||||||
</header>
|
<Skeleton />
|
||||||
<main className={styles.content} style={contentStyle}>
|
<Skeleton width={'60%'} />
|
||||||
<PagePreview
|
</>
|
||||||
fallback={
|
}
|
||||||
<>
|
pageId={meta.id}
|
||||||
<Skeleton />
|
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
|
||||||
<Skeleton width={'60%'} />
|
/>
|
||||||
</>
|
</main>
|
||||||
}
|
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
|
||||||
pageId={meta.id}
|
|
||||||
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</WorkbenchLink>
|
</WorkbenchLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const page = style({
|
||||||
|
width: '100dvw',
|
||||||
|
height: '100dvh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
});
|
||||||
@@ -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';
|
import { AllDocList, AllDocsHeader } from '../../views';
|
||||||
|
|
||||||
export const Component = () => {
|
export const Component = () => {
|
||||||
useThemeColorV2('layer/background/mobile/primary');
|
useThemeColorV2('layer/background/mobile/primary');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Page header={<AllDocsHeader />} tab>
|
||||||
<AllDocsHeader operations={<AllDocsMenu />} />
|
<AllDocList />
|
||||||
<SafeArea bottom>
|
</Page>
|
||||||
<AllDocList />
|
|
||||||
</SafeArea>
|
|
||||||
<AppTabs />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppTabs } from '../../../components';
|
|
||||||
import { CollectionDetail } from '../../../views';
|
import { CollectionDetail } from '../../../views';
|
||||||
|
|
||||||
export const Component = () => {
|
export const Component = () => {
|
||||||
@@ -68,10 +67,5 @@ export const Component = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <CollectionDetail collection={collection} />;
|
||||||
<>
|
|
||||||
<CollectionDetail collection={collection} />
|
|
||||||
<AppTabs />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { createVar, globalStyle } from '@vanilla-extract/css';
|
|||||||
|
|
||||||
export const globalVars = {
|
export const globalVars = {
|
||||||
appTabHeight: createVar('appTabHeight'),
|
appTabHeight: createVar('appTabHeight'),
|
||||||
|
appTabSafeArea: createVar('appTabSafeArea'),
|
||||||
};
|
};
|
||||||
|
|
||||||
globalStyle(':root', {
|
globalStyle(':root', {
|
||||||
vars: {
|
vars: {
|
||||||
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
|
||||||
|
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
||||||
},
|
},
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
@@ -18,17 +20,17 @@ globalStyle('body', {
|
|||||||
minHeight: '100dvh',
|
minHeight: '100dvh',
|
||||||
overflowY: 'unset',
|
overflowY: 'unset',
|
||||||
});
|
});
|
||||||
globalStyle('body:has(#app-tabs)', {
|
globalStyle('body:has(> #app-tabs)', {
|
||||||
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
paddingBottom: globalVars.appTabSafeArea,
|
||||||
});
|
});
|
||||||
globalStyle('body:has(#app-tabs) affine-keyboard-toolbar[data-shrink="true"]', {
|
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', {
|
globalStyle('body:has(#app-tabs) affine-keyboard-tool-panel', {
|
||||||
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`,
|
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`,
|
||||||
});
|
});
|
||||||
globalStyle('body:has(#app-tabs) edgeless-toolbar-widget', {
|
globalStyle('body:has(#app-tabs) edgeless-toolbar-widget', {
|
||||||
bottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
|
bottom: globalVars.appTabSafeArea,
|
||||||
});
|
});
|
||||||
globalStyle('html', {
|
globalStyle('html', {
|
||||||
height: '100dvh',
|
height: '100dvh',
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
import { IconButton, MobileMenu } from '@affine/component';
|
|
||||||
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
|
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
|
||||||
import { isEmptyCollection } from '@affine/core/desktop/pages/workspace/collection';
|
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 type { Collection } from '@affine/env/filter';
|
||||||
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
|
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||||
|
|
||||||
import { AllDocList } from '../doc/list';
|
import { AllDocList } from '../doc/list';
|
||||||
import { AllDocsMenu } from '../doc/menu';
|
|
||||||
import * as styles from './detail.css';
|
import * as styles from './detail.css';
|
||||||
|
|
||||||
export const DetailHeader = ({ collection }: { collection: Collection }) => {
|
export const DetailHeader = ({ collection }: { collection: Collection }) => {
|
||||||
return (
|
return (
|
||||||
<PageHeader
|
<PageHeader className={styles.header} back>
|
||||||
className={styles.header}
|
|
||||||
back
|
|
||||||
suffix={
|
|
||||||
<MobileMenu items={<AllDocsMenu />}>
|
|
||||||
<IconButton
|
|
||||||
size="24"
|
|
||||||
style={{ padding: 10 }}
|
|
||||||
icon={<MoreHorizontalIcon />}
|
|
||||||
/>
|
|
||||||
</MobileMenu>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.headerContent}>
|
<div className={styles.headerContent}>
|
||||||
<ViewLayersIcon className={styles.headerIcon} />
|
<ViewLayersIcon className={styles.headerIcon} />
|
||||||
{collection.name}
|
{collection.name}
|
||||||
@@ -42,14 +29,14 @@ export const CollectionDetail = ({
|
|||||||
<>
|
<>
|
||||||
<DetailHeader collection={collection} />
|
<DetailHeader collection={collection} />
|
||||||
<EmptyCollectionDetail collection={collection} absoluteCenter />
|
<EmptyCollectionDetail collection={collection} absoluteCenter />
|
||||||
|
<AppTabs />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Page header={<DetailHeader collection={collection} />}>
|
||||||
<DetailHeader collection={collection} />
|
|
||||||
<AllDocList collection={collection} />
|
<AllDocList collection={collection} />
|
||||||
</>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { EmptyDocs } from '@affine/core/components/affine/empty';
|
import { EmptyDocs } from '@affine/core/components/affine/empty';
|
||||||
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
||||||
import {
|
import {
|
||||||
type ItemGroupDefinition,
|
|
||||||
type ItemGroupProps,
|
type ItemGroupProps,
|
||||||
useAllDocDisplayProperties,
|
useAllDocDisplayProperties,
|
||||||
useFilteredPageMetas,
|
useFilteredPageMetas,
|
||||||
usePageItemGroupDefinitions,
|
|
||||||
} from '@affine/core/components/page-list';
|
} 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 { Tag } from '@affine/core/modules/tag';
|
||||||
import type { Collection, Filter } from '@affine/env/filter';
|
import type { Collection, Filter } from '@affine/env/filter';
|
||||||
import type { DocMeta } from '@blocksuite/affine/store';
|
import type { DocMeta } from '@blocksuite/affine/store';
|
||||||
@@ -19,7 +16,7 @@ import { useMemo } from 'react';
|
|||||||
import * as styles from './list.css';
|
import * as styles from './list.css';
|
||||||
import { MasonryDocs } from './masonry';
|
import { MasonryDocs } from './masonry';
|
||||||
|
|
||||||
const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
|
export const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
|
||||||
const [properties] = useAllDocDisplayProperties();
|
const [properties] = useAllDocDisplayProperties();
|
||||||
const showTags = properties.displayProperties.tags;
|
const showTags = properties.displayProperties.tags;
|
||||||
|
|
||||||
@@ -53,6 +50,7 @@ export const AllDocList = ({
|
|||||||
tag,
|
tag,
|
||||||
filters = [],
|
filters = [],
|
||||||
}: AllDocListProps) => {
|
}: AllDocListProps) => {
|
||||||
|
const [properties] = useAllDocDisplayProperties();
|
||||||
const workspace = useService(WorkspaceService).workspace;
|
const workspace = useService(WorkspaceService).workspace;
|
||||||
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
|
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
|
||||||
|
|
||||||
@@ -72,22 +70,29 @@ export const AllDocList = ({
|
|||||||
return filteredPageMetas;
|
return filteredPageMetas;
|
||||||
}, [filteredPageMetas, tag, tagPageIds]);
|
}, [filteredPageMetas, tag, tagPageIds]);
|
||||||
|
|
||||||
const groupDefs =
|
// const groupDefs =
|
||||||
usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
|
// usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
// const groups = useMemo(() => {
|
||||||
return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
|
// return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
|
||||||
}, [finalPageMetas, groupDefs]);
|
// }, [finalPageMetas, groupDefs]);
|
||||||
|
|
||||||
if (!groups.length) {
|
if (!finalPageMetas.length) {
|
||||||
return <EmptyDocs absoluteCenter tagId={tag?.id} />;
|
return <EmptyDocs absoluteCenter tagId={tag?.id} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className={styles.groups}>
|
||||||
|
// {groups.map(group => (
|
||||||
|
// <DocGroup key={group.id} group={group} />
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.groups}>
|
<MasonryDocs
|
||||||
{groups.map(group => (
|
items={finalPageMetas}
|
||||||
<DocGroup key={group.id} group={group} />
|
showTags={properties.displayProperties.tags}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +1,12 @@
|
|||||||
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
|
import { Masonry } from '@affine/component';
|
||||||
import type { DocMeta } from '@blocksuite/affine/store';
|
import type { DocMeta } from '@blocksuite/affine/store';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { calcRowsById, DocCard } from '../../../components';
|
import { calcRowsById, DocCard } from '../../../components';
|
||||||
import * as styles from './masonry.css';
|
|
||||||
|
|
||||||
const calcColumnCount = () => {
|
const fullStyle = {
|
||||||
const maxCardWidth = 220;
|
width: '100%',
|
||||||
const windowWidth = window.innerWidth;
|
height: '100%',
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MasonryDocs = ({
|
export const MasonryDocs = ({
|
||||||
@@ -35,32 +16,29 @@ export const MasonryDocs = ({
|
|||||||
items: DocMeta[];
|
items: DocMeta[];
|
||||||
showTags?: boolean;
|
showTags?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [columnCount, setColumnCount] = useState(calcColumnCount);
|
const masonryItems = useMemo(
|
||||||
|
() =>
|
||||||
const updateColumnCount = useCallback(() => {
|
items.map(item => {
|
||||||
setColumnCount(calcColumnCount());
|
return {
|
||||||
}, []);
|
id: item.id,
|
||||||
useGlobalEvent('resize', updateColumnCount);
|
height: calcRowsById(item.id) * 18 + 95,
|
||||||
|
children: (
|
||||||
const columns = useMemo(
|
<DocCard style={fullStyle} meta={item} showTags={showTags} />
|
||||||
() => calcColumns(items, columnCount),
|
),
|
||||||
[items, columnCount]
|
};
|
||||||
|
}),
|
||||||
|
[items, showTags]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.columns}>
|
<Masonry
|
||||||
{columns.map((col, index) => (
|
style={fullStyle}
|
||||||
<div key={`${columnCount}-${index}`} className={styles.column}>
|
itemWidthMin={160}
|
||||||
{col.map(item => (
|
gapX={17}
|
||||||
<DocCard
|
gapY={10}
|
||||||
key={item.id}
|
paddingX={16}
|
||||||
showTags={showTags}
|
paddingY={16}
|
||||||
meta={item}
|
virtualScroll
|
||||||
autoHeightById
|
items={masonryItems}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +1,14 @@
|
|||||||
import { IconButton, MobileMenu } from '@affine/component';
|
|
||||||
import { PageHeader } from '@affine/core/mobile/components';
|
import { PageHeader } from '@affine/core/mobile/components';
|
||||||
import type { Tag } from '@affine/core/modules/tag';
|
import type { Tag } from '@affine/core/modules/tag';
|
||||||
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useLiveData } from '@toeverything/infra';
|
import { useLiveData } from '@toeverything/infra';
|
||||||
|
|
||||||
import { AllDocsMenu } from '../doc';
|
|
||||||
import * as styles from './detail.css';
|
import * as styles from './detail.css';
|
||||||
|
|
||||||
export const TagDetailHeader = ({ tag }: { tag: Tag }) => {
|
export const TagDetailHeader = ({ tag }: { tag: Tag }) => {
|
||||||
const name = useLiveData(tag.value$);
|
const name = useLiveData(tag.value$);
|
||||||
const color = useLiveData(tag.color$);
|
const color = useLiveData(tag.color$);
|
||||||
return (
|
return (
|
||||||
<PageHeader
|
<PageHeader className={styles.header} back>
|
||||||
className={styles.header}
|
|
||||||
back
|
|
||||||
suffix={
|
|
||||||
<MobileMenu items={<AllDocsMenu />}>
|
|
||||||
<IconButton
|
|
||||||
size="24"
|
|
||||||
style={{ padding: 10 }}
|
|
||||||
icon={<MoreHorizontalIcon />}
|
|
||||||
/>
|
|
||||||
</MobileMenu>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.headerContent}>
|
<div className={styles.headerContent}>
|
||||||
<div className={styles.headerIcon} style={{ color }} />
|
<div className={styles.headerIcon} style={{ color }} />
|
||||||
{name}
|
{name}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
|
import { Page } from '@affine/core/mobile/components/page';
|
||||||
import type { Tag } from '@affine/core/modules/tag';
|
import type { Tag } from '@affine/core/modules/tag';
|
||||||
|
|
||||||
import { AppTabs } from '../../../components';
|
|
||||||
import { AllDocList } from '../doc';
|
import { AllDocList } from '../doc';
|
||||||
import { TagDetailHeader } from './detail-header';
|
import { TagDetailHeader } from './detail-header';
|
||||||
|
|
||||||
export const TagDetail = ({ tag }: { tag: Tag }) => {
|
export const TagDetail = ({ tag }: { tag: Tag }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Page header={<TagDetailHeader tag={tag} />} tab>
|
||||||
<TagDetailHeader tag={tag} />
|
|
||||||
<AllDocList tag={tag} />
|
<AllDocList tag={tag} />
|
||||||
<AppTabs />
|
</Page>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user