(
+ name: T,
+ handler: (e: WindowEventMap[T]) => void
+) => {
+ useEffect(() => {
+ addListener(name, handler);
+
+ return () => {
+ removeListener(name, handler);
+ };
+ }, [handler, name]);
+};
diff --git a/packages/frontend/mobile/src/pages/index.tsx b/packages/frontend/mobile/src/pages/index.tsx
index 508c37574..5df7ff704 100644
--- a/packages/frontend/mobile/src/pages/index.tsx
+++ b/packages/frontend/mobile/src/pages/index.tsx
@@ -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 ;
+ return ;
};
diff --git a/packages/frontend/mobile/src/pages/workspace/all.tsx b/packages/frontend/mobile/src/pages/workspace/all.tsx
index e2ec20a6b..3097c003b 100644
--- a/packages/frontend/mobile/src/pages/workspace/all.tsx
+++ b/packages/frontend/mobile/src/pages/workspace/all.tsx
@@ -1,3 +1,10 @@
+import { AppTabs } from '../../components';
+
export const Component = () => {
- return /workspace/:workspaceId/all
;
+ return (
+ <>
+ `workspace/all.tsx`;
+
+ >
+ );
};
diff --git a/packages/frontend/mobile/src/pages/workspace/detail.tsx b/packages/frontend/mobile/src/pages/workspace/detail.tsx
index f6c32eba5..937c08166 100644
--- a/packages/frontend/mobile/src/pages/workspace/detail.tsx
+++ b/packages/frontend/mobile/src/pages/workspace/detail.tsx
@@ -1,3 +1,10 @@
+import { PageHeader } from '../../components/page-header';
+
export const Component = () => {
- return /workspace/:workspaceId/:pageId
;
+ return (
+ <>
+
+ /workspace/:workspaceId/:pageId
;
+ >
+ );
};
diff --git a/packages/frontend/mobile/src/pages/workspace/home.tsx b/packages/frontend/mobile/src/pages/workspace/home.tsx
new file mode 100644
index 000000000..970a70ffd
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/home.tsx
@@ -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 (
+
+
+
+
+
+ {runtimeConfig.enableOrganize && }
+
+
+
+
+
+
+ );
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/index.tsx b/packages/frontend/mobile/src/pages/workspace/index.tsx
index 831918664..71c74a41d 100644
--- a/packages/frontend/mobile/src/pages/workspace/index.tsx
+++ b/packages/frontend/mobile/src/pages/workspace/index.tsx
@@ -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 (
+
+
+
+
+
+ );
+};
+
+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 ;
+ return ;
},
};
});
diff --git a/packages/frontend/mobile/src/pages/workspace/search.tsx b/packages/frontend/mobile/src/pages/workspace/search.tsx
new file mode 100644
index 000000000..2e42427ec
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/search.tsx
@@ -0,0 +1,10 @@
+import { AppTabs } from '../../components';
+
+export const Component = () => {
+ return (
+ <>
+ Search
+
+ >
+ );
+};
diff --git a/packages/frontend/mobile/src/router.tsx b/packages/frontend/mobile/src/router.tsx
index 11f3130d0..02f8a7f0d 100644
--- a/packages/frontend/mobile/src/router.tsx
+++ b/packages/frontend/mobile/src/router.tsx
@@ -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: ,
@@ -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',
diff --git a/packages/frontend/mobile/src/styles/mobile.css.ts b/packages/frontend/mobile/src/styles/mobile.css.ts
new file mode 100644
index 000000000..4d8753890
--- /dev/null
+++ b/packages/frontend/mobile/src/styles/mobile.css.ts
@@ -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'),
+});
diff --git a/packages/frontend/mobile/src/views/README.md b/packages/frontend/mobile/src/views/README.md
new file mode 100644
index 000000000..e85ea4a44
--- /dev/null
+++ b/packages/frontend/mobile/src/views/README.md
@@ -0,0 +1,3 @@
+# mobile views
+
+Maintain complex views that used for `../pages`, view can contain mobile-components in `../components`
diff --git a/packages/frontend/mobile/src/views/home-header/index.tsx b/packages/frontend/mobile/src/views/home-header/index.tsx
new file mode 100644
index 000000000..465518a06
--- /dev/null
+++ b/packages/frontend/mobile/src/views/home-header/index.tsx
@@ -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 (
+
+ );
+};
diff --git a/packages/frontend/mobile/src/views/home-header/styles.css.ts b/packages/frontend/mobile/src/views/home-header/styles.css.ts
new file mode 100644
index 000000000..f52c2c3e6
--- /dev/null
+++ b/packages/frontend/mobile/src/views/home-header/styles.css.ts
@@ -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,
+ },
+ },
+});
diff --git a/packages/frontend/mobile/src/views/index.ts b/packages/frontend/mobile/src/views/index.ts
new file mode 100644
index 000000000..09ecfc48d
--- /dev/null
+++ b/packages/frontend/mobile/src/views/index.ts
@@ -0,0 +1,2 @@
+export * from './home-header';
+export * from './recent-docs';
diff --git a/packages/frontend/mobile/src/views/recent-docs/index.tsx b/packages/frontend/mobile/src/views/recent-docs/index.tsx
new file mode 100644
index 000000000..6945b38b1
--- /dev/null
+++ b/packages/frontend/mobile/src/views/recent-docs/index.tsx
@@ -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 (
+
+
+
+ {cardMetas.map((doc, index) => (
+ -
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/frontend/mobile/src/views/recent-docs/styles.css.ts b/packages/frontend/mobile/src/views/recent-docs/styles.css.ts
new file mode 100644
index 000000000..73cc1d08c
--- /dev/null
+++ b/packages/frontend/mobile/src/views/recent-docs/styles.css.ts
@@ -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%',
+});
diff --git a/yarn.lock b/yarn.lock
index 9122c7e7f..4f8222d02 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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: