feat(mobile): mobile index page UI (#7959)

This commit is contained in:
CatsJuice
2024-08-29 06:09:46 +00:00
parent f37051dc87
commit db76780bc9
47 changed files with 1404 additions and 84 deletions
@@ -0,0 +1,3 @@
# mobile components
Maintain the smallest possible business components here.
@@ -0,0 +1,21 @@
/**
* TODO(@CatsJuice): replace with `@blocksuite/icons/rc` when ready
*/
export const HomeIcon = () => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 31 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24.5957 24.375V13.0977C24.5957 12.9048 24.5067 12.7228 24.3544 12.6044L15.6044 5.7988C15.3787 5.62326 15.0627 5.62326 14.837 5.7988L6.08699 12.6044C5.93475 12.7228 5.8457 12.9048 5.8457 13.0977V24.375C5.8457 24.7202 6.12553 25 6.4707 25H23.9707C24.3159 25 24.5957 24.7202 24.5957 24.375ZM4.93585 11.1243C4.32689 11.598 3.9707 12.3262 3.9707 13.0977V24.375C3.9707 25.7557 5.08999 26.875 6.4707 26.875H23.9707C25.3514 26.875 26.4707 25.7557 26.4707 24.375V13.0977C26.4707 12.3262 26.1145 11.598 25.5056 11.1243L16.7556 4.31876C15.8528 3.61661 14.5886 3.6166 13.6859 4.31876L4.93585 11.1243Z"
fill="currentColor"
/>
</svg>
);
};
@@ -0,0 +1,55 @@
import {
WorkbenchLink,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { AllDocsIcon, SearchIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { HomeIcon } from './home-icon';
import * as styles from './styles.css';
interface Route {
to: string;
Icon: React.FC;
LinkComponent?: React.FC;
}
const routes: Route[] = [
{
to: '/home',
Icon: HomeIcon,
},
{
to: '/all',
Icon: AllDocsIcon,
},
{
to: '/search',
Icon: SearchIcon,
},
];
export const AppTabs = () => {
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
return (
<ul className={styles.appTabs} id="app-tabs">
{routes.map(route => {
const Link = route.LinkComponent || WorkbenchLink;
return (
<Link
data-active={location.pathname === route.to}
to={route.to}
key={route.to}
className={styles.tabItem}
>
<li>
<route.Icon />
</li>
</Link>
);
})}
</ul>
);
};
@@ -0,0 +1,38 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
import { globalVars } from '../../styles/mobile.css';
export const appTabs = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: cssVarV2('layer/background/secondary'),
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
width: '100vw',
height: globalVars.appTabHeight,
padding: 16,
gap: 15.5,
position: 'fixed',
bottom: 0,
});
export const tabItem = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 0,
flex: 1,
height: 36,
padding: 3,
fontSize: 30,
color: cssVarV2('icon/primary'),
selectors: {
'&[data-active="true"]': {
color: cssVarV2('button/primary'),
},
},
});
@@ -0,0 +1,61 @@
import { IconButton } from '@affine/component';
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import {
WorkbenchLink,
type WorkbenchLinkProps,
} from '@affine/core/modules/workbench';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, useCallback } from 'react';
import * as styles from './styles.css';
import { DocCardTags } from './tag';
export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
meta: DocMeta;
}
export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
function DocCard({ meta, className, ...attrs }, ref) {
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const workspace = useService(WorkspaceService).workspace;
const favorited = useLiveData(favAdapter.isFavorite$(meta.id, 'doc'));
const toggleFavorite = useCallback(
() => favAdapter.toggle(meta.id, 'doc'),
[favAdapter, meta.id]
);
return (
<WorkbenchLink
to={`/${meta.id}`}
ref={ref}
className={clsx(styles.card, className)}
{...attrs}
>
<header className={styles.head}>
<h3 className={styles.title}>
{meta.title || <span className={styles.untitled}>Untitled</span>}
</h3>
<IconButton
icon={
<IsFavoriteIcon onClick={toggleFavorite} favorite={favorited} />
}
/>
</header>
<main className={styles.content}>
<PagePreview
docCollection={workspace.docCollection}
pageId={meta.id}
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
/>
</main>
<DocCardTags docId={meta.id} rows={2} />
</WorkbenchLink>
);
}
);
@@ -0,0 +1,53 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const card = style({
padding: 16,
borderRadius: 12,
border: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
boxShadow: '0px 2px 3px rgba(0,0,0,0.05)',
background: cssVarV2('layer/background/primary'),
display: 'flex',
flexDirection: 'column',
gap: 8,
color: 'unset',
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
});
export const head = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
});
export const title = style({
width: 0,
flex: 1,
fontSize: 17,
lineHeight: '22px',
fontWeight: 600,
letterSpacing: -0.43,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
});
export const untitled = style({
opacity: 0.4,
});
export const content = style({
fontSize: 13,
lineHeight: '18px',
fontWeight: 400,
letterSpacing: -0.08,
flex: 1,
overflow: 'hidden',
});
export const contentEmpty = style({
opacity: 0.3,
});
@@ -0,0 +1,45 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const tagColorVar = createVar();
export const tags = style({
width: '100%',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
position: 'relative',
transition: 'height 0.23s',
overflow: 'hidden',
});
export const tag = style({
visibility: 'hidden',
position: 'absolute',
// transition: 'all 0.23s',
padding: '0px 8px',
borderRadius: 10,
alignItems: 'center',
border: `1px solid ${cssVarV2('layer/insideBorder/blackBorder')}`,
maxWidth: '100%',
fontSize: 12,
lineHeight: '20px',
fontWeight: 400,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
':before': {
content: "''",
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: tagColorVar,
marginRight: 4,
},
});
@@ -0,0 +1,143 @@
import { observeResize } from '@affine/component';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useCallback, useEffect, useRef } from 'react';
import * as styles from './tag.css';
const DocCardTag = ({ tag }: { tag: Tag }) => {
const name = useLiveData(tag.value$);
const color = useLiveData(tag.color$);
return (
<li
data-name={name}
data-color={color}
className={styles.tag}
style={assignInlineVars({ [styles.tagColorVar]: color })}
>
{name}
</li>
);
};
const GAP = 4;
const MIN_WIDTH = 32;
const DocCardTagsRenderer = ({ tags, rows }: { tags: Tag[]; rows: number }) => {
const ulRef = useRef<HTMLUListElement>(null);
// A strategy to layout tags
const layoutTags = useCallback(
(entry: ResizeObserverEntry) => {
const availableWidth = entry.contentRect.width;
const lis = Array.from(ulRef.current?.querySelectorAll('li') ?? []);
const tagGrid: Array<{
x: number;
y: number;
w: number;
el: HTMLLIElement;
}> = [];
for (let i = 0; i < rows; i++) {
let width = 0;
let restSpace = availableWidth - width;
while (restSpace >= MIN_WIDTH) {
const li = lis.shift();
if (!li) break;
const liWidth = li.scrollWidth + 2; // 2 is for border
let liDisplayWidth = Math.min(liWidth, restSpace);
restSpace = restSpace - liDisplayWidth - GAP;
if (restSpace < MIN_WIDTH) {
liDisplayWidth += restSpace;
}
tagGrid.push({
x: width,
y: i * (22 + GAP),
w: liDisplayWidth,
el: li,
});
width += liDisplayWidth + GAP;
}
}
const lastItem = tagGrid[tagGrid.length - 1];
tagGrid.forEach(({ el, x, y, w }) => {
Object.assign(el.style, {
width: `${w}px`,
transform: `translate(${x}px, ${y}px)`,
visibility: 'visible',
});
});
// hide rest
lis.forEach(li =>
Object.assign(li.style, {
visibility: 'hidden',
width: '0px',
transform: `translate(0px, ${lastItem.y + 22 + GAP}px)`,
})
);
// update ul height
// to avoid trigger resize immediately
setTimeout(() => {
if (ulRef.current) {
ulRef.current.style.height = `${lastItem.y + 22}px`;
}
});
},
[rows]
);
const prevEntryRef = useRef<ResizeObserverEntry | null>(null);
useEffect(() => {
tags; // make sure tags is in deps
const ul = ulRef.current;
if (!ul) return;
const dispose = observeResize(ul, entry => {
if (entry.contentRect.width === prevEntryRef.current?.contentRect.width) {
return;
}
layoutTags(entry);
prevEntryRef.current = entry;
});
return () => {
dispose();
prevEntryRef.current = null;
};
}, [layoutTags, tags]);
return (
<ul className={styles.tags} ref={ulRef} style={{ gap: GAP }}>
{tags.map(tag => (
<DocCardTag key={tag.id} tag={tag} />
))}
{/* TODO: more icon */}
{/* <MoreHorizontalIcon /> */}
</ul>
);
};
export const DocCardTags = ({
docId,
rows = 2,
}: {
docId: string;
rows?: number;
}) => {
const tagService = useService(TagService);
const tags = useLiveData(tagService.tagList.tagsByPageId$(docId));
if (!tags.length) return null;
return <DocCardTagsRenderer tags={tags} rows={rows} />;
};
@@ -0,0 +1,4 @@
export * from './app-tabs';
export * from './page-header';
export * from './search-button';
export * from './workspace-selector';
@@ -0,0 +1,92 @@
import { IconButton } from '@affine/component';
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import {
forwardRef,
type HtmlHTMLAttributes,
type ReactNode,
useCallback,
} from 'react';
import * as styles from './styles.css';
export interface PageHeaderProps
extends Omit<HtmlHTMLAttributes<HTMLHeadElement>, 'prefix'> {
/**
* whether to show back button
*/
back?: boolean;
/**
* prefix content, shown after back button(if exists)
*/
prefix?: ReactNode;
/**
* suffix content
*/
suffix?: ReactNode;
/**
* Weather to center the content
* @default true
*/
centerContent?: boolean;
prefixClassName?: string;
prefixStyle?: React.CSSProperties;
suffixClassName?: string;
suffixStyle?: React.CSSProperties;
}
export const PageHeader = forwardRef<HTMLHeadElement, PageHeaderProps>(
function PageHeader(
{
back,
prefix,
suffix,
children,
className,
centerContent = true,
prefixClassName,
prefixStyle,
suffixClassName,
suffixStyle,
...attrs
},
ref
) {
const handleRouteBack = useCallback(() => {
history.back();
}, []);
return (
<header ref={ref} className={clsx(styles.root, className)} {...attrs}>
<section
className={clsx(styles.prefix, prefixClassName)}
style={prefixStyle}
>
{back ? (
<IconButton
size={24}
style={{ padding: 10 }}
onClick={handleRouteBack}
icon={<ArrowLeftSmallIcon />}
/>
) : null}
{prefix}
</section>
<section className={clsx(styles.content, { center: centerContent })}>
{children}
</section>
<section
className={clsx(styles.suffix, suffixClassName)}
style={suffixStyle}
>
{suffix}
</section>
</header>
);
}
);
@@ -0,0 +1,38 @@
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
minHeight: 44,
padding: '0 6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'relative',
});
export const content = style({
selectors: {
'&.center': {
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
},
'&:not(.center)': {
width: 0,
flex: 1,
},
},
});
export const spacer = style({
width: 0,
flex: 1,
});
export const prefix = style({
display: 'flex',
alignItems: 'center',
gap: 0,
});
export const suffix = style({
display: 'flex',
alignItems: 'center',
gap: 18,
});
@@ -0,0 +1,17 @@
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { SearchIcon } from '@blocksuite/icons/rc';
import * as styles from './styles.css';
export const SearchButton = () => {
const t = useI18n();
return (
<WorkbenchLink to="/search">
<div className={styles.search}>
<SearchIcon className={styles.icon} />
{t['Quick search']()}
</div>
</WorkbenchLink>
);
};
@@ -0,0 +1,25 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const search = style({
width: '100%',
height: 44,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 8px',
background: cssVarV2('layer/background/primary'),
borderRadius: 10,
color: cssVarV2('text/secondary'),
fontSize: 17,
fontWeight: 400,
lineHeight: '22px',
letterSpacing: -0.43,
});
export const icon = style({
width: 20,
height: 20,
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,24 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const card = style({
display: 'flex',
alignItems: 'center',
gap: 10,
});
export const label = style({
display: 'flex',
gap: 4,
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
color: cssVarV2('text/primary'),
letterSpacing: -0.43,
});
export const dropdownIcon = style({
fontSize: 24,
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,44 @@
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { ArrowDownSmallIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, type HTMLAttributes } from 'react';
import { card, dropdownIcon, label } from './card.css';
export interface CurrentWorkspaceCardProps
extends HTMLAttributes<HTMLDivElement> {}
export const CurrentWorkspaceCard = forwardRef<
HTMLDivElement,
CurrentWorkspaceCardProps
>(function CurrentWorkspaceCard({ onClick, className, ...attrs }, ref) {
const currentWorkspace = useService(WorkspaceService).workspace;
const info = useWorkspaceInfo(currentWorkspace.meta);
const name = info?.name ?? UNTITLED_WORKSPACE_NAME;
return (
<div
ref={ref}
onClick={onClick}
className={clsx(card, className)}
{...attrs}
>
<WorkspaceAvatar
key={currentWorkspace.id}
meta={currentWorkspace.meta}
rounded={3}
data-testid="workspace-avatar"
size={40}
name={name}
colorfulFallback
/>
<div className={label}>
{name}
<ArrowDownSmallIcon className={dropdownIcon} />
</div>
</div>
);
});
@@ -0,0 +1,39 @@
import { MobileMenu } from '@affine/component';
import { track } from '@affine/core/mixpanel';
import { useService, WorkspacesService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { CurrentWorkspaceCard } from './current-card';
import { SelectorMenu } from './menu';
export const WorkspaceSelector = () => {
const [open, setOpen] = useState(false);
const workspaceManager = useService(WorkspacesService);
const openMenu = useCallback(() => {
track.$.navigationPanel.workspaceList.open();
setOpen(true);
}, []);
const close = useCallback(() => {
setOpen(false);
}, []);
// revalidate workspace list when open workspace list
useEffect(() => {
if (open) workspaceManager.list.revalidate();
}, [workspaceManager, open]);
return (
<MobileMenu
items={<SelectorMenu onClose={close} />}
rootOptions={{ open }}
contentOptions={{
onInteractOutside: close,
onEscapeKeyDown: close,
style: { padding: 0 },
}}
>
<CurrentWorkspaceCard onClick={openMenu} />
</MobileMenu>
);
};
@@ -0,0 +1,76 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
maxHeight: 'calc(100vh - 100px)',
display: 'flex',
flexDirection: 'column',
});
export const divider = style({
height: 16,
display: 'flex',
alignItems: 'center',
position: 'relative',
':before': {
content: '""',
width: '100%',
height: 0.5,
background: cssVar('dividerColor'),
},
});
export const head = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
padding: '10px 16px',
fontSize: 17,
fontWeight: 600,
lineHeight: '22px',
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
});
export const body = style({
overflowY: 'auto',
flexShrink: 0,
flex: 1,
});
export const wsList = style({});
export const wsListTitle = style({
padding: '6px 16px',
fontSize: 13,
lineHeight: '18px',
letterSpacing: -0.08,
color: cssVar('textSecondaryColor'),
});
export const wsItem = style({
padding: '4px 12px',
});
export const wsCard = style({
display: 'flex',
alignItems: 'center',
border: 'none',
background: 'none',
width: '100%',
padding: 8,
borderRadius: 8,
gap: 8,
':active': {
background: cssVarV2('layer/background/hoverOverlay'),
},
});
export const wsName = style({
width: 0,
flex: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
textAlign: 'left',
});
@@ -0,0 +1,128 @@
import { IconButton } from '@affine/component';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { WorkspaceSubPath } from '@affine/core/shared';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { CloseIcon, CollaborationIcon } from '@blocksuite/icons/rc';
import {
useLiveData,
useService,
type WorkspaceMetadata,
WorkspaceService,
WorkspacesService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { type HTMLAttributes, useCallback, useMemo } from 'react';
import * as styles from './menu.css';
const filterByFlavour = (
workspaces: WorkspaceMetadata[],
flavour: WorkspaceFlavour
) => workspaces.filter(ws => flavour === ws.flavour);
const WorkspaceItem = ({
workspace,
className,
...attrs
}: { workspace: WorkspaceMetadata } & HTMLAttributes<HTMLButtonElement>) => {
const info = useWorkspaceInfo(workspace);
const name = info?.name;
const isOwner = info?.isOwner;
return (
<li className={styles.wsItem}>
<button className={clsx(styles.wsCard, className)} {...attrs}>
<WorkspaceAvatar
key={workspace.id}
meta={workspace}
rounded={6}
data-testid="workspace-avatar"
size={32}
name={name}
colorfulFallback
/>
<div className={styles.wsName}>{name}</div>
{!isOwner ? <CollaborationIcon fontSize={24} /> : null}
</button>
</li>
);
};
const WorkspaceList = ({
list,
title,
onClose,
}: {
title: string;
list: WorkspaceMetadata[];
onClose?: () => void;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const { jumpToSubPath } = useNavigateHelper();
const toggleWorkspace = useCallback(
(id: string) => {
if (id !== currentWorkspace.id) {
jumpToSubPath(id, WorkspaceSubPath.ALL);
}
onClose?.();
},
[currentWorkspace.id, jumpToSubPath, onClose]
);
if (!list.length) return null;
return (
<>
<section className={styles.wsListTitle}>{title}</section>
<ul className={styles.wsList}>
{list.map(ws => (
<WorkspaceItem
key={ws.id}
workspace={ws}
onClick={() => toggleWorkspace?.(ws.id)}
/>
))}
</ul>
</>
);
};
export const SelectorMenu = ({ onClose }: { onClose?: () => void }) => {
const workspacesService = useService(WorkspacesService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const cloudWorkspaces = useMemo(
() => filterByFlavour(workspaces, WorkspaceFlavour.AFFINE_CLOUD),
[workspaces]
);
const localWorkspaces = useMemo(
() => filterByFlavour(workspaces, WorkspaceFlavour.LOCAL),
[workspaces]
);
return (
<div className={styles.root}>
<header className={styles.head}>
Workspace
<IconButton onClick={onClose} size="24" icon={<CloseIcon />} />
</header>
<div className={styles.divider} />
<main className={styles.body}>
<WorkspaceList
onClose={onClose}
title="Cloud Sync"
list={cloudWorkspaces}
/>
<WorkspaceList
onClose={onClose}
title="Local Storage"
list={localWorkspaces}
/>
</main>
</div>
);
};