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

View File

@ -1,34 +1,32 @@
import type { DocCollection } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { Suspense } from 'react';
import { type ReactNode, Suspense } from 'react';
import { useBlockSuitePagePreview } from './use-block-suite-page-preview';
import { useDocCollectionPage } from './use-block-suite-workspace-page';
interface PagePreviewInnerProps {
interface PagePreviewProps {
docCollection: DocCollection;
pageId: string;
emptyFallback?: ReactNode;
}
const PagePreviewInner = ({
docCollection: workspace,
pageId,
}: PagePreviewInnerProps) => {
emptyFallback,
}: PagePreviewProps) => {
const page = useDocCollectionPage(workspace, pageId);
const previewAtom = useBlockSuitePagePreview(page);
const preview = useAtomValue(previewAtom);
return preview ? preview : null;
const res = preview ? preview : null;
return res || emptyFallback;
};
interface PagePreviewProps {
docCollection: DocCollection;
pageId: string;
}
export const PagePreview = ({ docCollection, pageId }: PagePreviewProps) => {
export const PagePreview = (props: PagePreviewProps) => {
return (
<Suspense>
<PagePreviewInner docCollection={docCollection} pageId={pageId} />
<PagePreviewInner {...props} />
</Suspense>
);
};

View File

@ -5,6 +5,7 @@ import { map } from 'rxjs';
import type { CollapsibleSectionName } from '../types';
const DEFAULT_COLLAPSABLE_STATE: Record<CollapsibleSectionName, boolean> = {
recent: true,
favorites: false,
organize: false,
collections: true,

View File

@ -8,6 +8,7 @@ import { ExplorerSection } from './entities/explore-section';
import { ExplorerService } from './services/explorer';
export { ExplorerService } from './services/explorer';
export type { CollapsibleSectionName } from './types';
export { CollapsibleSection } from './views/layouts/collapsible-section';
export { ExplorerMobileContext } from './views/mobile.context';
export { ExplorerCollections } from './views/sections/collections';
export { ExplorerFavorites } from './views/sections/favorites';

View File

@ -4,6 +4,7 @@ import { ExplorerSection } from '../entities/explore-section';
import type { CollapsibleSectionName } from '../types';
const allSectionName: Array<CollapsibleSectionName> = [
'recent', // mobile only
'favorites',
'organize',
'collections',

View File

@ -1,4 +1,5 @@
export type CollapsibleSectionName =
| 'recent'
| 'collections'
| 'favorites'
| 'tags'

View File

@ -77,7 +77,7 @@ export const postfix = style({
});
export const iconContainer = style({
display: 'flex',
alignContent: 'center',
justifyContent: 'center',
alignItems: 'center',
width: 20,
height: 20,

View File

@ -1,9 +1,11 @@
export { Workbench } from './entities/workbench';
export { ViewScope } from './scopes/view';
export { WorkbenchService } from './services/workbench';
export { useBindWorkbenchToBrowserRouter } from './view/browser-adapter';
export { useIsActiveView } from './view/use-is-active-view';
export { ViewBody, ViewHeader, ViewSidebarTab } from './view/view-islands';
export { ViewIcon, ViewTitle } from './view/view-meta';
export type { WorkbenchLinkProps } from './view/workbench-link';
export { WorkbenchLink } from './view/workbench-link';
export { WorkbenchRoot } from './view/workbench-root';

View File

@ -10,56 +10,57 @@ import { forwardRef, type MouseEvent } from 'react';
import { WorkbenchService } from '../services/workbench';
export const WorkbenchLink = forwardRef<
HTMLAnchorElement,
React.PropsWithChildren<
{
to: To;
onClick?: (e: MouseEvent) => void;
} & React.HTMLProps<HTMLAnchorElement>
>
>(function WorkbenchLink({ to, onClick, ...other }, ref) {
const { featureFlagService, workbenchService } = useServices({
FeatureFlagService,
WorkbenchService,
});
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const workbench = workbenchService.workbench;
const basename = useLiveData(workbench.basename$);
const link =
basename +
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
const handleClick = useCatchEventCallback(
async (event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (event.defaultPrevented) {
return;
}
const at = (() => {
if (isNewTabTrigger(event)) {
return event.altKey && enableMultiView && environment.isDesktop
? 'tail'
: 'new-tab';
}
return 'active';
})();
workbench.open(to, { at });
event.preventDefault();
},
[enableMultiView, onClick, to, workbench]
);
export type WorkbenchLinkProps = React.PropsWithChildren<
{
to: To;
onClick?: (e: MouseEvent) => void;
} & React.HTMLProps<HTMLAnchorElement>
>;
// eslint suspicious runtime error
// eslint-disable-next-line react/no-danger-with-children
return (
<a
{...other}
ref={ref}
href={link}
onClick={handleClick}
onAuxClick={handleClick}
/>
);
});
export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
function WorkbenchLink({ to, onClick, ...other }, ref) {
const { featureFlagService, workbenchService } = useServices({
FeatureFlagService,
WorkbenchService,
});
const enableMultiView = useLiveData(
featureFlagService.flags.enable_multi_view.$
);
const workbench = workbenchService.workbench;
const basename = useLiveData(workbench.basename$);
const link =
basename +
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
const handleClick = useCatchEventCallback(
async (event: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (event.defaultPrevented) {
return;
}
const at = (() => {
if (isNewTabTrigger(event)) {
return event.altKey && enableMultiView && environment.isDesktop
? 'tail'
: 'new-tab';
}
return 'active';
})();
workbench.open(to, { at });
event.preventDefault();
},
[enableMultiView, onClick, to, workbench]
);
// eslint suspicious runtime error
// eslint-disable-next-line react/no-danger-with-children
return (
<a
{...other}
ref={ref}
href={link}
onClick={handleClick}
onAuxClick={handleClick}
/>
);
}
);

View File

@ -28,7 +28,11 @@ export const loader: LoaderFunction = async () => {
return null;
};
export const Component = () => {
export const Component = ({
defaultIndexRoute = WorkspaceSubPath.ALL,
}: {
defaultIndexRoute?: WorkspaceSubPath;
}) => {
// navigating and creating may be slow, to avoid flickering, we show workspace fallback
const [navigating, setNavigating] = useState(true);
const [creating, setCreating] = useState(false);
@ -59,11 +63,11 @@ export const Component = () => {
if (defaultDocId) {
jumpToPage(meta.id, defaultDocId);
} else {
openPage(meta.id, WorkspaceSubPath.ALL);
openPage(meta.id, defaultIndexRoute);
}
})
.catch(err => console.error('Failed to create cloud workspace', err));
}, [jumpToPage, openPage, workspacesService]);
}, [defaultIndexRoute, jumpToPage, openPage, workspacesService]);
useLayoutEffect(() => {
if (!navigating) {
@ -86,7 +90,7 @@ export const Component = () => {
const openWorkspace =
list.find(w => w.flavour === WorkspaceFlavour.AFFINE_CLOUD) ??
list[0];
openPage(openWorkspace.id, WorkspaceSubPath.ALL);
openPage(openWorkspace.id, defaultIndexRoute);
} else {
return;
}
@ -99,7 +103,7 @@ export const Component = () => {
const lastId = localStorage.getItem('last_workspace_id');
const openWorkspace = list.find(w => w.id === lastId) ?? list[0];
openPage(openWorkspace.id, WorkspaceSubPath.ALL);
openPage(openWorkspace.id, defaultIndexRoute);
}
}, [
createCloudWorkspace,
@ -109,6 +113,7 @@ export const Component = () => {
listIsLoading,
loggedIn,
navigating,
defaultIndexRoute,
]);
useEffect(() => {

View File

@ -1,5 +1,7 @@
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useBindWorkbenchToBrowserRouter } from '@affine/core/modules/workbench/view/browser-adapter';
import {
useBindWorkbenchToBrowserRouter,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { ViewRoot } from '@affine/core/modules/workbench/view/view-root';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';

View File

@ -7,12 +7,14 @@ export enum WorkspaceSubPath {
ALL = 'all',
TRASH = 'trash',
SHARED = 'shared',
HOME = 'home',
}
export const WorkspaceSubPathName = {
[WorkspaceSubPath.ALL]: 'All Pages',
[WorkspaceSubPath.TRASH]: 'Trash',
[WorkspaceSubPath.SHARED]: 'Shared',
[WorkspaceSubPath.HOME]: 'Home',
} satisfies {
[Path in WorkspaceSubPath]: string;
};
@ -21,6 +23,7 @@ export const pathGenerator = {
all: workspaceId => `/workspace/${workspaceId}/all`,
trash: workspaceId => `/workspace/${workspaceId}/trash`,
shared: workspaceId => `/workspace/${workspaceId}/shared`,
home: workspaceId => `/workspace/${workspaceId}/home`,
} satisfies {
[Path in WorkspaceSubPath]: (workspaceId: string) => string;
};

View File

@ -12,16 +12,22 @@
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/env": "workspace:*",
"@blocksuite/icons": "^2.1.64",
"@sentry/react": "^8.0.0",
"@toeverything/theme": "^1.0.7",
"clsx": "^2.1.1",
"core-js": "^3.36.1",
"intl-segmenter-polyfill-rs": "^0.1.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.1"
},
"devDependencies": {
"@affine/cli": "workspace:*",
"@types/react": "^18.2.75",
"@types/react-dom": "^18.2.24",
"@vanilla-extract/css": "^1.15.5",
"@vanilla-extract/dynamic": "^2.1.2",
"cross-env": "^7.0.3",
"typescript": "^5.4.5"
}

View File

@ -1,5 +1,6 @@
import '@affine/component/theme/global.css';
import '@affine/component/theme/theme.css';
import './styles/mobile.css';
import { AffineContext } from '@affine/component/context';
import { AppFallback } from '@affine/core/components/affine/app-container';

View File

@ -0,0 +1,3 @@
# mobile components
Maintain the smallest possible business components here.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,54 @@
import { useEffect } from 'react';
type Handler<T extends Event> = (event: T) => void;
const _handlesMap = new Map<
keyof WindowEventMap,
Array<Handler<WindowEventMap[keyof WindowEventMap]>>
>();
function initGlobalEvent<T extends keyof WindowEventMap>(name: T) {
const prev = _handlesMap.get(name);
if (!prev) {
const handlers = [] as Handler<WindowEventMap[T]>[];
window.addEventListener(name, e => {
handlers.forEach(handler => {
handler(e);
});
});
_handlesMap.set(name, handlers as any);
return handlers;
}
return prev;
}
function addListener<T extends keyof WindowEventMap>(
name: T,
handler: (e: WindowEventMap[T]) => void
) {
initGlobalEvent(name).push(handler);
}
function removeListener<T extends keyof WindowEventMap>(
name: T,
handler: Handler<WindowEventMap[T]>
) {
const handlers = _handlesMap.get(name) as Handler<WindowEventMap[T]>[];
const idx = handlers.indexOf(handler);
if (idx !== -1) {
handlers.splice(idx, 1);
}
}
export const useGlobalEvent = <T extends keyof WindowEventMap>(
name: T,
handler: (e: WindowEventMap[T]) => void
) => {
useEffect(() => {
addListener(name, handler);
return () => {
removeListener(name, handler);
};
}, [handler, name]);
};

View File

@ -1,8 +1,9 @@
import { Component as IndexComponent } from '@affine/core/pages/index';
import { WorkspaceSubPath } from '@affine/core/shared';
// Default route fallback for mobile
export const Component = () => {
// TODO: replace with a mobile version
return <IndexComponent />;
return <IndexComponent defaultIndexRoute={WorkspaceSubPath.HOME} />;
};

View File

@ -1,3 +1,10 @@
import { AppTabs } from '../../components';
export const Component = () => {
return <div>/workspace/:workspaceId/all</div>;
return (
<>
`workspace/all.tsx`;
<AppTabs />
</>
);
};

View File

@ -1,3 +1,10 @@
import { PageHeader } from '../../components/page-header';
export const Component = () => {
return <div>/workspace/:workspaceId/:pageId</div>;
return (
<>
<PageHeader back />
<div>/workspace/:workspaceId/:pageId</div>;
</>
);
};

View File

@ -0,0 +1,35 @@
import {
ExplorerCollections,
ExplorerFavorites,
ExplorerMigrationFavorites,
ExplorerMobileContext,
ExplorerOrganize,
} from '@affine/core/modules/explorer';
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
import { AppTabs } from '../../components';
import { HomeHeader, RecentDocs } from '../../views';
export const Component = () => {
return (
<ExplorerMobileContext.Provider value={true}>
<HomeHeader />
<RecentDocs />
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 32,
padding: '0 8px 32px 8px',
}}
>
<ExplorerFavorites />
{runtimeConfig.enableOrganize && <ExplorerOrganize />}
<ExplorerMigrationFavorites />
<ExplorerCollections />
<ExplorerTags />
</div>
<AppTabs />
</ExplorerMobileContext.Provider>
);
};

View File

@ -1,5 +1,5 @@
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { RouteContainer } from '@affine/core/modules/workbench/view/route-container';
import { PageNotFound } from '@affine/core/pages/404';
import { MobileWorkbenchRoot } from '@affine/core/pages/workspace/workbench-root';
import {
@ -7,13 +7,44 @@ import {
useServices,
WorkspacesService,
} from '@toeverything/infra';
import { lazy as reactLazy, useEffect, useMemo, useState } from 'react';
import { matchPath, useLocation, useParams } from 'react-router-dom';
import {
lazy as reactLazy,
Suspense,
useEffect,
useMemo,
useState,
} from 'react';
import {
matchPath,
type RouteObject,
useLocation,
useParams,
} from 'react-router-dom';
import { viewRoutes } from '../../router';
import { WorkspaceLayout } from './layout';
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
type Route = { Component: React.ComponentType };
/**
* Source: core/src/modules/workbench/view/route-container.tsx
**/
const MobileRouteContainer = ({ route }: { route: Route }) => {
return (
<AffineErrorBoundary>
<Suspense>
<route.Component />
</Suspense>
</AffineErrorBoundary>
);
};
const warpedRoutes = viewRoutes.map((originalRoute: RouteObject) => {
if (originalRoute.Component || !originalRoute.lazy) {
return originalRoute;
}
const { path, lazy } = originalRoute;
const Component = reactLazy(() =>
lazy().then(m => ({
default: m.Component as React.ComponentType,
@ -26,7 +57,7 @@ const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
return {
path,
Component: () => {
return <RouteContainer route={route} />;
return <MobileRouteContainer route={route} />;
},
};
});

View File

@ -0,0 +1,10 @@
import { AppTabs } from '../../components';
export const Component = () => {
return (
<>
Search
<AppTabs />
</>
);
};

View File

@ -6,6 +6,10 @@ import {
redirect,
} from 'react-router-dom';
import { Component as All } from './pages/workspace/all';
import { Component as Home } from './pages/workspace/home';
import { Component as Search } from './pages/workspace/search';
export const topLevelRoutes = [
{
element: <RootRouter />,
@ -49,9 +53,17 @@ export const topLevelRoutes = [
] satisfies [RouteObject, ...RouteObject[]];
export const viewRoutes = [
{
path: '/home',
Component: Home,
},
{
path: '/search',
Component: Search,
},
{
path: '/all',
lazy: () => import('./pages/workspace/all'),
Component: All,
},
{
path: '/collection',

View File

@ -0,0 +1,23 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, globalStyle } from '@vanilla-extract/css';
export const globalVars = {
appTabHeight: createVar('appTabHeight'),
};
globalStyle(':root', {
vars: {
[globalVars.appTabHeight]: '68px',
},
});
globalStyle('body', {
height: 'auto',
});
globalStyle('body:has(#app-tabs)', {
paddingBottom: globalVars.appTabHeight,
});
globalStyle('html', {
overflowY: 'auto',
background: cssVarV2('layer/background/secondary'),
});

View File

@ -0,0 +1,3 @@
# mobile views
Maintain complex views that used for `../pages`, view can contain mobile-components in `../components`

View File

@ -0,0 +1,51 @@
import { IconButton } from '@affine/component';
import { SettingsIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { SearchButton, WorkspaceSelector } from '../../components';
import { useGlobalEvent } from '../../hooks/use-global-events';
import * as styles from './styles.css';
/**
* Contains `Setting`, `Workspace Selector`, `Search`
* When scrolled:
* - combine Setting and Workspace Selector
* - hide Search
*/
export const HomeHeader = () => {
const [dense, setDense] = useState(false);
useGlobalEvent(
'scroll',
useCallback(() => {
setDense(window.scrollY > 114);
}, [])
);
return (
<div className={clsx(styles.root, { dense })}>
<div className={styles.float}>
<div className={styles.headerAndWsSelector}>
<div className={styles.wsSelectorWrapper}>
<WorkspaceSelector />
</div>
<div className={styles.settingWrapper}>
<Link to="/settings">
<IconButton
size="24"
style={{ padding: 10 }}
icon={<SettingsIcon />}
/>
</Link>
</div>
</div>
<div className={styles.searchWrapper}>
<SearchButton />
</div>
</div>
<div className={styles.space} />
</div>
);
};

View File

@ -0,0 +1,83 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
const headerHeight = createVar('headerHeight');
const wsSelectorHeight = createVar('wsSelectorHeight');
const searchHeight = createVar('searchHeight');
const searchPadding = [10, 16, 15, 16];
export const root = style({
vars: {
[headerHeight]: '44px',
[wsSelectorHeight]: '48px',
[searchHeight]: '44px',
},
width: '100vw',
});
export const float = style({
// why not 'sticky'?
// when height change, will affect scroll behavior, causing shaking
position: 'fixed',
top: 0,
width: '100%',
background: cssVarV2('layer/background/secondary'),
paddingTop: 12,
zIndex: 1,
});
export const space = style({
height: `calc(${headerHeight} + ${wsSelectorHeight} + ${searchHeight} + ${searchPadding[0] + searchPadding[2]}px + 12px)`,
});
export const headerAndWsSelector = style({
display: 'flex',
gap: 10,
alignItems: 'end',
transition: 'height 0.2s',
height: `calc(${headerHeight} + ${wsSelectorHeight})`,
selectors: {
[`${root}.dense &`]: {
height: wsSelectorHeight,
},
},
});
export const wsSelectorWrapper = style({
width: 0,
flex: 1,
height: wsSelectorHeight,
padding: '0 10px 0 16px',
});
export const settingWrapper = style({
width: '44px',
height: headerHeight,
transition: 'height 0.2s',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'start',
selectors: {
[`${root}.dense &`]: {
height: wsSelectorHeight,
},
},
});
export const searchWrapper = style({
padding: searchPadding.map(v => `${v}px`).join(' '),
width: '100%',
height: 44 + searchPadding[0] + searchPadding[2],
transition: 'all 0.2s',
overflow: 'hidden',
selectors: {
[`${root}.dense &`]: {
height: 0,
paddingTop: 0,
paddingBottom: 0,
},
},
});

View File

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

View File

@ -0,0 +1,36 @@
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { CollapsibleSection } from '@affine/core/modules/explorer';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useMemo } from 'react';
import { DocCard } from '../../components/doc-card';
import * as styles from './styles.css';
export const RecentDocs = ({ max = 5 }: { max?: number }) => {
const workspace = useService(WorkspaceService).workspace;
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
const cardMetas = useMemo(() => {
return [...allPageMetas]
.sort((a, b) => (b.updatedDate ?? 0) - (a.updatedDate ?? 0))
.slice(0, max);
}, [allPageMetas, max]);
return (
<CollapsibleSection
name="recent"
title="Recent"
headerClassName={styles.header}
>
<div className={styles.scroll}>
<ul className={styles.list}>
{cardMetas.map((doc, index) => (
<li key={index} className={styles.cardWrapper}>
<DocCard meta={doc} />
</li>
))}
</ul>
</div>
</CollapsibleSection>
);
};

View File

@ -0,0 +1,31 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const scroll = style({
width: '100%',
paddingTop: 8,
paddingBottom: 32,
overflowX: 'auto',
});
export const list = style({
paddingLeft: 16,
paddingRight: 16,
display: 'flex',
gap: 10,
width: 'fit-content',
});
export const cardWrapper = style({
width: 172,
height: 210,
flexShrink: 0,
});
export const header = style({
margin: '0 8px',
});
globalStyle(`${cardWrapper} > *`, {
width: '100%',
height: '100%',
});

View File

@ -660,14 +660,20 @@ __metadata:
"@affine/component": "workspace:*"
"@affine/core": "workspace:*"
"@affine/env": "workspace:*"
"@blocksuite/icons": "npm:^2.1.64"
"@sentry/react": "npm:^8.0.0"
"@toeverything/theme": "npm:^1.0.7"
"@types/react": "npm:^18.2.75"
"@types/react-dom": "npm:^18.2.24"
"@vanilla-extract/css": "npm:^1.15.5"
"@vanilla-extract/dynamic": "npm:^2.1.2"
clsx: "npm:^2.1.1"
core-js: "npm:^3.36.1"
cross-env: "npm:^7.0.3"
intl-segmenter-polyfill-rs: "npm:^0.1.7"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-router-dom: "npm:^6.26.1"
typescript: "npm:^5.4.5"
languageName: unknown
linkType: soft
@ -3619,7 +3625,7 @@ __metadata:
languageName: node
linkType: hard
"@blocksuite/icons@npm:2.1.64, @blocksuite/icons@npm:^2.1.62":
"@blocksuite/icons@npm:2.1.64, @blocksuite/icons@npm:^2.1.62, @blocksuite/icons@npm:^2.1.64":
version: 2.1.64
resolution: "@blocksuite/icons@npm:2.1.64"
peerDependencies:
@ -15317,7 +15323,7 @@ __metadata:
languageName: node
linkType: hard
"@vanilla-extract/dynamic@npm:^2.1.0":
"@vanilla-extract/dynamic@npm:^2.1.0, @vanilla-extract/dynamic@npm:^2.1.2":
version: 2.1.2
resolution: "@vanilla-extract/dynamic@npm:2.1.2"
dependencies:
@ -30816,7 +30822,7 @@ __metadata:
languageName: node
linkType: hard
"react-router-dom@npm:^6.22.3, react-router-dom@npm:^6.23.1":
"react-router-dom@npm:^6.22.3, react-router-dom@npm:^6.23.1, react-router-dom@npm:^6.26.1":
version: 6.26.1
resolution: "react-router-dom@npm:6.26.1"
dependencies: