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
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
45 changed files with 1064 additions and 42 deletions

View File

@ -18,6 +18,31 @@ type GroupOption = {
label: string;
};
export function getGroupOptions(t: ReturnType<typeof useI18n>) {
return [
{
value: 'createDate',
label: t['Created'](),
},
{
value: 'updatedDate',
label: t['Updated'](),
},
{
value: 'tag',
label: t['com.affine.page.display.grouping.group-by-tag'](),
},
{
value: 'favourites',
label: t['com.affine.page.display.grouping.group-by-favourites'](),
},
{
value: 'none',
label: t['com.affine.page.display.grouping.no-grouping'](),
},
] satisfies GroupOption[];
}
export const PageDisplayMenu = () => {
const t = useI18n();
const [workspaceProperties, setProperties] = useAllDocDisplayProperties();
@ -66,28 +91,7 @@ export const PageDisplayMenu = () => {
}, [handleSetDocDisplayProperties, t]);
const items = useMemo(() => {
const groupOptions: GroupOption[] = [
{
value: 'createDate',
label: t['Created'](),
},
{
value: 'updatedDate',
label: t['Updated'](),
},
{
value: 'tag',
label: t['com.affine.page.display.grouping.group-by-tag'](),
},
{
value: 'favourites',
label: t['com.affine.page.display.grouping.group-by-favourites'](),
},
{
value: 'none',
label: t['com.affine.page.display.grouping.no-grouping'](),
},
];
const groupOptions: GroupOption[] = getGroupOptions(t);
const subItems = groupOptions.map(option => (
<MenuItem

View File

@ -19,6 +19,10 @@ export const getPagePreviewText = (page: Doc) => {
let count = MAX_SEARCH_BLOCK_COUNT;
while (queue.length && previewLenNeeded > 0 && count-- > 0) {
const block = queue.shift();
// if preview length is enough, skip the rest of the blocks
if (preview.join(' ').trim().length >= MAX_PREVIEW_LENGTH) {
break;
}
if (!block) {
console.error('Unexpected empty block');
break;
@ -51,7 +55,7 @@ export const getPagePreviewText = (page: Doc) => {
}
}
}
return preview.join(' ').slice(0, MAX_PREVIEW_LENGTH);
return preview.join(' ').trim().slice(0, MAX_PREVIEW_LENGTH);
};
const emptyAtom = atom<string>('');

View File

@ -14,11 +14,12 @@ export type WorkbenchLinkProps = React.PropsWithChildren<
{
to: To;
onClick?: (e: MouseEvent) => void;
replaceHistory?: boolean;
} & React.HTMLProps<HTMLAnchorElement>
>;
export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
function WorkbenchLink({ to, onClick, ...other }, ref) {
function WorkbenchLink({ to, onClick, replaceHistory, ...other }, ref) {
const { featureFlagService, workbenchService } = useServices({
FeatureFlagService,
WorkbenchService,
@ -45,10 +46,10 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
}
return 'active';
})();
workbench.open(to, { at });
workbench.open(to, { at, replaceHistory });
event.preventDefault();
},
[enableMultiView, onClick, to, workbench]
[enableMultiView, onClick, replaceHistory, to, workbench]
);
// eslint suspicious runtime error

View File

@ -121,7 +121,7 @@ export const Component = function CollectionPage() {
if (!collection) {
return null;
}
const inner = isEmpty(collection) ? (
const inner = isEmptyCollection(collection) ? (
<Placeholder collection={collection} />
) : (
<CollectionDetail collection={collection} />
@ -346,7 +346,7 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
);
};
const isEmpty = (collection: Collection) => {
export const isEmptyCollection = (collection: Collection) => {
return (
collection.allowList.length === 0 && collection.filterList.length === 0
);

View File

@ -4,6 +4,7 @@ import {
} from '@affine/core/modules/workbench';
import { AllDocsIcon, SearchIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import type { Location } from 'react-router-dom';
import { HomeIcon } from './home-icon';
import * as styles from './styles.css';
@ -12,6 +13,7 @@ interface Route {
to: string;
Icon: React.FC;
LinkComponent?: React.FC;
isActive?: (location: Location) => boolean;
}
const routes: Route[] = [
@ -22,6 +24,10 @@ const routes: Route[] = [
{
to: '/all',
Icon: AllDocsIcon,
isActive: location =>
location.pathname === '/all' ||
location.pathname.startsWith('/collection') ||
location.pathname.startsWith('/tag'),
},
{
to: '/search',
@ -37,9 +43,13 @@ export const AppTabs = () => {
<ul className={styles.appTabs} id="app-tabs">
{routes.map(route => {
const Link = route.LinkComponent || WorkbenchLink;
const isActive = route.isActive
? route.isActive(location)
: location.pathname === route.to;
return (
<Link
data-active={location.pathname === route.to}
data-active={isActive}
to={route.to}
key={route.to}
className={styles.tabItem}

View File

@ -18,6 +18,7 @@ export const appTabs = style({
position: 'fixed',
bottom: 0,
zIndex: 1,
});
export const tabItem = style({
display: 'flex',

View File

@ -16,10 +16,11 @@ import { DocCardTags } from './tag';
export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
meta: DocMeta;
showTags?: boolean;
}
export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
function DocCard({ meta, className, ...attrs }, ref) {
function DocCard({ showTags = true, meta, className, ...attrs }, ref) {
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const workspace = useService(WorkspaceService).workspace;
@ -54,7 +55,7 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
/>
</main>
<DocCardTags docId={meta.id} rows={2} />
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
</WorkbenchLink>
);
}

View File

@ -122,7 +122,7 @@ const DocCardTagsRenderer = ({ tags, rows }: { tags: Tag[]; rows: number }) => {
{tags.map(tag => (
<DocCardTag key={tag.id} tag={tag} />
))}
{/* TODO: more icon */}
{/* TODO(@CatsJuice): more icon */}
{/* <MoreHorizontalIcon /> */}
</ul>
);

View File

@ -1,4 +1,5 @@
export * from './app-tabs';
export * from './doc-card';
export * from './page-header';
export * from './search-button';
export * from './workspace-selector';

View File

@ -1,13 +1,19 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
minHeight: 44,
padding: '0 6px',
paddingTop: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'relative',
position: 'sticky',
top: 0,
zIndex: 1,
backgroundColor: cssVarV2('layer/background/secondary'),
});
export const content = style({
selectors: {
@ -15,6 +21,11 @@ export const content = style({
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
width: 'fit-content',
maxWidth: 'calc(100% - 12px - 88px - 16px)',
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
},
'&:not(.center)': {
width: 0,

View File

@ -1,9 +1,11 @@
import { AppTabs } from '../../components';
import { AllDocList, AllDocsHeader, AllDocsMenu } from '../../views';
export const Component = () => {
return (
<>
`workspace/all.tsx`;
<AllDocsHeader operations={<AllDocsMenu />} />
<AllDocList />
<AppTabs />
</>
);

View File

@ -1,3 +1,82 @@
import { notify } from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { isEmptyCollection } from '@affine/core/pages/workspace/collection';
import { WorkspaceSubPath } from '@affine/core/shared';
import {
GlobalContextService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { CollectionDetail, EmptyCollection } from '../../../views';
export const Component = () => {
return <div>/workspace/:workspaceId/collection/:collectionId</div>;
const { collectionService, globalContextService, workspaceService } =
useServices({
WorkspaceService,
CollectionService,
GlobalContextService,
});
const globalContext = globalContextService.globalContext;
const collections = useLiveData(collectionService.collections$);
const params = useParams();
const navigate = useNavigateHelper();
const workspace = workspaceService.workspace;
const collection = collections.find(v => v.id === params.collectionId);
useEffect(() => {
if (collection) {
globalContext.collectionId.set(collection.id);
globalContext.isCollection.set(true);
return () => {
globalContext.collectionId.set(null);
globalContext.isCollection.set(false);
};
}
return;
}, [collection, globalContext]);
const notifyCollectionDeleted = useCallback(() => {
navigate.jumpToSubPath(workspace.id, WorkspaceSubPath.HOME);
const collection = collectionService.collectionsTrash$.value.find(
v => v.collection.id === params.collectionId
);
let text = 'Collection does not exist';
if (collection) {
if (collection.userId) {
text = `${collection.collection.name} has been deleted by ${collection.userName}`;
} else {
text = `${collection.collection.name} has been deleted`;
}
}
return notify.error({ title: text });
}, [collectionService, navigate, params.collectionId, workspace.id]);
useEffect(() => {
if (!collection) {
notifyCollectionDeleted();
}
}, [collection, notifyCollectionDeleted]);
if (!collection) {
return null;
}
if (isEmptyCollection(collection)) {
return <EmptyCollection collection={collection} />;
}
return (
<>
<CollectionDetail collection={collection} />
<AppTabs />
</>
);
};

View File

@ -1,3 +1,12 @@
import { AppTabs } from '../../../components';
import { AllDocsHeader, CollectionList } from '../../../views';
export const Component = () => {
return <div>/workspace/:workspaceId/collection</div>;
return (
<>
<AllDocsHeader />
<AppTabs />
<CollectionList />
</>
);
};

View File

@ -1,3 +1,40 @@
import { TagService } from '@affine/core/modules/tag';
import { PageNotFound } from '@affine/core/pages/404';
import {
GlobalContextService,
useLiveData,
useService,
} from '@toeverything/infra';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { TagDetail } from '../../../views';
export const Component = () => {
return <div>/workspace/:workspaceId/tag/:tagId</div>;
const params = useParams();
const tagId = params.tagId;
const globalContext = useService(GlobalContextService).globalContext;
const tagList = useService(TagService).tagList;
const currentTag = useLiveData(tagList.tagByTagId$(tagId));
useEffect(() => {
if (currentTag) {
globalContext.tagId.set(currentTag.id);
globalContext.isTag.set(true);
return () => {
globalContext.tagId.set(null);
globalContext.isTag.set(false);
};
}
return;
}, [currentTag, globalContext]);
if (!currentTag) {
return <PageNotFound />;
}
return <TagDetail tag={currentTag} />;
};

View File

@ -1,3 +1,12 @@
import { AppTabs } from '../../../components';
import { AllDocsHeader, TagList } from '../../../views';
export const Component = () => {
return <div>/workspace/:workspaceId/tag</div>;
return (
<>
<AllDocsHeader />
<AppTabs />
<TagList />
</>
);
};

View File

@ -7,8 +7,12 @@ import {
} from 'react-router-dom';
import { Component as All } from './pages/workspace/all';
import { Component as Collection } from './pages/workspace/collection';
import { Component as CollectionDetail } from './pages/workspace/collection/detail';
import { Component as Home } from './pages/workspace/home';
import { Component as Search } from './pages/workspace/search';
import { Component as Tag } from './pages/workspace/tag';
import { Component as TagDetail } from './pages/workspace/tag/detail';
export const topLevelRoutes = [
{
@ -67,19 +71,23 @@ export const viewRoutes = [
},
{
path: '/collection',
lazy: () => import('./pages/workspace/collection/index'),
// lazy: () => import('./pages/workspace/collection/index'),
Component: Collection,
},
{
path: '/collection/:collectionId',
lazy: () => import('./pages/workspace/collection/detail'),
// lazy: () => import('./pages/workspace/collection/detail'),
Component: CollectionDetail,
},
{
path: '/tag',
lazy: () => import('./pages/workspace/tag/index'),
// lazy: () => import('./pages/workspace/tag/index'),
Component: Tag,
},
{
path: '/tag/:tagId',
lazy: () => import('./pages/workspace/tag/detail'),
// lazy: () => import('./pages/workspace/tag/detail'),
Component: TagDetail,
},
{
path: '/trash',

View File

@ -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'),
});

View File

@ -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} />
</>
);
};

View File

@ -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
</>
);
};

View File

@ -0,0 +1,3 @@
export { CollectionDetail } from './detail';
export * from './empty';
export * from './list';

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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]);

View File

@ -0,0 +1,3 @@
export * from './list';
export * from './masonry';
export * from './menu';

View File

@ -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,
});

View File

@ -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>
);
};

View File

@ -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,
});

View File

@ -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>
);
};

View File

@ -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,
},
},
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,4 @@
export * from './collection';
export * from './doc/';
export * from './header';
export * from './tag';

View File

@ -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'),
},
},
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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',
},
});

View File

@ -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 />
</>
);
};

View File

@ -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
</>
);
};

View File

@ -0,0 +1,3 @@
export { TagDetail } from './detail';
export * from './item';
export * from './list';

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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,
});

View File

@ -1,2 +1,3 @@
export * from './all-docs';
export * from './home-header';
export * from './recent-docs';

View File

@ -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}>

View File

@ -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,