feat(mobile): all docs page ui impl (#7976)

This commit is contained in:
CatsJuice
2024-08-29 06:09:48 +00:00
parent db76780bc9
commit 3ce92f2abc
45 changed files with 1064 additions and 42 deletions
@@ -0,0 +1,19 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const headerContent = style({
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});
export const headerIcon = style({
fontSize: 24,
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,43 @@
import { IconButton, MobileMenu } from '@affine/component';
import type { Collection } from '@affine/env/filter';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import { PageHeader } from '../../../components';
import { AllDocList } from '../doc/list';
import { AllDocsMenu } from '../doc/menu';
import * as styles from './detail.css';
export const DetailHeader = ({ collection }: { collection: Collection }) => {
return (
<PageHeader
back
suffix={
<MobileMenu items={<AllDocsMenu />}>
<IconButton
size="24"
style={{ padding: 10 }}
icon={<MoreHorizontalIcon />}
/>
</MobileMenu>
}
>
<div className={styles.headerContent}>
<ViewLayersIcon className={styles.headerIcon} />
{collection.name}
</div>
</PageHeader>
);
};
export const CollectionDetail = ({
collection,
}: {
collection: Collection;
}) => {
return (
<>
<DetailHeader collection={collection} />
<AllDocList collection={collection} />
</>
);
};
@@ -0,0 +1,12 @@
import type { Collection } from '@affine/env/filter';
import { DetailHeader } from './detail';
export const EmptyCollection = ({ collection }: { collection: Collection }) => {
return (
<>
<DetailHeader collection={collection} />
Empty
</>
);
};
@@ -0,0 +1,3 @@
export { CollectionDetail } from './detail';
export * from './empty';
export * from './list';
@@ -0,0 +1,38 @@
import { IconButton } from '@affine/component';
import type { CollectionMeta } from '@affine/core/components/page-list';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { item, name, prefixIcon, suffixIcon } from './styles.css';
export const CollectionListItem = ({ meta }: { meta: CollectionMeta }) => {
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const isFavorite = useLiveData(favAdapter.isFavorite$(meta.id, 'collection'));
const toggle = useCallback(
(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
favAdapter.toggle(meta.id, 'collection');
},
[favAdapter, meta.id]
);
return (
<WorkbenchLink to={`/collection/${meta.id}`} className={item}>
<ViewLayersIcon className={prefixIcon} />
<span className={name}>{meta.title}</span>
<IconButton
className={suffixIcon}
onClick={toggle}
icon={<IsFavoriteIcon favorite={isFavorite} />}
size="24"
/>
</WorkbenchLink>
);
};
@@ -0,0 +1,29 @@
import type { CollectionMeta } from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { CollectionListItem } from './item';
import { list } from './styles.css';
export const CollectionList = () => {
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const collectionMetas = useMemo(
() =>
collections.map(
collection =>
({ ...collection, title: collection.name }) satisfies CollectionMeta
),
[collections]
);
return (
<ul className={list}>
{collectionMetas.map(meta => (
<CollectionListItem key={meta.id} meta={meta} />
))}
</ul>
);
};
@@ -0,0 +1,45 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const list = style({});
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 16,
color: 'unset',
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
':focus': { color: 'unset' },
selectors: {
'&:not(:last-child)': {
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
},
});
export const icon = style({
width: 24,
height: 24,
color: cssVarV2('icon/primary'),
flexShrink: 0,
});
export const prefixIcon = style([icon]);
export const name = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/primary'),
fontSize: 17,
fontWeight: 600,
lineHeight: '24px',
letterSpacing: -0.43,
});
export const suffixIcon = style([icon]);
@@ -0,0 +1,3 @@
export * from './list';
export * from './masonry';
export * from './menu';
@@ -0,0 +1,32 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const groupTitle = style({
display: 'flex',
alignItems: 'center',
padding: '0px 16px',
width: '100%',
});
// to override style defined in `core`
globalStyle(`${groupTitle} > div`, {
marginRight: -4,
});
globalStyle(`${groupTitle} div[data-testid^='group-label']`, {
fontSize: `20px !important`,
color: `${cssVarV2('text/primary')} !important`,
lineHeight: '25px !important',
});
export const groupTitleIcon = style({
color: cssVarV2('icon/tertiary'),
transition: 'transform 0.2s',
selectors: {
'[data-state="closed"] &': {
transform: `rotate(-90deg)`,
},
},
});
export const groups = style({
display: 'flex',
flexDirection: 'column',
gap: 32,
});
@@ -0,0 +1,88 @@
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 { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import type { Tag } from '@affine/core/modules/tag';
import type { Collection, Filter } from '@affine/env/filter';
import { ToggleExpandIcon } from '@blocksuite/icons/rc';
import type { DocMeta } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useMemo } from 'react';
import * as styles from './list.css';
import { MasonryDocs } from './masonry';
const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
const [properties] = useAllDocDisplayProperties();
const showTags = properties.displayProperties.tags;
if (group.id === 'all') {
return <MasonryDocs items={group.items} showTags={showTags} />;
}
return (
<Collapsible.Root defaultOpen>
<Collapsible.Trigger className={styles.groupTitle}>
{group.label}
<ToggleExpandIcon className={styles.groupTitleIcon} />
</Collapsible.Trigger>
<Collapsible.Content>
<MasonryDocs items={group.items} showTags={showTags} />
</Collapsible.Content>
</Collapsible.Root>
);
};
export interface AllDocListProps {
collection?: Collection;
tag?: Tag;
filters?: Filter[];
trash?: boolean;
}
export const AllDocList = ({
trash,
collection,
tag,
filters = [],
}: AllDocListProps) => {
const workspace = useService(WorkspaceService).workspace;
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
const tagPageIds = useLiveData(tag?.pageIds$);
const filteredPageMetas = useFilteredPageMetas(allPageMetas, {
trash,
filters,
collection,
});
const finalPageMetas = useMemo(() => {
if (tag) {
const pageIdsSet = new Set(tagPageIds);
return filteredPageMetas.filter(page => pageIdsSet.has(page.id));
}
return filteredPageMetas;
}, [filteredPageMetas, tag, tagPageIds]);
const groupDefs =
usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
const groups = useMemo(() => {
return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
}, [finalPageMetas, groupDefs]);
return (
<div className={styles.groups}>
{groups.map(group => (
<DocGroup key={group.id} group={group} />
))}
</div>
);
};
@@ -0,0 +1,28 @@
import { style } from '@vanilla-extract/css';
export const invisibleWrapper = style({
position: 'absolute',
padding: 'inherit',
width: '100%',
height: 0,
overflow: 'hidden',
visibility: 'hidden',
pointerEvents: 'none',
});
export const invisibleList = style({
width: `calc(50% - 17px / 2)`,
});
export const stacks = style({
position: 'relative',
width: '100%',
display: 'flex',
gap: 17,
padding: 16,
});
export const stack = style({
width: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 10,
});
@@ -0,0 +1,37 @@
import type { DocMeta } from '@blocksuite/store';
import { useMemo } from 'react';
import { DocCard } from '../../../components';
import * as styles from './masonry.css';
// TODO(@CatsJuice): Large amount docs performance
export const MasonryDocs = ({
items,
showTags,
}: {
items: DocMeta[];
showTags?: boolean;
}) => {
// card preview is loaded lazily, it's meaningless to calculate height
const stacks = useMemo(() => {
return items.reduce(
(acc, item, i) => {
acc[i % 2].push(item);
return acc;
},
[[], []] as [DocMeta[], DocMeta[]]
);
}, [items]);
return (
<div className={styles.stacks}>
{stacks.map((stack, i) => (
<ul key={i} className={styles.stack}>
{stack.map(item => (
<DocCard showTags={showTags} key={item.id} meta={item} />
))}
</ul>
))}
</div>
);
};
@@ -0,0 +1,67 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
gap: 10,
});
export const head = style({
padding: '10px 20px',
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});
export const item = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 4,
height: 34,
padding: '0 20px',
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
});
export const itemSuffix = style({
display: 'flex',
gap: 8,
alignItems: 'center',
});
export const itemSuffixText = style({
color: cssVarV2('text/secondary'),
});
export const itemSuffixIcon = style({
color: cssVarV2('icon/primary'),
fontSize: 20,
});
export const divider = style({
width: '100%',
height: 16,
display: 'flex',
alignItems: 'center',
':before': {
content: "''",
height: 0.5,
width: '100%',
backgroundColor: cssVarV2('layer/insideBorder/border'),
},
});
export const propertiesList = style({
padding: '4px 20px',
display: 'flex',
gap: 8,
});
export const propertyButton = style({
opacity: 0.4,
selectors: {
'&[data-selected="true"]': {
opacity: 1,
},
},
});
@@ -0,0 +1,73 @@
import { Button, MobileMenu, MobileMenuItem } from '@affine/component';
import {
getGroupOptions,
useAllDocDisplayProperties,
} from '@affine/core/components/page-list';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useMemo } from 'react';
import * as styles from './menu.css';
export const AllDocsMenu = () => {
const t = useI18n();
const [properties, setProperties] = useAllDocDisplayProperties();
const groupOptions = useMemo(() => getGroupOptions(t), [t]);
const activeGroup = useMemo(
() => groupOptions.find(g => g.value === properties.groupBy),
[groupOptions, properties.groupBy]
);
return (
<div className={styles.root}>
<header className={styles.head}>Display Settings</header>
<MobileMenu
items={
<>
<div className={styles.divider} />
{groupOptions.map(group => (
<MobileMenuItem
onSelect={() => setProperties('groupBy', group.value)}
selected={properties.groupBy === group.value}
key={group.value}
>
{group.label}
</MobileMenuItem>
))}
</>
}
>
<div className={styles.item}>
<span>{t['com.affine.page.display.grouping']()}</span>
<div className={styles.itemSuffix}>
<span className={styles.itemSuffixText}>{activeGroup?.label}</span>
<ArrowRightSmallIcon className={styles.itemSuffixIcon} />
</div>
</div>
</MobileMenu>
<div className={styles.divider} />
<div className={styles.item}>
{t['com.affine.page.display.display-properties']()}
</div>
<div className={styles.propertiesList}>
<Button
size="large"
className={styles.propertyButton}
data-selected={properties.displayProperties.tags}
onClick={() =>
setProperties('displayProperties', {
...properties.displayProperties,
tags: !properties.displayProperties.tags,
})
}
>
Tag
</Button>
</div>
</div>
);
};
@@ -0,0 +1,24 @@
import { IconButton, MobileMenu } from '@affine/component';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { header } from './style.css';
import { AllDocsTabs } from './tabs';
export interface AllDocsHeaderProps {
operations?: React.ReactNode;
}
export const AllDocsHeader = ({ operations }: AllDocsHeaderProps) => {
return (
<header className={header}>
<AllDocsTabs />
<div>
{operations ? (
<MobileMenu items={operations}>
<IconButton icon={<MoreHorizontalIcon />} />
</MobileMenu>
) : null}
</div>
</header>
);
};
@@ -0,0 +1,4 @@
export * from './collection';
export * from './doc/';
export * from './header';
export * from './tag';
@@ -0,0 +1,32 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '16px 16px 0px 16px',
position: 'sticky',
top: 0,
backgroundColor: cssVarV2('layer/background/secondary'),
zIndex: 1,
});
export const tabs = style({
height: 56,
gap: 16,
display: 'flex',
alignItems: 'center',
});
export const tab = style({
fontSize: 20,
fontWeight: 600,
lineHeight: '28px',
color: cssVarV2('text/tertiary'),
selectors: {
'&[data-active="true"]': {
color: cssVarV2('text/primary'),
},
},
});
@@ -0,0 +1,50 @@
import {
WorkbenchLink,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './style.css';
interface Tab {
to: string;
label: string;
}
const tabs: Tab[] = [
{
to: '/all',
label: 'Docs',
},
{
to: '/collection',
label: 'Collections',
},
{
to: '/tag',
label: 'Tags',
},
];
export const AllDocsTabs = () => {
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
return (
<ul className={styles.tabs}>
{tabs.map(tab => {
return (
<WorkbenchLink
data-active={location.pathname === tab.to}
replaceHistory
className={styles.tab}
key={tab.to}
to={tab.to}
>
{tab.label}
</WorkbenchLink>
);
})}
</ul>
);
};
@@ -0,0 +1,32 @@
import { IconButton, MobileMenu } from '@affine/component';
import type { Tag } from '@affine/core/modules/tag';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { PageHeader } from '../../../components';
import { AllDocsMenu } from '../doc';
import * as styles from './detail.css';
export const TagDetailHeader = ({ tag }: { tag: Tag }) => {
const name = useLiveData(tag.value$);
const color = useLiveData(tag.color$);
return (
<PageHeader
back
suffix={
<MobileMenu items={<AllDocsMenu />}>
<IconButton
size="24"
style={{ padding: 10 }}
icon={<MoreHorizontalIcon />}
/>
</MobileMenu>
}
>
<div className={styles.headerContent}>
<div className={styles.headerIcon} style={{ color }} />
{name}
</div>
</PageHeader>
);
};
@@ -0,0 +1,29 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const headerContent = style({
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
});
export const headerIcon = style({
width: 24,
height: 24,
marginRight: 8,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
':before': {
content: '""',
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: 'currentColor',
},
});
@@ -0,0 +1,22 @@
import type { Tag } from '@affine/core/modules/tag';
import { useLiveData } from '@toeverything/infra';
import { AppTabs } from '../../../components';
import { AllDocList } from '../doc';
import { TagDetailHeader } from './detail-header';
import { TagEmpty } from './empty';
export const TagDetail = ({ tag }: { tag: Tag }) => {
const pageIds = useLiveData(tag?.pageIds$);
if (!pageIds.length) {
return <TagEmpty tag={tag} />;
}
return (
<>
<TagDetailHeader tag={tag} />
<AllDocList tag={tag} />
<AppTabs />
</>
);
};
@@ -0,0 +1,12 @@
import type { Tag } from '@affine/core/modules/tag';
import { TagDetailHeader } from './detail-header';
export const TagEmpty = ({ tag }: { tag: Tag }) => {
return (
<>
<TagDetailHeader tag={tag} />
Empty
</>
);
};
@@ -0,0 +1,3 @@
export { TagDetail } from './detail';
export * from './item';
export * from './list';
@@ -0,0 +1,38 @@
import { IconButton } from '@affine/component';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Tag } from '@affine/core/modules/tag';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { content, item, prefixIcon, suffixIcon } from './styles.css';
export const TagItem = ({ tag }: { tag: Tag }) => {
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const isFavorite = useLiveData(favAdapter.isFavorite$(tag.id, 'tag'));
const color = useLiveData(tag.color$);
const name = useLiveData(tag.value$);
const toggle = useCallback(
(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
favAdapter.toggle(tag.id, 'tag');
},
[favAdapter, tag.id]
);
return (
<WorkbenchLink to={`/tag/${tag.id}`} className={item}>
<div className={prefixIcon} style={{ color }} />
<span className={content}>{name}</span>
<IconButton
className={suffixIcon}
onClick={toggle}
icon={<IsFavoriteIcon favorite={isFavorite} />}
size="24"
/>
</WorkbenchLink>
);
};
@@ -0,0 +1,18 @@
import { TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { TagItem } from './item';
import { list } from './styles.css';
export const TagList = () => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
return (
<ul className={list}>
{tags.map(tag => (
<TagItem key={tag.id} tag={tag} />
))}
</ul>
);
};
@@ -0,0 +1,54 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const list = style({});
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 16,
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
':focus': { color: 'unset' },
selectors: {
'&:not(:last-child)': {
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
},
});
export const content = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/primary'),
fontSize: 17,
fontWeight: 600,
lineHeight: '24px',
letterSpacing: -0.43,
});
export const prefixIcon = style({
width: 24,
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
':before': {
content: '""',
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: 'currentColor',
},
});
export const suffixIcon = style({
padding: 0,
fontSize: 24,
});
@@ -1,2 +1,3 @@
export * from './all-docs';
export * from './home-header';
export * from './recent-docs';
@@ -21,6 +21,7 @@ export const RecentDocs = ({ max = 5 }: { max?: number }) => {
name="recent"
title="Recent"
headerClassName={styles.header}
className={styles.recentSection}
>
<div className={styles.scroll}>
<ul className={styles.list}>
@@ -1,5 +1,13 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const recentSection = style({
paddingBottom: 32,
selectors: {
'&[data-state="open"]': {
paddingBottom: 0,
},
},
});
export const scroll = style({
width: '100%',
paddingTop: 8,