diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx
index b19d181a73..08b8064c70 100644
--- a/packages/frontend/core/src/layouts/workspace-layout.tsx
+++ b/packages/frontend/core/src/layouts/workspace-layout.tsx
@@ -73,7 +73,7 @@ export const WorkspaceLayout = function WorkspaceLayout({
);
};
-const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => {
+export const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => {
const t = useI18n();
const pushGlobalLoadingEvent = useSetAtom(pushGlobalLoadingEventAtom);
const resolveGlobalLoadingEvent = useSetAtom(resolveGlobalLoadingEventAtom);
diff --git a/packages/frontend/core/src/modules/workbench/view/view-root.tsx b/packages/frontend/core/src/modules/workbench/view/view-root.tsx
index 55c1e2db96..9a29528683 100644
--- a/packages/frontend/core/src/modules/workbench/view/view-root.tsx
+++ b/packages/frontend/core/src/modules/workbench/view/view-root.tsx
@@ -1,5 +1,6 @@
import { FrameworkScope, useLiveData } from '@toeverything/infra';
-import { lazy as reactLazy, useLayoutEffect, useMemo } from 'react';
+import { useLayoutEffect, useMemo } from 'react';
+import type { RouteObject } from 'react-router-dom';
import {
createMemoryRouter,
RouterProvider,
@@ -7,30 +8,16 @@ import {
UNSAFE_RouteContext,
} from 'react-router-dom';
-import { viewRoutes } from '../../../router';
import type { View } from '../entities/view';
-import { RouteContainer } from './route-container';
-const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
- const Component = reactLazy(() =>
- lazy().then(m => ({
- default: m.Component as React.ComponentType,
- }))
- );
- const route = {
- Component,
- };
-
- return {
- path,
- Component: () => {
- return ;
- },
- };
-});
-
-export const ViewRoot = ({ view }: { view: View }) => {
- const viewRouter = useMemo(() => createMemoryRouter(warpedRoutes), []);
+export const ViewRoot = ({
+ view,
+ routes,
+}: {
+ view: View;
+ routes: RouteObject[];
+}) => {
+ const viewRouter = useMemo(() => createMemoryRouter(routes), [routes]);
const location = useLiveData(view.location$);
diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx
index 8b50d7b16a..9ce7b81717 100644
--- a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx
+++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx
@@ -1,5 +1,6 @@
import { ResizePanel } from '@affine/component/resize-panel';
import { rightSidebarWidthAtom } from '@affine/core/atoms';
+import { viewRoutes } from '@affine/core/router';
import {
appSettingAtom,
FrameworkScope,
@@ -7,13 +8,21 @@ import {
useService,
} from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
-import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import {
+ lazy as reactLazy,
+ memo,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import { useLocation } from 'react-router-dom';
import type { View } from '../entities/view';
import { WorkbenchService } from '../services/workbench';
import { useBindWorkbenchToBrowserRouter } from './browser-adapter';
import { useBindWorkbenchToDesktopRouter } from './desktop-adapter';
+import { RouteContainer } from './route-container';
import { SidebarContainer } from './sidebar/sidebar-container';
import { SplitView } from './split-view/split-view';
import { ViewIslandRegistryProvider } from './view-islands';
@@ -24,6 +33,24 @@ const useAdapter = environment.isDesktop
? useBindWorkbenchToDesktopRouter
: useBindWorkbenchToBrowserRouter;
+const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
+ const Component = reactLazy(() =>
+ lazy().then(m => ({
+ default: m.Component as React.ComponentType,
+ }))
+ );
+ const route = {
+ Component,
+ };
+
+ return {
+ path,
+ Component: () => {
+ return ;
+ },
+ };
+});
+
export const WorkbenchRoot = memo(() => {
const workbench = useService(WorkbenchService).workbench;
@@ -93,7 +120,7 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
return (
-
+
);
};
diff --git a/packages/frontend/core/src/pages/workspace/workbench-root.tsx b/packages/frontend/core/src/pages/workspace/workbench-root.tsx
new file mode 100644
index 0000000000..e12e9b2d5e
--- /dev/null
+++ b/packages/frontend/core/src/pages/workspace/workbench-root.tsx
@@ -0,0 +1,26 @@
+import { WorkbenchService } from '@affine/core/modules/workbench';
+import { useBindWorkbenchToBrowserRouter } from '@affine/core/modules/workbench/view/browser-adapter';
+import { ViewRoot } from '@affine/core/modules/workbench/view/view-root';
+import { useLiveData, useService } from '@toeverything/infra';
+import { useEffect } from 'react';
+import { type RouteObject, useLocation } from 'react-router-dom';
+
+export const MobileWorkbenchRoot = ({ routes }: { routes: RouteObject[] }) => {
+ const workbench = useService(WorkbenchService).workbench;
+
+ // for debugging
+ (window as any).workbench = workbench;
+
+ const views = useLiveData(workbench.views$);
+
+ const location = useLocation();
+ const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
+
+ useBindWorkbenchToBrowserRouter(workbench, basename);
+
+ useEffect(() => {
+ workbench.updateBasename(basename);
+ }, [basename, workbench]);
+
+ return ;
+};
diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx
index e43c36cf3a..ec8b42ff1e 100644
--- a/packages/frontend/core/src/router.tsx
+++ b/packages/frontend/core/src/router.tsx
@@ -11,7 +11,7 @@ import {
export const NavigateContext = createContext(null);
-function RootRouter() {
+export function RootRouter() {
const navigate = useNavigate();
const [ready, setReady] = useState(false);
useEffect(() => {
diff --git a/packages/frontend/mobile/package.json b/packages/frontend/mobile/package.json
new file mode 100644
index 0000000000..dfabc27b90
--- /dev/null
+++ b/packages/frontend/mobile/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@affine/mobile",
+ "version": "0.16.0",
+ "description": "AFFiNE Desktop Web application",
+ "private": true,
+ "browser": "src/index.tsx",
+ "scripts": {
+ "build": "cross-env DISTRIBUTION=mobile yarn workspace @affine/cli build",
+ "dev": "yarn workspace @affine/cli dev"
+ },
+ "dependencies": {
+ "@affine/component": "workspace:*",
+ "@affine/core": "workspace:*",
+ "@affine/env": "workspace:*",
+ "@sentry/react": "^8.0.0",
+ "core-js": "^3.36.1",
+ "intl-segmenter-polyfill-rs": "^0.1.7",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@affine/cli": "workspace:*",
+ "@types/react": "^18.2.75",
+ "@types/react-dom": "^18.2.24",
+ "cross-env": "^7.0.3",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/packages/frontend/mobile/project.json b/packages/frontend/mobile/project.json
new file mode 100644
index 0000000000..ca8ed34e3b
--- /dev/null
+++ b/packages/frontend/mobile/project.json
@@ -0,0 +1,69 @@
+{
+ "name": "@affine/mobile",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "targets": {
+ "build": {
+ "executor": "nx:run-script",
+ "dependsOn": [
+ {
+ "projects": ["tag:infra"],
+ "target": "build",
+ "params": "ignore"
+ },
+ "^build"
+ ],
+ "inputs": [
+ "{projectRoot}/**/*",
+ "{workspaceRoot}/tools/**/*",
+ "{workspaceRoot}/packages/frontend/core/**/*",
+ "{workspaceRoot}/packages/**/*",
+ {
+ "env": "BUILD_TYPE"
+ },
+ {
+ "env": "BUILD_TYPE_OVERRIDE"
+ },
+ {
+ "env": "PERFSEE_TOKEN"
+ },
+ {
+ "env": "SENTRY_ORG"
+ },
+ {
+ "env": "SENTRY_PROJECT"
+ },
+ {
+ "env": "SENTRY_AUTH_TOKEN"
+ },
+ {
+ "env": "SENTRY_DSN"
+ },
+ {
+ "env": "DISTRIBUTION"
+ },
+ {
+ "env": "COVERAGE"
+ },
+ {
+ "env": "DISABLE_DEV_OVERLAY"
+ },
+ {
+ "env": "CAPTCHA_SITE_KEY"
+ },
+ {
+ "env": "R2_ACCOUNT_ID"
+ },
+ {
+ "env": "R2_ACCESS_KEY_ID"
+ },
+ {
+ "env": "R2_SECRET_ACCESS_KEY"
+ }
+ ],
+ "options": {
+ "script": "build"
+ },
+ "outputs": ["{projectRoot}/dist"]
+ }
+ }
+}
diff --git a/packages/frontend/mobile/src/app.tsx b/packages/frontend/mobile/src/app.tsx
new file mode 100644
index 0000000000..8e46841299
--- /dev/null
+++ b/packages/frontend/mobile/src/app.tsx
@@ -0,0 +1,91 @@
+import '@affine/component/theme/global.css';
+import '@affine/component/theme/theme.css';
+
+import { NotificationCenter } from '@affine/component';
+import { AffineContext } from '@affine/component/context';
+import { AppFallback } from '@affine/core/components/affine/app-container';
+import { configureCommonModules } from '@affine/core/modules';
+import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage';
+import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench';
+import {
+ configureBrowserWorkspaceFlavours,
+ configureIndexedDBWorkspaceEngineStorageProvider,
+} from '@affine/core/modules/workspace-engine';
+import {
+ performanceLogger,
+ performanceRenderLogger,
+} from '@affine/core/shared';
+import { Telemetry } from '@affine/core/telemetry';
+import { createI18n, setUpLanguage } from '@affine/i18n';
+import {
+ Framework,
+ FrameworkRoot,
+ getCurrentStore,
+ LifecycleService,
+} from '@toeverything/infra';
+import { Suspense } from 'react';
+import { RouterProvider } from 'react-router-dom';
+
+import { router } from './router';
+
+if (!environment.isBrowser && environment.isDebug) {
+ document.body.innerHTML = `Don't run web entry in electron.
`;
+ throw new Error('Wrong distribution');
+}
+
+const future = {
+ v7_startTransition: true,
+} as const;
+
+const performanceI18nLogger = performanceLogger.namespace('i18n');
+
+async function loadLanguage() {
+ performanceI18nLogger.info('start');
+
+ const i18n = createI18n();
+ document.documentElement.lang = i18n.language;
+
+ performanceI18nLogger.info('set up');
+ await setUpLanguage(i18n);
+ performanceI18nLogger.info('done');
+}
+
+let languageLoadingPromise: Promise | null = null;
+
+const framework = new Framework();
+configureCommonModules(framework);
+configureBrowserWorkbenchModule(framework);
+configureLocalStorageStateStorageImpls(framework);
+configureBrowserWorkspaceFlavours(framework);
+configureIndexedDBWorkspaceEngineStorageProvider(framework);
+const frameworkProvider = framework.provider();
+
+// setup application lifecycle events, and emit application start event
+window.addEventListener('focus', () => {
+ frameworkProvider.get(LifecycleService).applicationFocus();
+});
+frameworkProvider.get(LifecycleService).applicationStart();
+
+export function App() {
+ performanceRenderLogger.debug('App');
+
+ if (!languageLoadingPromise) {
+ languageLoadingPromise = loadLanguage().catch(console.error);
+ }
+
+ return (
+
+
+
+
+
+ }
+ router={router}
+ future={future}
+ />
+
+
+
+ );
+}
diff --git a/packages/frontend/mobile/src/index.tsx b/packages/frontend/mobile/src/index.tsx
new file mode 100644
index 0000000000..eeda0a5def
--- /dev/null
+++ b/packages/frontend/mobile/src/index.tsx
@@ -0,0 +1,76 @@
+import './polyfill/dispose';
+import './polyfill/intl-segmenter';
+import './polyfill/promise-with-resolvers';
+import './polyfill/request-idle-callback';
+import '@affine/core/bootstrap/preload';
+
+import { performanceLogger } from '@affine/core/shared';
+import { isDesktop } from '@affine/env/constant';
+import {
+ init,
+ reactRouterV6BrowserTracingIntegration,
+ setTags,
+} from '@sentry/react';
+import { StrictMode, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import {
+ createRoutesFromChildren,
+ matchRoutes,
+ useLocation,
+ useNavigationType,
+} from 'react-router-dom';
+
+import { App } from './app';
+
+const performanceMainLogger = performanceLogger.namespace('main');
+function main() {
+ performanceMainLogger.info('start');
+
+ // skip bootstrap setup for desktop onboarding
+ if (isDesktop && window.appInfo?.windowName === 'onboarding') {
+ performanceMainLogger.info('skip setup');
+ } else {
+ performanceMainLogger.info('setup start');
+ if (window.SENTRY_RELEASE || environment.isDebug) {
+ // https://docs.sentry.io/platforms/javascript/guides/react/#configure
+ init({
+ dsn: process.env.SENTRY_DSN,
+ environment: process.env.BUILD_TYPE ?? 'development',
+ integrations: [
+ reactRouterV6BrowserTracingIntegration({
+ useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ }),
+ ],
+ });
+ setTags({
+ appVersion: runtimeConfig.appVersion,
+ editorVersion: runtimeConfig.editorVersion,
+ });
+ }
+ performanceMainLogger.info('setup done');
+ }
+
+ mountApp();
+}
+
+function mountApp() {
+ performanceMainLogger.info('import app');
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const root = document.getElementById('app')!;
+ performanceMainLogger.info('render app');
+ createRoot(root).render(
+
+
+
+ );
+}
+
+try {
+ main();
+} catch (err) {
+ console.error('Failed to bootstrap app', err);
+}
diff --git a/packages/frontend/mobile/src/pages/404.tsx b/packages/frontend/mobile/src/pages/404.tsx
new file mode 100644
index 0000000000..df4674933c
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/404.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /404
;
+};
diff --git a/packages/frontend/mobile/src/pages/auth.tsx b/packages/frontend/mobile/src/pages/auth.tsx
new file mode 100644
index 0000000000..4b4d5d0eb0
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/auth.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /auth/*
;
+};
diff --git a/packages/frontend/mobile/src/pages/index.tsx b/packages/frontend/mobile/src/pages/index.tsx
new file mode 100644
index 0000000000..508c375741
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/index.tsx
@@ -0,0 +1,8 @@
+import { Component as IndexComponent } from '@affine/core/pages/index';
+
+// Default route fallback for mobile
+
+export const Component = () => {
+ // TODO: replace with a mobile version
+ return ;
+};
diff --git a/packages/frontend/mobile/src/pages/sign-in.tsx b/packages/frontend/mobile/src/pages/sign-in.tsx
new file mode 100644
index 0000000000..3687f114f1
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/sign-in.tsx
@@ -0,0 +1,7 @@
+// Default route fallback for mobile
+import { SignIn } from '@affine/core/pages/sign-in';
+
+export const Component = () => {
+ // placeholder impl
+ return ;
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/all.tsx b/packages/frontend/mobile/src/pages/workspace/all.tsx
new file mode 100644
index 0000000000..e2ec20a6b9
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/all.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /workspace/:workspaceId/all
;
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/collection/detail.tsx b/packages/frontend/mobile/src/pages/workspace/collection/detail.tsx
new file mode 100644
index 0000000000..a098207e75
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/collection/detail.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /workspace/:workspaceId/collection/:collectionId
;
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/collection/index.tsx b/packages/frontend/mobile/src/pages/workspace/collection/index.tsx
new file mode 100644
index 0000000000..bdc53f76d7
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/collection/index.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /workspace/:workspaceId/collection
;
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/detail.tsx b/packages/frontend/mobile/src/pages/workspace/detail.tsx
new file mode 100644
index 0000000000..f6c32eba56
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/detail.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /workspace/:workspaceId/:pageId
;
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/index.tsx b/packages/frontend/mobile/src/pages/workspace/index.tsx
new file mode 100644
index 0000000000..8319186640
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/index.tsx
@@ -0,0 +1,112 @@
+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 {
+ useLiveData,
+ useServices,
+ WorkspacesService,
+} from '@toeverything/infra';
+import { lazy as reactLazy, useEffect, useMemo, useState } from 'react';
+import { matchPath, useLocation, useParams } from 'react-router-dom';
+
+import { viewRoutes } from '../../router';
+import { WorkspaceLayout } from './layout';
+
+const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
+ const Component = reactLazy(() =>
+ lazy().then(m => ({
+ default: m.Component as React.ComponentType,
+ }))
+ );
+ const route = {
+ Component,
+ };
+
+ return {
+ path,
+ Component: () => {
+ return ;
+ },
+ };
+});
+
+export const Component = () => {
+ const { workspacesService } = useServices({
+ WorkspacesService,
+ });
+
+ const params = useParams();
+ const location = useLocation();
+
+ // todo(pengx17): dedupe the code with core
+ // check if we are in detail doc route, if so, maybe render share page
+ const detailDocRoute = useMemo(() => {
+ const match = matchPath(
+ '/workspace/:workspaceId/:docId',
+ location.pathname
+ );
+ if (
+ match &&
+ match.params.docId &&
+ match.params.workspaceId &&
+ // TODO(eyhn): need a better way to check if it's a docId
+ viewRoutes.find(route => matchPath(route.path, '/' + match.params.docId))
+ ?.path === '/:pageId'
+ ) {
+ return {
+ docId: match.params.docId,
+ workspaceId: match.params.workspaceId,
+ };
+ } else {
+ return null;
+ }
+ }, [location.pathname]);
+
+ const [workspaceNotFound, setWorkspaceNotFound] = useState(false);
+ const listLoading = useLiveData(workspacesService.list.isRevalidating$);
+ const workspaces = useLiveData(workspacesService.list.workspaces$);
+ const meta = useMemo(() => {
+ return workspaces.find(({ id }) => id === params.workspaceId);
+ }, [workspaces, params.workspaceId]);
+
+ // if listLoading is false, we can show 404 page, otherwise we should show loading page.
+ useEffect(() => {
+ if (listLoading === false && meta === undefined) {
+ setWorkspaceNotFound(true);
+ }
+ if (meta) {
+ setWorkspaceNotFound(false);
+ }
+ }, [listLoading, meta, workspacesService]);
+
+ // if workspace is not found, we should revalidate in interval
+ useEffect(() => {
+ if (listLoading === false && meta === undefined) {
+ const timer = setInterval(
+ () => workspacesService.list.revalidate(),
+ 5000
+ );
+ return () => clearInterval(timer);
+ }
+ return;
+ }, [listLoading, meta, workspaceNotFound, workspacesService]);
+
+ if (workspaceNotFound) {
+ if (
+ detailDocRoute /* */ &&
+ environment.isBrowser /* only browser has share page */
+ ) {
+ return TODO: share page
;
+ }
+ return ;
+ }
+ if (!meta) {
+ return ;
+ }
+ return (
+
+
+
+ );
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/layout.tsx b/packages/frontend/mobile/src/pages/workspace/layout.tsx
new file mode 100644
index 0000000000..5a29217f50
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/layout.tsx
@@ -0,0 +1,92 @@
+import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
+import { AppFallback } from '@affine/core/components/affine/app-container';
+import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout';
+import {
+ AllWorkspaceModals,
+ CurrentWorkspaceModals,
+} from '@affine/core/providers/modal-provider';
+import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider';
+import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
+import {
+ FrameworkScope,
+ GlobalContextService,
+ useLiveData,
+ useServices,
+ WorkspacesService,
+} from '@toeverything/infra';
+import {
+ type PropsWithChildren,
+ useEffect,
+ useLayoutEffect,
+ useState,
+} from 'react';
+
+export const WorkspaceLayout = ({
+ meta,
+ children,
+}: PropsWithChildren<{ meta: WorkspaceMetadata }>) => {
+ // todo: reduce code duplication with packages\frontend\core\src\pages\workspace\index.tsx
+ const { workspacesService, globalContextService } = useServices({
+ WorkspacesService,
+ GlobalContextService,
+ });
+
+ const [workspace, setWorkspace] = useState(null);
+
+ useLayoutEffect(() => {
+ const ref = workspacesService.open({ metadata: meta });
+ setWorkspace(ref.workspace);
+ return () => {
+ ref.dispose();
+ };
+ }, [meta, workspacesService]);
+
+ useEffect(() => {
+ if (workspace) {
+ // for debug purpose
+ window.currentWorkspace = workspace ?? undefined;
+ window.dispatchEvent(
+ new CustomEvent('affine:workspace:change', {
+ detail: {
+ id: workspace.id,
+ },
+ })
+ );
+ localStorage.setItem('last_workspace_id', workspace.id);
+ globalContextService.globalContext.workspaceId.set(workspace.id);
+ return () => {
+ window.currentWorkspace = undefined;
+ globalContextService.globalContext.workspaceId.set(null);
+ };
+ }
+ return;
+ }, [globalContextService, workspace]);
+
+ const isRootDocReady =
+ useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
+
+ if (!workspace) {
+ return null; // skip this, workspace will be set in layout effect
+ }
+
+ if (!isRootDocReady) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/tag/detail.tsx b/packages/frontend/mobile/src/pages/workspace/tag/detail.tsx
new file mode 100644
index 0000000000..2aa1bda3a6
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/tag/detail.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /workspace/:workspaceId/tag/:tagId
;
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/tag/index.tsx b/packages/frontend/mobile/src/pages/workspace/tag/index.tsx
new file mode 100644
index 0000000000..9433819f81
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/tag/index.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /workspace/:workspaceId/tag
;
+};
diff --git a/packages/frontend/mobile/src/pages/workspace/trash.tsx b/packages/frontend/mobile/src/pages/workspace/trash.tsx
new file mode 100644
index 0000000000..86415ad779
--- /dev/null
+++ b/packages/frontend/mobile/src/pages/workspace/trash.tsx
@@ -0,0 +1,3 @@
+export const Component = () => {
+ return /workspace/:workspaceId/trash
;
+};
diff --git a/packages/frontend/mobile/src/polyfill/dispose.ts b/packages/frontend/mobile/src/polyfill/dispose.ts
new file mode 100644
index 0000000000..615ed233c7
--- /dev/null
+++ b/packages/frontend/mobile/src/polyfill/dispose.ts
@@ -0,0 +1,2 @@
+import 'core-js/modules/esnext.symbol.async-dispose';
+import 'core-js/modules/esnext.symbol.dispose';
diff --git a/packages/frontend/mobile/src/polyfill/intl-segmenter.ts b/packages/frontend/mobile/src/polyfill/intl-segmenter.ts
new file mode 100644
index 0000000000..f493cd793e
--- /dev/null
+++ b/packages/frontend/mobile/src/polyfill/intl-segmenter.ts
@@ -0,0 +1,11 @@
+if (Intl.Segmenter === undefined) {
+ await import('intl-segmenter-polyfill-rs').then(({ Segmenter }) => {
+ Object.defineProperty(Intl, 'Segmenter', {
+ value: Segmenter,
+ configurable: true,
+ writable: true,
+ });
+ });
+}
+
+export {};
diff --git a/packages/frontend/mobile/src/polyfill/promise-with-resolvers.ts b/packages/frontend/mobile/src/polyfill/promise-with-resolvers.ts
new file mode 100644
index 0000000000..6d806c3a08
--- /dev/null
+++ b/packages/frontend/mobile/src/polyfill/promise-with-resolvers.ts
@@ -0,0 +1 @@
+import 'core-js/features/promise/with-resolvers';
diff --git a/packages/frontend/mobile/src/polyfill/request-idle-callback.ts b/packages/frontend/mobile/src/polyfill/request-idle-callback.ts
new file mode 100644
index 0000000000..e3156d20df
--- /dev/null
+++ b/packages/frontend/mobile/src/polyfill/request-idle-callback.ts
@@ -0,0 +1,19 @@
+window.requestIdleCallback =
+ window.requestIdleCallback ||
+ function (cb) {
+ const start = Date.now();
+ return setTimeout(function () {
+ cb({
+ didTimeout: false,
+ timeRemaining: function () {
+ return Math.max(0, 50 - (Date.now() - start));
+ },
+ });
+ }, 1);
+ };
+
+window.cancelIdleCallback =
+ window.cancelIdleCallback ||
+ function (id) {
+ clearTimeout(id);
+ };
diff --git a/packages/frontend/mobile/src/polyfill/set-immediate.ts b/packages/frontend/mobile/src/polyfill/set-immediate.ts
new file mode 100644
index 0000000000..b8f042569a
--- /dev/null
+++ b/packages/frontend/mobile/src/polyfill/set-immediate.ts
@@ -0,0 +1 @@
+import 'setimmediate';
diff --git a/packages/frontend/mobile/src/router.tsx b/packages/frontend/mobile/src/router.tsx
new file mode 100644
index 0000000000..11f3130d01
--- /dev/null
+++ b/packages/frontend/mobile/src/router.tsx
@@ -0,0 +1,95 @@
+import { RootRouter } from '@affine/core/router';
+import { wrapCreateBrowserRouter } from '@sentry/react';
+import type { RouteObject } from 'react-router-dom';
+import {
+ createBrowserRouter as reactRouterCreateBrowserRouter,
+ redirect,
+} from 'react-router-dom';
+
+export const topLevelRoutes = [
+ {
+ element: ,
+ children: [
+ {
+ path: '/',
+ lazy: () => import('./pages/index'),
+ },
+ {
+ path: '/workspace/:workspaceId/*',
+ lazy: () => import('./pages/workspace/index'),
+ },
+ {
+ path: '/share/:workspaceId/:pageId',
+ loader: ({ params }) => {
+ return redirect(`/workspace/${params.workspaceId}/${params.pageId}`);
+ },
+ },
+ {
+ path: '/404',
+ lazy: () => import('./pages/404'),
+ },
+ {
+ path: '/auth/:authType',
+ lazy: () => import('./pages/auth'),
+ },
+ {
+ path: '/sign-in',
+ lazy: () => import('./pages/sign-in'),
+ },
+ {
+ path: '/redirect-proxy',
+ lazy: () => import('@affine/core/pages/redirect'),
+ },
+ {
+ path: '*',
+ lazy: () => import('./pages/404'),
+ },
+ ],
+ },
+] satisfies [RouteObject, ...RouteObject[]];
+
+export const viewRoutes = [
+ {
+ path: '/all',
+ lazy: () => import('./pages/workspace/all'),
+ },
+ {
+ path: '/collection',
+ lazy: () => import('./pages/workspace/collection/index'),
+ },
+ {
+ path: '/collection/:collectionId',
+ lazy: () => import('./pages/workspace/collection/detail'),
+ },
+ {
+ path: '/tag',
+ lazy: () => import('./pages/workspace/tag/index'),
+ },
+ {
+ path: '/tag/:tagId',
+ lazy: () => import('./pages/workspace/tag/detail'),
+ },
+ {
+ path: '/trash',
+ lazy: () => import('./pages/workspace/trash'),
+ },
+ {
+ path: '/:pageId',
+ lazy: () => import('./pages/workspace/detail'),
+ },
+ {
+ path: '*',
+ lazy: () => import('./pages/404'),
+ },
+] satisfies [RouteObject, ...RouteObject[]];
+
+const createBrowserRouter = wrapCreateBrowserRouter(
+ reactRouterCreateBrowserRouter
+);
+export const router = (
+ window.SENTRY_RELEASE ? createBrowserRouter : reactRouterCreateBrowserRouter
+)(topLevelRoutes, {
+ future: {
+ v7_normalizeFormMethod: true,
+ },
+});
diff --git a/packages/frontend/mobile/tsconfig.json b/packages/frontend/mobile/tsconfig.json
new file mode 100644
index 0000000000..af59eddffb
--- /dev/null
+++ b/packages/frontend/mobile/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "lib",
+ "moduleResolution": "Bundler",
+ "types": ["affine__env"],
+ "rootDir": "./src"
+ },
+ "include": ["./src"],
+ "references": [{ "path": "../core" }]
+}
diff --git a/tools/cli/src/bin/dev.ts b/tools/cli/src/bin/dev.ts
index 12895cab50..e68726740c 100644
--- a/tools/cli/src/bin/dev.ts
+++ b/tools/cli/src/bin/dev.ts
@@ -49,6 +49,9 @@ const buildFlags = process.argv.includes('--static')
{
value: 'admin',
},
+ {
+ value: 'mobile',
+ },
],
initialValue: 'browser',
}),
diff --git a/tools/cli/src/config/cwd.cjs b/tools/cli/src/config/cwd.cjs
index dbffed6657..13e44913a6 100644
--- a/tools/cli/src/config/cwd.cjs
+++ b/tools/cli/src/config/cwd.cjs
@@ -23,6 +23,8 @@ module.exports.getCwdFromDistribution = function getCwdFromDistribution(
return join(projectRoot, 'packages/frontend/electron/renderer');
case 'admin':
return join(projectRoot, 'packages/frontend/admin');
+ case 'mobile':
+ return join(projectRoot, 'packages/frontend/mobile');
default: {
throw new Error('DISTRIBUTION must be one of browser, desktop');
}
diff --git a/tools/cli/src/config/index.ts b/tools/cli/src/config/index.ts
index 0035fb39da..534bbdb9f0 100644
--- a/tools/cli/src/config/index.ts
+++ b/tools/cli/src/config/index.ts
@@ -1,5 +1,5 @@
export type BuildFlags = {
- distribution: 'browser' | 'desktop' | 'admin';
+ distribution: 'browser' | 'desktop' | 'admin' | 'mobile';
mode: 'development' | 'production';
channel: 'stable' | 'beta' | 'canary' | 'internal';
coverage?: boolean;
diff --git a/yarn.lock b/yarn.lock
index 437951cb5b..1a5e0d6038 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -637,6 +637,26 @@ __metadata:
languageName: unknown
linkType: soft
+"@affine/mobile@workspace:packages/frontend/mobile":
+ version: 0.0.0-use.local
+ resolution: "@affine/mobile@workspace:packages/frontend/mobile"
+ dependencies:
+ "@affine/cli": "workspace:*"
+ "@affine/component": "workspace:*"
+ "@affine/core": "workspace:*"
+ "@affine/env": "workspace:*"
+ "@sentry/react": "npm:^8.0.0"
+ "@types/react": "npm:^18.2.75"
+ "@types/react-dom": "npm:^18.2.24"
+ 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"
+ typescript: "npm:^5.4.5"
+ languageName: unknown
+ linkType: soft
+
"@affine/monorepo@workspace:.":
version: 0.0.0-use.local
resolution: "@affine/monorepo@workspace:."