Browse Source

feat: support navigator and trash page

* refactor: navigator

* feat: support trash
Kilu.He 1 year ago
parent
commit
c65584d23c
100 changed files with 2610 additions and 1587 deletions
  1. 2 0
      frontend/appflowy_tauri/package.json
  2. 6 1
      frontend/appflowy_tauri/pnpm-lock.yaml
  3. 2 1
      frontend/appflowy_tauri/scripts/i18n/index.cjs
  4. 2 0
      frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx
  5. 56 0
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/ButtonPopoverList/index.tsx
  6. 6 8
      frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx
  7. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts
  8. 4 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  9. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/grid/Grid/Grid.tsx
  10. 0 39
      frontend/appflowy_tauri/src/appflowy_app/components/layout/AppLogo.tsx
  11. 70 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts
  12. 43 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx
  13. 22 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/CollapseMenuButton/index.tsx
  14. 0 61
      frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/Breadcrumbs.tsx
  15. 0 11
      frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/HeaderPanel.tsx
  16. 0 45
      frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/MoreMenu.tsx
  17. 0 20
      frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/PageOptions.hooks.ts
  18. 0 73
      frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/PageOptions.tsx
  19. 47 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx
  20. 0 42
      frontend/appflowy_tauri/src/appflowy_app/components/layout/MainPanel.tsx
  21. 0 84
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/MoreMenu.tsx
  22. 0 200
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.hooks.ts
  23. 0 124
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.tsx
  24. 0 22
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.hooks.ts
  25. 0 131
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx
  26. 0 26
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationResizer.tsx
  27. 0 67
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewPageMenu.tsx
  28. 0 45
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.hooks.ts
  29. 0 23
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.tsx
  30. 0 8
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PluginsButton.tsx
  31. 0 13
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/TrashButton.tsx
  32. 77 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/AddButton.tsx
  33. 64 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/DeleteDialog.tsx
  34. 112 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/MoreButton.tsx
  35. 146 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts
  36. 75 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx
  37. 9 3
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/RenameDialog.tsx
  38. 39 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx
  39. 0 21
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Screen.tsx
  40. 12 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/Share.hooks.ts
  41. 14 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/index.tsx
  42. 54 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/Resizer.tsx
  43. 12 10
      frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/UserInfo.tsx
  44. 47 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/index.tsx
  45. 26 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/FontSizeConfig.tsx
  46. 33 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreButton.tsx
  47. 29 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.hooks.ts
  48. 15 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.tsx
  49. 34 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/index.tsx
  50. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/SettingPanel.tsx
  51. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx
  52. 0 69
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Workspace.hooks.ts
  53. 64 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/MoreButton.tsx
  54. 20 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx
  55. 44 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx
  56. 32 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx
  57. 116 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts
  58. 26 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx
  59. 38 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/WorkspaceTitle.tsx
  60. 26 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx
  61. 27 10
      frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts
  62. 5 9
      frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts
  63. 14 9
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx
  64. 44 0
      frontend/appflowy_tauri/src/appflowy_app/components/trash/ConfirmDialog.tsx
  65. 82 0
      frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts
  66. 78 0
      frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx
  67. 64 0
      frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx
  68. 8 0
      frontend/appflowy_tauri/src/appflowy_app/constants/index.ts
  69. 0 49
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_bd_svc.ts
  70. 0 101
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_observer.ts
  71. 0 71
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_bd_svc.ts
  72. 0 57
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_observer.ts
  73. 6 5
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/notifications/observer.ts
  74. 4 3
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/notifications/parser.ts
  75. 83 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts
  76. 112 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts
  77. 44 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/bd_svc.ts
  78. 74 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/controller.ts
  79. 61 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_bd_svc.ts
  80. 99 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts
  81. 72 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_manager_controller.ts
  82. 99 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_observer.ts
  83. 0 12
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/active-page-id/slice.ts
  84. 0 33
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/folders/slice.ts
  85. 0 17
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/navigation-width/slice.ts
  86. 67 28
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts
  87. 34 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts
  88. 49 5
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts
  89. 2 4
      frontend/appflowy_tauri/src/appflowy_app/stores/store.ts
  90. 2 1
      frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts
  91. 5 4
      frontend/appflowy_tauri/src/appflowy_app/views/BoardPage.tsx
  92. 12 0
      frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx
  93. 11 3
      frontend/appflowy_tauri/src/styles/mui.css
  94. 1 1
      frontend/appflowy_tauri/src/styles/variables/dark.variables.css
  95. 2 2
      frontend/appflowy_tauri/src/styles/variables/light.variables.css
  96. 1 1
      frontend/appflowy_tauri/style-dictionary/config.cjs
  97. 1 1
      frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs
  98. 1 1
      frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs
  99. 1 1
      frontend/appflowy_tauri/tailwind.config.cjs
  100. 15 2
      frontend/resources/translations/en.json

+ 2 - 0
frontend/appflowy_tauri/package.json

@@ -52,6 +52,7 @@
     "react-katex": "^3.0.1",
     "react-redux": "^8.0.5",
     "react-router-dom": "^6.8.0",
+    "react-transition-group": "^4.4.5",
     "react18-input-otp": "^1.1.2",
     "redux": "^4.2.1",
     "rxjs": "^7.8.0",
@@ -73,6 +74,7 @@
     "@types/react-beautiful-dnd": "^13.1.3",
     "@types/react-dom": "^18.0.6",
     "@types/react-katex": "^3.0.0",
+    "@types/react-transition-group": "^4.4.6",
     "@types/utf8": "^3.0.1",
     "@types/uuid": "^9.0.1",
     "@typescript-eslint/eslint-plugin": "^5.51.0",

+ 6 - 1
frontend/appflowy_tauri/pnpm-lock.yaml

@@ -106,6 +106,9 @@ dependencies:
   react-router-dom:
     specifier: ^6.8.0
     version: 6.11.1([email protected])([email protected])
+  react-transition-group:
+    specifier: ^4.4.5
+    version: 4.4.5([email protected])([email protected])
   react18-input-otp:
     specifier: ^1.1.2
     version: 1.1.3([email protected])([email protected])
@@ -165,6 +168,9 @@ devDependencies:
   '@types/react-katex':
     specifier: ^3.0.0
     version: 3.0.0
+  '@types/react-transition-group':
+    specifier: ^4.4.6
+    version: 4.4.6
   '@types/utf8':
     specifier: ^3.0.1
     version: 3.0.1
@@ -1725,7 +1731,6 @@ packages:
     resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==}
     dependencies:
       '@types/react': 18.2.6
-    dev: false
 
   /@types/[email protected]:
     resolution: {integrity: sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==}

+ 2 - 1
frontend/appflowy_tauri/scripts/i18n/index.cjs

@@ -44,7 +44,8 @@ function flattenJSON(obj, prefix = '') {
             const nestedKeys = flattenJSON(obj[key], `${prefix}${key}.`);
             result = { ...result, ...nestedKeys };
         } else {
-            result[`${prefix}${key}`] = obj[key];
+
+            result[`${prefix}${key}`] = obj[key].replaceAll('{', '{{').replaceAll('}', '}}');
         }
     }
 

+ 2 - 0
frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx

@@ -14,6 +14,7 @@ import { ConfirmAccountPage } from '$app/views/ConfirmAccountPage';
 import { ThemeProvider } from '@mui/material';
 import { useUserSetting } from '$app/AppMain.hooks';
 import { UserSettingControllerContext } from '$app/components/_shared/app-hooks/useUserSettingControllerContext';
+import TrashPage from '$app/views/TrashPage';
 
 function AppMain() {
   const { muiTheme, userSettingController } = useUserSetting();
@@ -29,6 +30,7 @@ function AppMain() {
             <Route path={'/page/document/:id'} element={<DocumentPage />} />
             <Route path={'/page/board/:id'} element={<BoardPage />} />
             <Route path={'/page/grid/:id'} element={<GridPage />} />
+            <Route path={'/trash'} id={'trash'} element={<TrashPage />} />
           </Route>
           <Route path={'/auth/login'} element={<LoginPage />}></Route>
           <Route path={'/auth/getStarted'} element={<GetStarted />}></Route>

+ 56 - 0
frontend/appflowy_tauri/src/appflowy_app/components/_shared/ButtonPopoverList/index.tsx

@@ -0,0 +1,56 @@
+import React, { useCallback, useState } from 'react';
+import { List, MenuItem, Popover, Portal } from '@mui/material';
+import { PopoverOrigin } from '@mui/material/Popover/Popover';
+
+interface ButtonPopoverListProps {
+  isVisible: boolean;
+  children: React.ReactNode;
+  popoverOptions: {
+    key: string;
+    icon: React.ReactNode;
+    label: React.ReactNode | string;
+    onClick: () => void;
+  }[];
+  popoverOrigin: {
+    anchorOrigin: PopoverOrigin;
+    transformOrigin: PopoverOrigin;
+  };
+}
+function ButtonPopoverList({ popoverOrigin, isVisible, children, popoverOptions }: ButtonPopoverListProps) {
+  const [anchorEl, setAnchorEl] = useState<HTMLDivElement>();
+  const open = Boolean(anchorEl);
+  const visible = isVisible || open;
+  const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
+    setAnchorEl(event.currentTarget);
+  };
+
+  const handleClose = useCallback(() => {
+    setAnchorEl(undefined);
+  }, []);
+
+  return (
+    <>
+      {visible && <div onClick={handleClick}>{children}</div>}
+      <Portal>
+        <Popover open={open} {...popoverOrigin} anchorEl={anchorEl} onClose={handleClose}>
+          <List>
+            {popoverOptions.map((option) => (
+              <MenuItem
+                key={option.key}
+                onClick={() => {
+                  option.onClick();
+                  handleClose();
+                }}
+              >
+                <span className={'mr-2'}>{option.icon}</span>
+                <span>{option.label}</span>
+              </MenuItem>
+            ))}
+          </List>
+        </Popover>
+      </Portal>
+    </>
+  );
+}
+
+export default ButtonPopoverList;

+ 6 - 8
frontend/appflowy_tauri/src/appflowy_app/components/auth/ProtectedRoutes.tsx

@@ -1,11 +1,10 @@
 import { Outlet } from 'react-router-dom';
 import { useAuth } from './auth.hooks';
-import { Screen } from '../layout/Screen';
+import Layout from '$app/components/layout/Layout';
 import { useEffect, useState } from 'react';
 import { GetStarted } from './GetStarted/GetStarted';
 import { AppflowyLogo } from '../_shared/svg/AppflowyLogo';
 
-
 export const ProtectedRoutes = () => {
   const { currentUser, checkUser } = useAuth();
   const [isLoading, setIsLoading] = useState(true);
@@ -13,15 +12,14 @@ export const ProtectedRoutes = () => {
   useEffect(() => {
     void checkUser().then(async (result) => {
       await new Promise(() =>
-          setTimeout(() => {
-            setIsLoading(false);
-          }, 1200)
+        setTimeout(() => {
+          setIsLoading(false);
+        }, 1200)
       );
 
       if (result.err) {
         throw new Error(result.val.msg);
       }
-
     });
   }, []);
 
@@ -46,9 +44,9 @@ const StartLoading = () => {
 const SplashScreen = ({ isAuthenticated }: { isAuthenticated: boolean }) => {
   if (isAuthenticated) {
     return (
-      <Screen>
+      <Layout>
         <Outlet />
-      </Screen>
+      </Layout>
     );
   } else {
     return <GetStarted></GetStarted>;

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/auth/auth.hooks.ts

@@ -46,7 +46,7 @@ export const useAuth = () => {
     if (authResult.ok) {
       const userProfile = authResult.val;
       // Get the workspace setting after user registered. The workspace setting
-      // contains the latest visiting view and the current workspace data.
+      // contains the latest visiting page and the current workspace data.
       const openWorkspaceResult = await _openWorkspace();
 
       if (openWorkspaceResult.ok) {

+ 4 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx

@@ -6,9 +6,9 @@ import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 
 const headingBlockTopOffset: Record<number, number> = {
-  1: 7,
-  2: 5,
-  3: 4,
+  1: 6,
+  2: 4,
+  3: 3,
 };
 
 export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
@@ -32,7 +32,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
         });
         return;
       } else {
-        let top = 2;
+        let top = 0;
 
         if (node.type === BlockType.HeadingBlock) {
           const nodeData = node.data as HeadingBlockData;

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/grid/Grid/Grid.tsx

@@ -30,7 +30,7 @@ export const Grid = ({ viewId }: { viewId: string }) => {
               <GridToolbar />
             </div>
 
-            {/* table component view with text area for td */}
+            {/* table component page with text area for td */}
             <div className='flex flex-col gap-4'>
               <table className='w-full table-fixed text-sm'>
                 <GridTableHeader controller={controller} />

+ 0 - 39
frontend/appflowy_tauri/src/appflowy_app/components/layout/AppLogo.tsx

@@ -1,39 +0,0 @@
-import { HideMenuSvg } from '../_shared/svg/HideMenuSvg';
-import { ShowMenuSvg } from '../_shared/svg/ShowMenuSvg';
-import { useAppSelector } from '$app/stores/store';
-import { ThemeMode } from '$app/interfaces';
-import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight';
-import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark';
-
-export const AppLogo = ({
-  iconToShow,
-  onHideMenuClick,
-  onShowMenuClick,
-}: {
-  iconToShow: 'hide' | 'show';
-  onHideMenuClick?: () => void;
-  onShowMenuClick?: () => void;
-}) => {
-  const isDark = useAppSelector((state) => state.currentUser?.userSetting?.themeMode === ThemeMode.Dark);
-
-  return (
-    <div className={'mb-2 flex h-[60px] items-center justify-between px-6 text-text-title'}>
-      {isDark ? <AppflowyLogoDark /> : <AppflowyLogoLight />}
-
-      {iconToShow === 'hide' && (
-        <button onClick={onHideMenuClick} className={'h-5 w-5'}>
-          <i>
-            <HideMenuSvg></HideMenuSvg>
-          </i>
-        </button>
-      )}
-      {iconToShow === 'show' && (
-        <button onClick={onShowMenuClick} className={'h-5 w-5 text-text-title'}>
-          <i>
-            <ShowMenuSvg></ShowMenuSvg>
-          </i>
-        </button>
-      )}
-    </div>
-  );
-};

+ 70 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts

@@ -0,0 +1,70 @@
+import { useAppDispatch } from '$app/stores/store';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { PageController } from '$app/stores/effects/workspace/page/page_controller';
+import { useParams, useLocation } from 'react-router-dom';
+import { Page, pagesActions } from '$app_reducers/pages/slice';
+import { Log } from '$app/utils/log';
+import { useTranslation } from 'react-i18next';
+
+export function useLoadExpandedPages() {
+  const dispatch = useAppDispatch();
+  const { t } = useTranslation();
+  const params = useParams();
+  const location = useLocation();
+  const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]);
+  const currentPageId = params.id;
+  const [pagePath, setPagePath] = useState<
+    (
+      | Page
+      | {
+          name: string;
+        }
+    )[]
+  >([]);
+
+  const loadPage = useCallback(
+    async (id: string) => {
+      if (!id) return;
+      const controller = new PageController(id);
+
+      try {
+        const page = await controller.getPage();
+        const childPages = await controller.getChildPages();
+
+        dispatch(pagesActions.addChildPages({ id, childPages }));
+        dispatch(pagesActions.expandPage(id));
+
+        setPagePath((prev) => [page, ...prev]);
+        await loadPage(page.parentId);
+      } catch (e) {
+        Log.info(`${id} is workspace`);
+      }
+    },
+    [dispatch]
+  );
+
+  useEffect(() => {
+    setPagePath([]);
+    if (!currentPageId) {
+      return;
+    }
+
+    void (async () => {
+      await loadPage(currentPageId);
+    })();
+  }, [currentPageId, dispatch, loadPage]);
+
+  useEffect(() => {
+    if (isTrash) {
+      setPagePath([
+        {
+          name: t('trash.text'),
+        },
+      ]);
+    }
+  }, [isTrash, t]);
+
+  return {
+    pagePath,
+  };
+}

+ 43 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx

@@ -0,0 +1,43 @@
+import React, { useCallback, useMemo } from 'react';
+import { useLoadExpandedPages } from '$app/components/layout/Breadcrumb/Breadcrumb.hooks';
+import Breadcrumbs from '@mui/material/Breadcrumbs';
+import Link from '@mui/material/Link';
+import Typography from '@mui/material/Typography';
+import { Page } from '$app_reducers/pages/slice';
+import { useNavigate } from 'react-router-dom';
+import { pageTypeMap } from '$app/constants';
+
+function Breadcrumb() {
+  const { pagePath } = useLoadExpandedPages();
+  const navigate = useNavigate();
+  const activePage = useMemo(() => pagePath[pagePath.length - 1], [pagePath]);
+  const parentPages = useMemo(() => pagePath.slice(0, pagePath.length - 1) as Page[], [pagePath]);
+  const navigateToPage = useCallback(
+    (page: Page) => {
+      const pageType = pageTypeMap[page.layout];
+
+      navigate(`/page/${pageType}/${page.id}`);
+    },
+    [navigate]
+  );
+
+  return (
+    <Breadcrumbs aria-label='breadcrumb'>
+      {parentPages?.map((page: Page) => (
+        <Link
+          key={page.id}
+          underline='hover'
+          color='inherit'
+          onClick={() => {
+            navigateToPage(page);
+          }}
+        >
+          {page.name}
+        </Link>
+      ))}
+      <Typography color='text.primary'>{activePage?.name}</Typography>
+    </Breadcrumbs>
+  );
+}
+
+export default Breadcrumb;

+ 22 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/CollapseMenuButton/index.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import { IconButton } from '@mui/material';
+import { ShowMenuSvg } from '$app/components/_shared/svg/ShowMenuSvg';
+import { HideMenuSvg } from '$app/components/_shared/svg/HideMenuSvg';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { sidebarActions } from '$app_reducers/sidebar/slice';
+
+function CollapseMenuButton() {
+  const isCollapsed = useAppSelector((state) => state.sidebar.isCollapsed);
+  const dispatch = useAppDispatch();
+  const handleClick = () => {
+    dispatch(sidebarActions.toggleCollapse());
+  };
+
+  return (
+    <IconButton className={'h-6 w-6 p-2'} size={'small'} onClick={handleClick}>
+      {isCollapsed ? <ShowMenuSvg /> : <HideMenuSvg />}
+    </IconButton>
+  );
+}
+
+export default CollapseMenuButton;

+ 0 - 61
frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/Breadcrumbs.tsx

@@ -1,61 +0,0 @@
-import { ShowMenuSvg } from '../../_shared/svg/ShowMenuSvg';
-import { useEffect, useState } from 'react';
-import { useAppSelector } from '$app/stores/store';
-import { useLocation } from 'react-router-dom';
-import { ArrowLeft, ArrowRight } from '@mui/icons-material';
-import { ArrowLeftSvg } from '$app/components/_shared/svg/ArrowLeftSvg';
-import { ArrowRightSvg } from '$app/components/_shared/svg/ArrowRightSvg';
-
-export const Breadcrumbs = ({ menuHidden, onShowMenuClick }: { menuHidden: boolean; onShowMenuClick: () => void }) => {
-  const [folderName, setFolderName] = useState('');
-  const [pageName, setPageName] = useState('');
-  const [activePageId, setActivePageId] = useState<string>('');
-  const currentLocation = useLocation();
-  const pagesStore = useAppSelector((state) => state.pages);
-
-  useEffect(() => {
-    const { pathname } = currentLocation;
-    const parts = pathname.split('/');
-    const pageId = parts[parts.length - 1];
-
-    setActivePageId(pageId);
-  }, [currentLocation]);
-
-  useEffect(() => {
-    const page = pagesStore.find((p) => p.id === activePageId);
-
-    // const folder = foldersStore.find((f) => f.id === page?.parentPageId);
-    // setFolderName(folder?.title ?? '');
-    setPageName(page?.title ?? '');
-  }, [pagesStore, activePageId]);
-
-  return (
-    <div className={'flex items-center'}>
-      <div className={'mr-4 flex items-center'}>
-        {menuHidden && (
-          <button onClick={() => onShowMenuClick()} className={'mr-2 h-5 w-5 text-text-title'}>
-            <ShowMenuSvg></ShowMenuSvg>
-          </button>
-        )}
-
-        <button
-          className={'h-6 w-6 rounded p-1 text-text-title hover:bg-fill-list-hover'}
-          onClick={() => history.back()}
-        >
-          <ArrowLeftSvg />
-        </button>
-        <button
-          className={'h-6 w-6 rounded p-1 text-text-title hover:bg-fill-list-hover'}
-          onClick={() => history.forward()}
-        >
-          <ArrowRightSvg />
-        </button>
-      </div>
-      <div className={'mr-8 flex items-center gap-4'}>
-        <span>{folderName}</span>
-        <span>/</span>
-        <span>{pageName}</span>
-      </div>
-    </div>
-  );
-};

+ 0 - 11
frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/HeaderPanel.tsx

@@ -1,11 +0,0 @@
-import { Breadcrumbs } from './Breadcrumbs';
-import { PageOptions } from './PageOptions';
-
-export const HeaderPanel = ({ menuHidden, onShowMenuClick }: { menuHidden: boolean; onShowMenuClick: () => void }) => {
-  return (
-    <div className={'flex h-[60px] items-center justify-between border-b border-line-divider px-8'}>
-      <Breadcrumbs menuHidden={menuHidden} onShowMenuClick={onShowMenuClick}></Breadcrumbs>
-      <PageOptions></PageOptions>
-    </div>
-  );
-};

+ 0 - 45
frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/MoreMenu.tsx

@@ -1,45 +0,0 @@
-import React, { useCallback, useMemo } from 'react';
-import { LogoutSvg } from '$app/components/_shared/svg/LogoutSvg';
-import { useAuth } from '$app/components/auth/auth.hooks';
-import MenuItem from '@mui/material/MenuItem';
-import { useTranslation } from 'react-i18next';
-
-function MoreMenu({ onClose }: { onClose: () => void }) {
-  const { t } = useTranslation();
-  const { logout } = useAuth();
-  const onSignOutClick = useCallback(async () => {
-    await logout();
-    onClose();
-  }, [onClose, logout]);
-
-  const items = useMemo(() => {
-    return [
-      {
-        title: t('button.signOut'),
-        icon: (
-          <i className={'block h-5 w-5 flex-shrink-0'}>
-            <LogoutSvg></LogoutSvg>
-          </i>
-        ),
-        onClick: onSignOutClick,
-      },
-    ];
-  }, [onSignOutClick, t]);
-
-  return (
-    <>
-      {items.map((item, index) => {
-        return (
-          <MenuItem key={index} onClick={item.onClick}>
-            <div className={'flex items-center gap-2'}>
-              {item.icon}
-              <span className={'flex-shrink-0'}>{item.title}</span>
-            </div>
-          </MenuItem>
-        );
-      })}
-    </>
-  );
-}
-
-export default MoreMenu;

+ 0 - 20
frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/PageOptions.hooks.ts

@@ -1,20 +0,0 @@
-import { useCallback, useState } from 'react';
-import { useAuth } from '../../auth/auth.hooks';
-
-export const usePageOptions = () => {
-  const [anchorEl, setAnchorEl] = useState<HTMLDivElement | HTMLButtonElement>();
-
-  const onOptionsClick = useCallback((el: HTMLDivElement | HTMLButtonElement) => {
-    setAnchorEl(el);
-  }, []);
-
-  const onClose = () => {
-    setAnchorEl(undefined);
-  };
-
-  return {
-    anchorEl,
-    onOptionsClick,
-    onClose,
-  };
-};

+ 0 - 73
frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/PageOptions.tsx

@@ -1,73 +0,0 @@
-import { Details2Svg } from '../../_shared/svg/Details2Svg';
-import { usePageOptions } from './PageOptions.hooks';
-import { Button, IconButton, List } from '@mui/material';
-import Popover from '@mui/material/Popover';
-import { useCallback, useState } from 'react';
-import MoreMenu from '$app/components/layout/HeaderPanel/MoreMenu';
-import { useTranslation } from 'react-i18next';
-
-enum PageOptionsEnum {
-  Share = 'Share',
-  More = 'More',
-}
-export const PageOptions = () => {
-  const { t } = useTranslation();
-  const { anchorEl, onOptionsClick, onClose } = usePageOptions();
-  const open = Boolean(anchorEl);
-  const [option, setOption] = useState<PageOptionsEnum>();
-  const renderMenu = useCallback(() => {
-    switch (option) {
-      case PageOptionsEnum.Share:
-        return <div>Share</div>;
-      default:
-        return <MoreMenu onClose={onClose} />;
-    }
-  }, [onClose, option]);
-
-  return (
-    <>
-      <div className={'relative flex items-center gap-4'}>
-        <Button
-          variant={'contained'}
-          onClick={(e) => {
-            const el = e.currentTarget;
-
-            setOption(PageOptionsEnum.Share);
-            onOptionsClick(el);
-          }}
-        >
-          {t('shareAction.buttonText')}
-        </Button>
-
-        <IconButton
-          id='option-button'
-          size={'small'}
-          className={'h-8 w-8 rounded text-text-title hover:bg-fill-list-hover'}
-          onClick={(e) => {
-            const el = e.currentTarget;
-
-            setOption(PageOptionsEnum.More);
-            onOptionsClick(el);
-          }}
-        >
-          <Details2Svg></Details2Svg>
-        </IconButton>
-      </div>
-      <Popover
-        open={open}
-        anchorEl={anchorEl}
-        onClose={onClose}
-        anchorOrigin={{
-          vertical: 'bottom',
-          horizontal: 'right',
-        }}
-        transformOrigin={{
-          vertical: 'top',
-          horizontal: 'right',
-        }}
-      >
-        <List>{renderMenu()}</List>
-      </Popover>
-    </>
-  );
-};

+ 47 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx

@@ -0,0 +1,47 @@
+import React, { ReactNode, useEffect } from 'react';
+import SideBar from '$app/components/layout/SideBar';
+import TopBar from '$app/components/layout/TopBar';
+import { useAppSelector } from '$app/stores/store';
+import { FooterPanel } from '$app/components/layout/FooterPanel';
+
+function Layout({ children }: { children: ReactNode }) {
+  const { isCollapsed, width } = useAppSelector((state) => state.sidebar);
+
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Backspace' && e.target instanceof HTMLBodyElement) {
+        e.preventDefault();
+      }
+    };
+
+    window.addEventListener('keydown', onKeyDown);
+    return () => {
+      window.removeEventListener('keydown', onKeyDown);
+    };
+  }, []);
+  return (
+    <div className='flex h-screen w-[100%] text-sm text-text-title'>
+      <SideBar />
+      <div
+        className='flex flex-1 flex-col bg-bg-body'
+        style={{
+          width: isCollapsed ? 'auto' : `calc(100% - ${width}px)`,
+        }}
+      >
+        <TopBar />
+        <div
+          style={{
+            height: 'calc(100vh - 64px - 48px)',
+          }}
+          className={'overflow-y-auto overflow-x-hidden'}
+        >
+          {children}
+        </div>
+
+        <FooterPanel />
+      </div>
+    </div>
+  );
+}
+
+export default Layout;

+ 0 - 42
frontend/appflowy_tauri/src/appflowy_app/components/layout/MainPanel.tsx

@@ -1,42 +0,0 @@
-import { ReactNode, useEffect, useState } from 'react';
-import { HeaderPanel } from './HeaderPanel/HeaderPanel';
-import { FooterPanel } from './FooterPanel';
-import { ANIMATION_DURATION } from '../_shared/constants';
-
-export const MainPanel = ({
-  left,
-  menuHidden,
-  onShowMenuClick,
-  children,
-}: {
-  left: number;
-  menuHidden: boolean;
-  onShowMenuClick: () => void;
-  children: ReactNode;
-}) => {
-  const [animation, setAnimation] = useState(false);
-
-  useEffect(() => {
-    if (!menuHidden) {
-      setTimeout(() => {
-        setAnimation(false);
-      }, ANIMATION_DURATION);
-    } else {
-      setAnimation(true);
-    }
-  }, [menuHidden]);
-
-  return (
-    <div
-      className={`absolute inset-0 flex h-full flex-1 flex-col bg-bg-body text-text-title`}
-      style={{
-        transition: menuHidden || animation ? `left ${ANIMATION_DURATION}ms ease-out` : 'none',
-        left: `${menuHidden ? 0 : left}px`,
-      }}
-    >
-      <HeaderPanel menuHidden={menuHidden} onShowMenuClick={onShowMenuClick}></HeaderPanel>
-      <div className={'min-h-0 flex-1 overflow-auto'}>{children}</div>
-      <FooterPanel></FooterPanel>
-    </div>
-  );
-};

+ 0 - 84
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/MoreMenu.tsx

@@ -1,84 +0,0 @@
-import React, { useMemo, useState } from 'react';
-import { EditSvg } from '$app/components/_shared/svg/EditSvg';
-import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
-import { CopySvg } from '$app/components/_shared/svg/CopySvg';
-import MenuItem from '@mui/material/MenuItem';
-import { useTranslation } from 'react-i18next';
-import RenameDialog from '$app/components/layout/NavigationPanel/RenameDialog';
-import { IPage } from '$app_reducers/pages/slice';
-
-function MoreMenu({
-  selectedPage,
-  onRename,
-  onDeleteClick,
-  onDuplicateClick,
-}: {
-  selectedPage: IPage;
-  onRename: (name: string) => Promise<void>;
-  onDeleteClick: () => void;
-  onDuplicateClick: () => void;
-}) {
-  const { t } = useTranslation();
-  const [renameDialogOpen, setRenameDialogOpen] = useState(false);
-
-  const items = useMemo(
-    () => [
-      {
-        icon: (
-          <i className={'h-[16px] w-[16px] text-text-title'}>
-            <EditSvg></EditSvg>
-          </i>
-        ),
-        onClick: () => {
-          setRenameDialogOpen(true);
-        },
-        title: t('disclosureAction.rename'),
-      },
-      {
-        icon: (
-          <i className={'h-[16px] w-[16px] text-text-title'}>
-            <TrashSvg></TrashSvg>
-          </i>
-        ),
-        onClick: onDeleteClick,
-        title: t('disclosureAction.delete'),
-      },
-      {
-        icon: (
-          <i className={'h-[16px] w-[16px] text-text-title'}>
-            <CopySvg></CopySvg>
-          </i>
-        ),
-        onClick: onDuplicateClick,
-        title: t('disclosureAction.duplicate'),
-      },
-    ],
-    [onDeleteClick, onDuplicateClick, t]
-  );
-
-  return (
-    <>
-      {items.map((item, index) => {
-        return (
-          <MenuItem key={index} onClick={item.onClick}>
-            <div className={'flex items-center gap-2'}>
-              {item.icon}
-              <span className={'flex-shrink-0'}>{item.title}</span>
-            </div>
-          </MenuItem>
-        );
-      })}
-      <RenameDialog
-        defaultValue={selectedPage.title}
-        open={renameDialogOpen}
-        onClose={() => setRenameDialogOpen(false)}
-        onOk={async (val: string) => {
-          await onRename(val);
-          setRenameDialogOpen(false);
-        }}
-      />
-    </>
-  );
-}
-
-export default MoreMenu;

+ 0 - 200
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.hooks.ts

@@ -1,200 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { IPage, pagesActions } from '$app_reducers/pages/slice';
-import { ViewLayoutPB } from '@/services/backend';
-import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
-
-import { useLocation, useNavigate } from 'react-router-dom';
-import { INITIAL_FOLDER_HEIGHT, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
-
-import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
-import { ViewObserver } from '$app/stores/effects/folder/view/view_observer';
-
-export enum NavItemOptions {
-  More = 'More',
-  NewPage = 'NewPage',
-}
-export const useNavItem = (page: IPage) => {
-  const appDispatch = useAppDispatch();
-  const workspace = useAppSelector((state) => state.workspace);
-  const currentLocation = useLocation();
-  const [activePageId, setActivePageId] = useState<string>('');
-  const pages = useAppSelector((state) => state.pages);
-  const [anchorEl, setAnchorEl] = useState<HTMLElement>();
-  const menuOpen = Boolean(anchorEl);
-  const [menuOption, setMenuOption] = useState<NavItemOptions>();
-  const [selectedPage, setSelectedPage] = useState<IPage>();
-  const onClickMenuBtn = useCallback((page: IPage, option: NavItemOptions) => {
-    setSelectedPage(page);
-    setMenuOption(option);
-  }, []);
-  const navigate = useNavigate();
-
-  // backend
-  const service = new ViewBackendService(page.id);
-  const observer = new ViewObserver(page.id);
-
-  const loadInsidePages = async () => {
-    const result = await service.getChildViews();
-
-    if (!result.ok) return;
-    const views = result.val;
-    const updatedPages: IPage[] = views.map<IPage>((view) => ({
-      parentPageId: page.id,
-      id: view.id,
-      pageType: view.layout,
-      title: view.name,
-      showPagesInside: false,
-    }));
-
-    appDispatch(pagesActions.addInsidePages({ currentPageId: page.id, insidePages: updatedPages }));
-  };
-
-  useEffect(() => {
-    void loadInsidePages();
-    void observer.subscribe({
-      onChildViewsChanged: () => {
-        void loadInsidePages();
-      },
-    });
-    return () => {
-      // Unsubscribe when the component is unmounted.
-      void observer.unsubscribe();
-    };
-  }, []);
-
-  useEffect(() => {
-    const { pathname } = currentLocation;
-    const parts = pathname.split('/');
-    const pageId = parts[parts.length - 1];
-
-    setActivePageId(pageId);
-  }, [currentLocation]);
-
-  // recursively get all unfolded child pages
-  const getChildCount: (startPage: IPage) => number = (startPage: IPage) => {
-    let count = 0;
-
-    count = pages.filter((p) => p.parentPageId === startPage.id).length;
-    pages
-      .filter((p) => p.parentPageId === startPage.id)
-      .forEach((p) => {
-        if (p.showPagesInside) {
-          count += getChildCount(p);
-        }
-      });
-    return count;
-  };
-
-  const onUnfoldClick = () => {
-    appDispatch(pagesActions.toggleShowPages({ id: page.id }));
-  };
-
-  const changePageTitle = async (newTitle: string) => {
-    await service.update({ name: newTitle });
-    appDispatch(pagesActions.renamePage({ id: page.id, newTitle }));
-    setAnchorEl(undefined);
-  };
-
-  const deletePage = async () => {
-    await service.delete();
-    appDispatch(pagesActions.deletePage({ id: page.id }));
-    setAnchorEl(undefined);
-  };
-
-  const duplicatePage = async () => {
-    await service.duplicate();
-    setAnchorEl(undefined);
-  };
-
-  const onPageClick = (eventPage: IPage) => {
-    const pageTypeRoute = (() => {
-      switch (eventPage.pageType) {
-        case ViewLayoutPB.Document:
-          return 'document';
-        case ViewLayoutPB.Grid:
-          return 'grid';
-        case ViewLayoutPB.Board:
-          return 'board';
-        default:
-          return 'document';
-      }
-    })();
-
-    navigate(`/page/${pageTypeRoute}/${eventPage.id}`);
-  };
-
-  const onAddNewPage = async (pageType: ViewLayoutPB) => {
-    if (!workspace?.id) return;
-
-    let newPageName = '';
-    let pageTypeRoute = '';
-
-    switch (pageType) {
-      case ViewLayoutPB.Document:
-        newPageName = 'Document Page 1';
-        pageTypeRoute = 'document';
-        break;
-      case ViewLayoutPB.Grid:
-        newPageName = 'Grid Page 1';
-        pageTypeRoute = 'grid';
-        break;
-      case ViewLayoutPB.Board:
-        newPageName = 'Board Page 1';
-        pageTypeRoute = 'board';
-        break;
-      default:
-        newPageName = 'Document Page 1';
-        pageTypeRoute = 'document';
-        break;
-    }
-
-    const workspaceService = new WorkspaceBackendService(workspace.id);
-    const newViewResult = await workspaceService.createView({
-      name: newPageName,
-      layoutType: pageType,
-      parentViewId: page.id,
-    });
-
-    if (newViewResult.ok) {
-      const newView = newViewResult.val;
-
-      if (!page.showPagesInside) {
-        appDispatch(pagesActions.toggleShowPages({ id: page.id }));
-      }
-
-      appDispatch(
-        pagesActions.addPage({
-          parentPageId: page.id,
-          pageType,
-          title: newView.name,
-          id: newView.id,
-          showPagesInside: false,
-        })
-      );
-      setAnchorEl(undefined);
-      navigate(`/page/${pageTypeRoute}/${newView.id}`);
-    }
-  };
-
-  return {
-    onUnfoldClick,
-
-    changePageTitle,
-
-    deletePage,
-    duplicatePage,
-
-    onPageClick,
-
-    onAddNewPage,
-
-    activePageId,
-    menuOpen,
-    anchorEl,
-    setAnchorEl,
-    menuOption,
-    selectedPage,
-    onClickMenuBtn,
-  };
-};

+ 0 - 124
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.tsx

@@ -1,124 +0,0 @@
-import { Details2Svg } from '../../_shared/svg/Details2Svg';
-import AddSvg from '../../_shared/svg/AddSvg';
-import { IPage } from '$app_reducers/pages/slice';
-import { useMemo, useRef } from 'react';
-import { DropDownShowSvg } from '../../_shared/svg/DropDownShowSvg';
-import { ANIMATION_DURATION } from '../../_shared/constants';
-import { NavItemOptions, useNavItem } from '$app/components/layout/NavigationPanel/NavItem.hooks';
-import { useAppSelector } from '$app/stores/store';
-import { ViewLayoutPB } from '@/services/backend';
-import Popover from '@mui/material/Popover';
-import { IconButton, List } from '@mui/material';
-import MoreMenu from '$app/components/layout/NavigationPanel/MoreMenu';
-import NewPageMenu from '$app/components/layout/NavigationPanel/NewPageMenu';
-
-export const NavItem = ({ page }: { page: IPage }) => {
-  const pages = useAppSelector((state) => state.pages);
-  const {
-    onUnfoldClick,
-    changePageTitle,
-    deletePage,
-    duplicatePage,
-
-    onAddNewPage,
-
-    activePageId,
-
-    onPageClick,
-    onClickMenuBtn,
-    menuOpen,
-    menuOption,
-    setAnchorEl,
-    selectedPage,
-    anchorEl,
-  } = useNavItem(page);
-
-  const el = useRef<HTMLDivElement>(null);
-
-  return (
-    <>
-      <div ref={el}>
-        <div className={`transition-all`} style={{ transitionDuration: `${ANIMATION_DURATION}ms` }}>
-          <div className={`cursor-pointer px-1 py-1`}>
-            <div
-              className={`flex items-center justify-between rounded-lg px-2 py-1 hover:bg-fill-list-hover ${
-                activePageId === page.id ? 'bg-fill-list-hover' : ''
-              }`}
-            >
-              <div className={'flex h-full min-w-0 flex-1 items-center'}>
-                <button
-                  onClick={() => onUnfoldClick()}
-                  className={`mr-2 h-5 w-5 transition-transform duration-200 ${
-                    page.showPagesInside ? 'rotate-180' : ''
-                  }`}
-                >
-                  <DropDownShowSvg></DropDownShowSvg>
-                </button>
-                <div
-                  onClick={() => onPageClick(page)}
-                  className={'mr-1 flex h-full min-w-0 flex-1 items-center text-left'}
-                >
-                  <span className={'w-[100%] overflow-hidden overflow-ellipsis whitespace-nowrap'}>{page.title}</span>
-                </div>
-              </div>
-              <div className={'flex items-center'}>
-                <IconButton
-                  className={'h-6 w-6'}
-                  size={'small'}
-                  onClick={(e) => {
-                    setAnchorEl(e.currentTarget);
-                    onClickMenuBtn(page, NavItemOptions.More);
-                  }}
-                >
-                  <Details2Svg></Details2Svg>
-                </IconButton>
-                <IconButton
-                  className={'h-6 w-6'}
-                  size={'small'}
-                  onClick={(e) => {
-                    setAnchorEl(e.currentTarget);
-                    onClickMenuBtn(page, NavItemOptions.NewPage);
-                  }}
-                >
-                  <AddSvg></AddSvg>
-                </IconButton>
-              </div>
-            </div>
-          </div>
-          <div className={`${page.showPagesInside ? '' : 'hidden'} pl-4`}>
-            {useMemo(() => pages.filter((insidePage) => insidePage.parentPageId === page.id), [pages, page]).map(
-              (insidePage, insideIndex) => (
-                <NavItem key={insideIndex} page={insidePage}></NavItem>
-              )
-            )}
-          </div>
-        </div>
-      </div>
-      <Popover
-        open={menuOpen}
-        anchorEl={anchorEl}
-        onClose={() => setAnchorEl(undefined)}
-        anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
-        transformOrigin={{ vertical: 'top', horizontal: 'left' }}
-      >
-        <List>
-          {menuOption === NavItemOptions.More && selectedPage && (
-            <MoreMenu
-              selectedPage={selectedPage}
-              onRename={changePageTitle}
-              onDeleteClick={() => deletePage()}
-              onDuplicateClick={() => duplicatePage()}
-            />
-          )}
-          {menuOption === NavItemOptions.NewPage && (
-            <NewPageMenu
-              onDocumentClick={() => onAddNewPage(ViewLayoutPB.Document)}
-              onBoardClick={() => onAddNewPage(ViewLayoutPB.Board)}
-              onGridClick={() => onAddNewPage(ViewLayoutPB.Grid)}
-            />
-          )}
-        </List>
-      </Popover>
-    </>
-  );
-};

+ 0 - 22
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.hooks.ts

@@ -1,22 +0,0 @@
-import { useAppSelector } from '$app/stores/store';
-import { useState } from 'react';
-
-export const useNavigationPanelHooks = function () {
-  const width = useAppSelector((state) => state.navigationWidth);
-  const [menuHidden, setMenuHidden] = useState(false);
-
-  const onHideMenuClick = () => {
-    setMenuHidden(true);
-  };
-
-  const onShowMenuClick = () => {
-    setMenuHidden(false);
-  };
-
-  return {
-    width,
-    menuHidden,
-    onHideMenuClick,
-    onShowMenuClick,
-  };
-};

+ 0 - 131
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx

@@ -1,131 +0,0 @@
-import { WorkspaceUser } from '../WorkspaceUser';
-import { AppLogo } from '../AppLogo';
-import { TrashButton } from './TrashButton';
-import { NewViewButton } from './NewViewButton';
-import { NavigationResizer } from './NavigationResizer';
-import { IPage } from '$app_reducers/pages/slice';
-import { useLocation, useNavigate } from 'react-router-dom';
-import React, { useEffect, useRef, useState } from 'react';
-import { useAppSelector } from '$app/stores/store';
-import { NavItem } from '$app/components/layout/NavigationPanel/NavItem';
-import { ANIMATION_DURATION, NAV_PANEL_MINIMUM_WIDTH, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
-
-export const NavigationPanel = ({
-  onHideMenuClick,
-  menuHidden,
-  width,
-}: {
-  onHideMenuClick: () => void;
-  menuHidden: boolean;
-  width: number;
-}) => {
-  const el = useRef<HTMLDivElement>(null);
-  const pages = useAppSelector((state) => state.pages);
-  const workspace = useAppSelector((state) => state.workspace);
-  const [activePageId, setActivePageId] = useState<string>('');
-  const currentLocation = useLocation();
-  const [maxHeight, setMaxHeight] = useState(0);
-
-  useEffect(() => {
-    const { pathname } = currentLocation;
-    const parts = pathname.split('/');
-    const pageId = parts[parts.length - 1];
-
-    setActivePageId(pageId);
-  }, [currentLocation]);
-
-  useEffect(() => {
-    setMaxHeight(pages.length * PAGE_ITEM_HEIGHT);
-  }, [pages]);
-
-  const scrollDown = () => {
-    setTimeout(() => {
-      el?.current?.scrollTo({ top: maxHeight, behavior: 'smooth' });
-    }, ANIMATION_DURATION);
-  };
-
-  return (
-    <>
-      <div
-        className={`absolute inset-0 flex flex-col justify-between bg-bg-base text-sm text-text-title`}
-        style={{
-          transition: `left ${ANIMATION_DURATION}ms ease-out`,
-          width: `${width}px`,
-          left: `${menuHidden ? -width : 0}px`,
-        }}
-      >
-        <AppLogo iconToShow={'hide'} onHideMenuClick={onHideMenuClick}></AppLogo>
-        <WorkspaceUser></WorkspaceUser>
-        <div className={'relative flex flex-1 flex-col'}>
-          <div className={'flex h-[100%] flex-col overflow-auto px-2'} ref={el}>
-            <WorkspaceApps pages={pages.filter((p) => p.parentPageId === workspace.id)} />
-          </div>
-        </div>
-
-        <div className={'flex max-h-[240px] flex-col'}>
-          <div className={'border-b border-line-divider px-2 pb-4'}>
-            {/*<PluginsButton></PluginsButton>*/}
-
-            {/*<DesignSpec></DesignSpec>*/}
-            {/*<AllIcons></AllIcons>*/}
-            {/*<TestBackendButton></TestBackendButton>*/}
-
-            {/*Trash Button*/}
-            <TrashButton></TrashButton>
-          </div>
-
-          {/*New Root View Button*/}
-          <NewViewButton scrollDown={scrollDown}></NewViewButton>
-        </div>
-      </div>
-      <NavigationResizer minWidth={NAV_PANEL_MINIMUM_WIDTH}></NavigationResizer>
-    </>
-  );
-};
-
-const WorkspaceApps: React.FC<{ pages: IPage[] }> = ({ pages }) => (
-  <>
-    {pages.map((page, index) => (
-      <NavItem key={index} page={page}></NavItem>
-    ))}
-  </>
-);
-
-export const TestBackendButton = () => {
-  const navigate = useNavigate();
-
-  return (
-    <button
-      onClick={() => navigate('/page/api-test')}
-      className={'hover:bg-fill-active flex w-full items-center rounded-lg px-4 py-2'}
-    >
-      API Test
-    </button>
-  );
-};
-
-export const DesignSpec = () => {
-  const navigate = useNavigate();
-
-  return (
-    <button
-      onClick={() => navigate('page/colors')}
-      className={'hover:bg-fill-active flex w-full items-center rounded-lg px-4 py-2'}
-    >
-      Color Palette
-    </button>
-  );
-};
-
-export const AllIcons = () => {
-  const navigate = useNavigate();
-
-  return (
-    <button
-      onClick={() => navigate('page/all-icons')}
-      className={'hover:bg-fill-active flex w-full items-center rounded-lg px-4 py-2'}
-    >
-      All Icons
-    </button>
-  );
-};

+ 0 - 26
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationResizer.tsx

@@ -1,26 +0,0 @@
-import { useResizer } from '../../_shared/useResizer';
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { useEffect } from 'react';
-import { navigationWidthActions } from '$app_reducers/navigation-width/slice';
-
-export const NavigationResizer = ({ minWidth }: { minWidth: number }) => {
-  const width = useAppSelector((state) => state.navigationWidth);
-  const appDispatch = useAppDispatch();
-  const { onMouseDown, movementX } = useResizer();
-
-  useEffect(() => {
-    if (width + movementX < minWidth) {
-      appDispatch(navigationWidthActions.changeWidth(minWidth));
-    } else {
-      appDispatch(navigationWidthActions.changeWidth(width + movementX));
-    }
-  }, [movementX]);
-
-  return (
-    <button
-      className={'fixed z-10 h-full w-[15px] cursor-ew-resize'}
-      style={{ left: `${width - 8}px`, userSelect: 'none' }}
-      onMouseDown={onMouseDown}
-    ></button>
-  );
-};

+ 0 - 67
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewPageMenu.tsx

@@ -1,67 +0,0 @@
-import React, { useMemo } from 'react';
-import { DocumentSvg } from '$app/components/_shared/svg/DocumentSvg';
-import { BoardSvg } from '$app/components/_shared/svg/BoardSvg';
-import { GridSvg } from '$app/components/_shared/svg/GridSvg';
-import MenuItem from '@mui/material/MenuItem';
-import { useTranslation } from 'react-i18next';
-
-function NewPageMenu({
-  onDocumentClick,
-  onGridClick,
-  onBoardClick,
-}: {
-  onDocumentClick: () => void;
-  onGridClick: () => void;
-  onBoardClick: () => void;
-}) {
-  const { t } = useTranslation();
-  const items = useMemo(
-    () => [
-      {
-        icon: (
-          <i className={'h-[16px] w-[16px] text-text-title'}>
-            <DocumentSvg></DocumentSvg>
-          </i>
-        ),
-        onClick: onDocumentClick,
-        title: t('document.menuName'),
-      },
-      {
-        icon: (
-          <i className={'h-[16px] w-[16px] text-text-title'}>
-            <BoardSvg></BoardSvg>
-          </i>
-        ),
-        onClick: onBoardClick,
-        title: t('board.menuName'),
-      },
-      {
-        icon: (
-          <i className={'h-[16px] w-[16px] text-text-title'}>
-            <GridSvg></GridSvg>
-          </i>
-        ),
-        onClick: onGridClick,
-        title: t('grid.menuName'),
-      },
-    ],
-    [onBoardClick, onDocumentClick, onGridClick, t]
-  );
-
-  return (
-    <>
-      {items.map((item, index) => {
-        return (
-          <MenuItem key={index} onClick={item.onClick}>
-            <div className={'flex items-center gap-2'}>
-              {item.icon}
-              <span className={'flex-shrink-0'}>{item.title}</span>
-            </div>
-          </MenuItem>
-        );
-      })}
-    </>
-  );
-}
-
-export default NewPageMenu;

+ 0 - 45
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.hooks.ts

@@ -1,45 +0,0 @@
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
-import { ViewLayoutPB } from '@/services/backend';
-import { pagesActions } from '$app_reducers/pages/slice';
-import { useNavigate } from 'react-router-dom';
-
-export const useNewRootView = () => {
-  const appDispatch = useAppDispatch();
-  const workspace = useAppSelector((state) => state.workspace);
-  const navigate = useNavigate();
-
-  const onNewRootView = async () => {
-    if (!workspace.id) return;
-    const workspaceBackendService = new WorkspaceBackendService(workspace.id);
-
-    // in future should show options for new page type
-    const defaultType = ViewLayoutPB.Document;
-    const defaultName = 'Document Page 1';
-    const defaultRoute = 'document';
-
-    const result = await workspaceBackendService.createView({
-      parentViewId: workspace.id,
-      layoutType: defaultType,
-      name: defaultName,
-    });
-
-    if (result.ok) {
-      const newView = result.val;
-      appDispatch(
-        pagesActions.addPage({
-          parentPageId: workspace.id,
-          id: newView.id,
-          title: newView.name,
-          showPagesInside: false,
-          pageType: defaultType,
-        })
-      );
-      navigate(`/page/${defaultRoute}/${newView.id}`);
-    }
-  };
-
-  return {
-    onNewRootView,
-  };
-};

+ 0 - 23
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.tsx

@@ -1,23 +0,0 @@
-import AddSvg from '../../_shared/svg/AddSvg';
-import { useNewRootView } from './NewViewButton.hooks';
-
-export const NewViewButton = ({ scrollDown }: { scrollDown: () => void }) => {
-  const { onNewRootView } = useNewRootView();
-
-  return (
-    <button
-      onClick={() => {
-        void onNewRootView();
-        scrollDown();
-      }}
-      className={'flex h-[50px] w-full items-center px-6 hover:bg-fill-list-active'}
-    >
-      <div className={'mr-2 rounded-full bg-fill-default'}>
-        <div className={'h-[24px] w-[24px] text-content-on-fill'}>
-          <AddSvg></AddSvg>
-        </div>
-      </div>
-      <span>New View</span>
-    </button>
-  );
-};

+ 0 - 8
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PluginsButton.tsx

@@ -1,8 +0,0 @@
-export const PluginsButton = () => {
-  return (
-    <button className={'flex w-full items-center rounded-lg px-4 py-2 hover:bg-fill-active'}>
-      <img className={'mr-2 h-[24px] w-[24px]'} src={'/images/home/page.svg'} alt={''} />
-      <span>Plugins</span>
-    </button>
-  );
-};

+ 0 - 13
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/TrashButton.tsx

@@ -1,13 +0,0 @@
-import { DeleteForeverOutlined } from '@mui/icons-material';
-import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
-
-export const TrashButton = () => {
-  return (
-    <button className={'flex w-full items-center rounded-lg px-4 py-2 text-text-title hover:bg-fill-list-active'}>
-      <span className={'h-[23px] w-[23px]'}>
-        <TrashSvg />
-      </span>
-      <span className={'ml-2'}>Trash</span>
-    </button>
-  );
-};

+ 77 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/AddButton.tsx

@@ -0,0 +1,77 @@
+import React, { useMemo } from 'react';
+import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
+import { IconButton } from '@mui/material';
+import AddSvg from '$app/components/_shared/svg/AddSvg';
+import { useTranslation } from 'react-i18next';
+import { DocumentSvg } from '$app/components/_shared/svg/DocumentSvg';
+import { GridSvg } from '$app/components/_shared/svg/GridSvg';
+import { BoardSvg } from '$app/components/_shared/svg/BoardSvg';
+import { ViewLayoutPB } from '@/services/backend';
+
+function AddButton({ isVisible, onAddPage }: { isVisible: boolean; onAddPage: (layout: ViewLayoutPB) => void }) {
+  const { t } = useTranslation();
+  const options = useMemo(
+    () => [
+      {
+        key: 'add-document',
+        label: t('document.menuName'),
+        icon: (
+          <div className={'h-5 w-5'}>
+            <DocumentSvg />
+          </div>
+        ),
+        onClick: () => {
+          onAddPage(ViewLayoutPB.Document);
+        },
+      },
+      {
+        key: 'add-grid',
+        label: t('grid.menuName'),
+        icon: (
+          <div className={'h-5 w-5'}>
+            <GridSvg />
+          </div>
+        ),
+        onClick: () => {
+          onAddPage(ViewLayoutPB.Grid);
+        },
+      },
+      {
+        key: 'add-board',
+        label: t('board.menuName'),
+        icon: (
+          <div className={'h-5 w-5'}>
+            <BoardSvg />
+          </div>
+        ),
+        onClick: () => {
+          onAddPage(ViewLayoutPB.Board);
+        },
+      },
+    ],
+    [onAddPage, t]
+  );
+
+  return (
+    <ButtonPopoverList
+      popoverOrigin={{
+        anchorOrigin: {
+          vertical: 'bottom',
+          horizontal: 'left',
+        },
+        transformOrigin: {
+          vertical: 'top',
+          horizontal: 'left',
+        },
+      }}
+      popoverOptions={options}
+      isVisible={isVisible}
+    >
+      <IconButton className={'mr-2 h-6 w-6'}>
+        <AddSvg />
+      </IconButton>
+    </ButtonPopoverList>
+  );
+}
+
+export default AddButton;

+ 64 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/DeleteDialog.tsx

@@ -0,0 +1,64 @@
+import React, { useState } from 'react';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogContent from '@mui/material/DialogContent';
+import Dialog from '@mui/material/Dialog';
+import { useTranslation } from 'react-i18next';
+import TextField from '@mui/material/TextField';
+import { Button, DialogActions } from '@mui/material';
+import { ViewLayoutPB } from '@/services/backend';
+
+function DeleteDialog({
+  layout,
+  open,
+  onClose,
+  onOk,
+}: {
+  layout: ViewLayoutPB;
+  open: boolean;
+  onClose: () => void;
+  onOk: () => Promise<void>;
+}) {
+  const { t } = useTranslation();
+
+  const pageType = {
+    [ViewLayoutPB.Document]: t('document.menuName'),
+    [ViewLayoutPB.Grid]: t('grid.menuName'),
+    [ViewLayoutPB.Board]: t('board.menuName'),
+    [ViewLayoutPB.Calendar]: t('calendar.menuName'),
+  }[layout];
+
+  return (
+    <Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
+      <DialogContent className={'flex w-[540px] flex-col items-center justify-center'}>
+        <div className={'text-md m-2 font-bold'}>
+          {t('views.deleteContentTitle', {
+            pageType,
+          })}
+        </div>
+        <div className={'m-1 text-sm text-text-caption'}>
+          {t('views.deleteContentCaption', {
+            pageType,
+          })}
+        </div>
+      </DialogContent>
+      <DialogActions>
+        <Button variant={'outlined'} onClick={onClose}>
+          {t('button.Cancel')}
+        </Button>
+        <Button
+          variant={'contained'}
+          onClick={async () => {
+            try {
+              await onOk();
+              onClose();
+            } catch (e) {}
+          }}
+        >
+          {t('button.delete')}
+        </Button>
+      </DialogActions>
+    </Dialog>
+  );
+}
+
+export default DeleteDialog;

+ 112 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/MoreButton.tsx

@@ -0,0 +1,112 @@
+import React, { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { IconButton } from '@mui/material';
+import ButtonPopoverList from '../../_shared/ButtonPopoverList';
+import { MoreHoriz } from '@mui/icons-material';
+import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
+import { CopySvg } from '$app/components/_shared/svg/CopySvg';
+import { EditSvg } from '$app/components/_shared/svg/EditSvg';
+import RenameDialog from './RenameDialog';
+import { Page } from '$app_reducers/pages/slice';
+import DeleteDialog from '$app/components/layout/NestedPage/DeleteDialog';
+
+function MoreButton({
+  isVisible,
+  onDelete,
+  onDuplicate,
+  onRename,
+  page,
+}: {
+  isVisible: boolean;
+  onDelete: () => Promise<void>;
+  onDuplicate: () => Promise<void>;
+  onRename: (newName: string) => Promise<void>;
+  page: Page;
+}) {
+  const [renameDialogOpen, setRenameDialogOpen] = useState(false);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+
+  const { t } = useTranslation();
+  const options = useMemo(
+    () => [
+      {
+        label: t('disclosureAction.rename'),
+        key: 'rename',
+        icon: (
+          <div className={'h-5 w-5'}>
+            <EditSvg />
+          </div>
+        ),
+        onClick: () => {
+          setRenameDialogOpen(true);
+        },
+      },
+      {
+        label: t('button.delete'),
+        key: 'delete',
+        onClick: () => {
+          setDeleteDialogOpen(true);
+        },
+        icon: (
+          <div className={'h-5 w-5'}>
+            <TrashSvg />
+          </div>
+        ),
+      },
+      {
+        key: 'duplicate',
+        label: t('button.duplicate'),
+        onClick: onDuplicate,
+        icon: (
+          <div className={'h-5 w-5'}>
+            <CopySvg />
+          </div>
+        ),
+      },
+    ],
+    [onDuplicate, t]
+  );
+
+  return (
+    <>
+      <ButtonPopoverList
+        isVisible={isVisible}
+        popoverOptions={options}
+        popoverOrigin={{
+          anchorOrigin: {
+            vertical: 'bottom',
+            horizontal: 'left',
+          },
+          transformOrigin: {
+            vertical: 'top',
+            horizontal: 'left',
+          },
+        }}
+      >
+        <IconButton className={'h-6 w-6'}>
+          <MoreHoriz />
+        </IconButton>
+      </ButtonPopoverList>
+      <RenameDialog
+        defaultValue={page.name}
+        open={renameDialogOpen}
+        onClose={() => setRenameDialogOpen(false)}
+        onOk={async (newName: string) => {
+          await onRename(newName);
+          setRenameDialogOpen(false);
+        }}
+      />
+      <DeleteDialog
+        layout={page.layout}
+        open={deleteDialogOpen}
+        onClose={() => setDeleteDialogOpen(false)}
+        onOk={async () => {
+          await onDelete();
+          setDeleteDialogOpen(false);
+        }}
+      />
+    </>
+  );
+}
+
+export default MoreButton;

+ 146 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts

@@ -0,0 +1,146 @@
+import { useCallback, useEffect, useMemo } from 'react';
+import { PageController } from '$app/stores/effects/workspace/page/page_controller';
+import { Page, pagesActions } from '$app_reducers/pages/slice';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { ViewLayoutPB } from '@/services/backend';
+import { useNavigate, useParams } from 'react-router-dom';
+import { pageTypeMap } from '$app/constants';
+import { useTranslation } from 'react-i18next';
+
+export function useLoadChildPages(pageId: string) {
+  const dispatch = useAppDispatch();
+  const childPages = useAppSelector((state) => state.pages.childPages[pageId]);
+
+  const collapsed = useAppSelector((state) => !state.pages.expandedPages[pageId]);
+  const toggleCollapsed = useCallback(() => {
+    if (collapsed) {
+      dispatch(pagesActions.expandPage(pageId));
+    } else {
+      dispatch(pagesActions.collapsePage(pageId));
+    }
+  }, [dispatch, pageId, collapsed]);
+
+  const controller = useMemo(() => {
+    return new PageController(pageId);
+  }, [pageId]);
+
+  const onChildPagesChanged = useCallback(
+    (childPages: Page[]) => {
+      dispatch(
+        pagesActions.addChildPages({
+          id: pageId,
+          childPages,
+        })
+      );
+    },
+    [dispatch, pageId]
+  );
+
+  const onPageCollapsed = useCallback(async () => {
+    dispatch(pagesActions.removeChildPages(pageId));
+    await controller.unsubscribe();
+  }, [dispatch, pageId, controller]);
+
+  const onPageExpanded = useCallback(async () => {
+    const childPages = await controller.getChildPages();
+
+    dispatch(
+      pagesActions.addChildPages({
+        id: pageId,
+        childPages,
+      })
+    );
+    await controller.subscribe({
+      onChildPagesChanged,
+    });
+  }, [controller, dispatch, onChildPagesChanged, pageId]);
+
+  useEffect(() => {
+    if (collapsed) {
+      onPageCollapsed();
+    } else {
+      onPageExpanded();
+    }
+  }, [collapsed, onPageCollapsed, onPageExpanded]);
+
+  useEffect(() => {
+    return () => {
+      controller.dispose();
+    };
+  }, [controller]);
+
+  return {
+    toggleCollapsed,
+    collapsed,
+    childPages,
+  };
+}
+
+export function usePageActions(pageId: string) {
+  const page = useAppSelector((state) => state.pages.map[pageId]);
+  const { t } = useTranslation();
+  const dispatch = useAppDispatch();
+  const navigate = useNavigate();
+  const controller = useMemo(() => {
+    return new PageController(pageId);
+  }, [pageId]);
+
+  const onPageClick = useCallback(() => {
+    const pageType = pageTypeMap[page.layout];
+
+    navigate(`/page/${pageType}/${pageId}`);
+  }, [navigate, page.layout, pageId]);
+
+  const onAddPage = useCallback(
+    async (layout: ViewLayoutPB) => {
+      const newViewId = await controller.createPage({
+        layout,
+        name: t('document.title.placeholder'),
+      });
+
+      dispatch(pagesActions.expandPage(pageId));
+      const pageType = pageTypeMap[layout];
+
+      navigate(`/page/${pageType}/${newViewId}`);
+    },
+    [t, controller, dispatch, navigate, pageId]
+  );
+
+  const onDeletePage = useCallback(async () => {
+    await controller.deletePage();
+  }, [controller]);
+
+  const onDuplicatePage = useCallback(async () => {
+    await controller.duplicatePage();
+  }, [controller]);
+
+  const onRenamePage = useCallback(
+    async (name: string) => {
+      await controller.updatePage({
+        id: pageId,
+        name,
+      });
+    },
+    [controller, pageId]
+  );
+
+  useEffect(() => {
+    return () => {
+      controller.dispose();
+    };
+  }, [controller]);
+
+  return {
+    onAddPage,
+    onPageClick,
+    onRenamePage,
+    onDeletePage,
+    onDuplicatePage,
+  };
+}
+
+export function useSelectedPage(pageId: string) {
+  const id = useParams().id;
+
+  return id === pageId;
+}

+ 75 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx

@@ -0,0 +1,75 @@
+import React, { useState } from 'react';
+import { ArrowRightSvg } from '$app/components/_shared/svg/ArrowRightSvg';
+import MenuItem from '@mui/material/MenuItem';
+import { useAppSelector } from '$app/stores/store';
+import AddButton from './AddButton';
+import MoreButton from './MoreButton';
+import { ViewLayoutPB } from '@/services/backend';
+import { useSelectedPage } from '$app/components/layout/NestedPage/NestedPage.hooks';
+
+function NestedPageTitle({
+  pageId,
+  collapsed,
+  toggleCollapsed,
+  onAddPage,
+  onClick,
+  onDelete,
+  onDuplicate,
+  onRename,
+}: {
+  pageId: string;
+  collapsed: boolean;
+  toggleCollapsed: () => void;
+  onAddPage: (layout: ViewLayoutPB) => void;
+  onClick: () => void;
+  onDelete: () => Promise<void>;
+  onDuplicate: () => Promise<void>;
+  onRename: (newName: string) => Promise<void>;
+}) {
+  const page = useAppSelector((state) => {
+    return state.pages.map[pageId];
+  });
+  const [isHovering, setIsHovering] = useState(false);
+  const isSelected = useSelectedPage(pageId);
+
+  return (
+    <MenuItem
+      selected={isSelected}
+      onClick={onClick}
+      onMouseEnter={() => setIsHovering(true)}
+      onMouseLeave={() => setIsHovering(false)}
+    >
+      <div className={'flex h-6 w-[100%] items-center justify-between'}>
+        <div className={'flex flex-1 items-center justify-start overflow-hidden'}>
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              toggleCollapsed();
+            }}
+            style={{
+              transform: collapsed ? 'rotate(0deg)' : 'rotate(-90deg)',
+            }}
+            className={'flex h-[100%] w-8 items-center justify-center p-2'}
+          >
+            <div className={'h-5 w-5'}>
+              <ArrowRightSvg />
+            </div>
+          </button>
+          <div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{page.name}</div>
+        </div>
+        <div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end'}>
+          <AddButton isVisible={isHovering} onAddPage={onAddPage} />
+          <MoreButton
+            page={page}
+            isVisible={isHovering}
+            onDelete={onDelete}
+            onDuplicate={onDuplicate}
+            onRename={onRename}
+          />
+        </div>
+      </div>
+    </MenuItem>
+  );
+}
+
+export default NestedPageTitle;

+ 9 - 3
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/RenameDialog.tsx → frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/RenameDialog.tsx

@@ -15,16 +15,18 @@ function RenameDialog({
   defaultValue: string;
   open: boolean;
   onClose: () => void;
-  onOk: (val: string) => void;
+  onOk: (val: string) => Promise<void>;
 }) {
   const { t } = useTranslation();
   const [value, setValue] = useState(defaultValue);
+  const [error, setError] = useState(false);
 
   return (
     <Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
       <DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle>
       <DialogContent className={'flex w-[540px]'}>
         <TextField
+          error={error}
           autoFocus
           value={value}
           onChange={(e) => {
@@ -38,8 +40,12 @@ function RenameDialog({
       <DialogActions>
         <Button onClick={onClose}>{t('button.Cancel')}</Button>
         <Button
-          onClick={() => {
-            onOk(value);
+          onClick={async () => {
+            try {
+              await onOk(value);
+            } catch (e) {
+              setError(true);
+            }
           }}
         >
           {t('button.OK')}

+ 39 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx

@@ -0,0 +1,39 @@
+import React from 'react';
+import Collapse from '@mui/material/Collapse';
+import { TransitionGroup } from 'react-transition-group';
+import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle';
+import { useLoadChildPages, usePageActions } from '$app/components/layout/NestedPage/NestedPage.hooks';
+
+function NestedPage({ pageId }: { pageId: string }) {
+  const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId);
+  const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId);
+
+  return (
+    <div>
+      <NestedPageTitle
+        onClick={() => {
+          onPageClick();
+        }}
+        onAddPage={onAddPage}
+        onDuplicate={onDuplicatePage}
+        onDelete={onDeletePage}
+        onRename={onRenamePage}
+        collapsed={collapsed}
+        toggleCollapsed={toggleCollapsed}
+        pageId={pageId}
+      />
+
+      <div className={'pl-4 pt-[2px]'}>
+        <TransitionGroup>
+          {childPages?.map((pageId) => (
+            <Collapse key={pageId}>
+              <NestedPage key={pageId} pageId={pageId} />
+            </Collapse>
+          ))}
+        </TransitionGroup>
+      </div>
+    </div>
+  );
+}
+
+export default NestedPage;

+ 0 - 21
frontend/appflowy_tauri/src/appflowy_app/components/layout/Screen.tsx

@@ -1,21 +0,0 @@
-import React, { ReactNode } from 'react';
-import { NavigationPanel } from './NavigationPanel/NavigationPanel';
-import { MainPanel } from './MainPanel';
-import { useNavigationPanelHooks } from './NavigationPanel/NavigationPanel.hooks';
-import { useWorkspace } from './Workspace.hooks';
-
-export const Screen = ({ children }: { children: ReactNode }) => {
-  useWorkspace();
-
-  const { width, onHideMenuClick, onShowMenuClick, menuHidden } = useNavigationPanelHooks();
-
-  return (
-    <div className='flex h-screen w-screen bg-bg-body text-text-title'>
-      <NavigationPanel onHideMenuClick={onHideMenuClick} width={width} menuHidden={menuHidden}></NavigationPanel>
-
-      <MainPanel left={width} menuHidden={menuHidden} onShowMenuClick={onShowMenuClick}>
-        {children}
-      </MainPanel>
-    </div>
-  );
-};

+ 12 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/Share.hooks.ts

@@ -0,0 +1,12 @@
+import { useLocation, useParams } from 'react-router-dom';
+
+export function useShareConfig() {
+  const params = useParams();
+  const id = params.id;
+
+  const showShareButton = !!id;
+
+  return {
+    showShareButton,
+  };
+}

+ 14 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/Share/index.tsx

@@ -0,0 +1,14 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import Button from '@mui/material/Button';
+import { useShareConfig } from '$app/components/layout/Share/Share.hooks';
+
+function ShareButton() {
+  const { showShareButton } = useShareConfig();
+  const { t } = useTranslation();
+
+  if (!showShareButton) return null;
+  return <Button variant={'contained'}>{t('shareAction.buttonText')}</Button>;
+}
+
+export default ShareButton;

+ 54 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/Resizer.tsx

@@ -0,0 +1,54 @@
+import React, { useCallback, useRef } from 'react';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { sidebarActions } from '$app_reducers/sidebar/slice';
+
+const minSidebarWidth = 200;
+
+function Resizer() {
+  const dispatch = useAppDispatch();
+  const width = useAppSelector((state) => state.sidebar.width);
+  const startX = useRef(0);
+  const onResize = useCallback(
+    (e: MouseEvent) => {
+      const diff = e.clientX - startX.current;
+      const newWidth = width + diff;
+
+      if (newWidth < minSidebarWidth) {
+        return;
+      }
+
+      dispatch(sidebarActions.changeWidth(newWidth));
+    },
+    [dispatch, width]
+  );
+
+  const onResizeEnd = useCallback(() => {
+    dispatch(sidebarActions.stopResizing());
+    document.removeEventListener('mousemove', onResize);
+    document.removeEventListener('mouseup', onResizeEnd);
+  }, [onResize, dispatch]);
+
+  const onResizeStart = useCallback(
+    (e: React.MouseEvent) => {
+      startX.current = e.clientX;
+      dispatch(sidebarActions.startResizing());
+      document.addEventListener('mousemove', onResize);
+      document.addEventListener('mouseup', onResizeEnd);
+    },
+    [onResize, onResizeEnd, dispatch]
+  );
+
+  return (
+    <div
+      onMouseDown={onResizeStart}
+      style={{
+        left: `${width - 8}px`,
+      }}
+      className={'fixed top-0 z-10 h-screen cursor-col-resize'}
+    >
+      <div className={'h-full w-2 select-none bg-transparent'}></div>
+    </div>
+  );
+}
+
+export default React.memo(Resizer);

+ 12 - 10
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceUser.tsx → frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/UserInfo.tsx

@@ -1,22 +1,22 @@
+import React, { useState } from 'react';
 import { useAppSelector } from '$app/stores/store';
-import UserSetting from '$app/components/layout/UserSetting';
-import { useState } from 'react';
+import { Avatar } from '@mui/material';
 import PersonOutline from '@mui/icons-material/PersonOutline';
-import { Avatar, IconButton } from '@mui/material';
 import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
+import UserSetting from '../UserSetting';
 
-export const WorkspaceUser = () => {
+function UserInfo() {
   const currentUser = useAppSelector((state) => state.currentUser);
   const [showUserSetting, setShowUserSetting] = useState(false);
 
   return (
-    <div className={'flex items-center justify-between px-2 py-2'}>
+    <>
       <div
         onClick={(e) => {
           e.stopPropagation();
           setShowUserSetting(!showUserSetting);
         }}
-        className={'flex cursor-pointer items-center pl-4 text-text-title'}
+        className={'flex cursor-pointer items-center px-6 text-text-title'}
       >
         <Avatar
           sx={{
@@ -28,13 +28,15 @@ export const WorkspaceUser = () => {
         >
           <PersonOutline />
         </Avatar>
-        <span className={'ml-2'}>{currentUser.displayName}</span>
-        <button className={'ml-1 rounded hover:bg-fill-list-hover'}>
+        <span className={'ml-2 text-sm'}>{currentUser.displayName}</span>
+        <button className={'ml-2 rounded hover:bg-fill-list-hover'}>
           <ArrowDropDown />
         </button>
       </div>
 
       <UserSetting open={showUserSetting} onClose={() => setShowUserSetting(false)} />
-    </div>
+    </>
   );
-};
+}
+
+export default UserInfo;

+ 47 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/index.tsx

@@ -0,0 +1,47 @@
+import React from 'react';
+import { useAppSelector } from '$app/stores/store';
+import { ThemeMode } from '$app/interfaces';
+import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark';
+import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight';
+import CollapseMenuButton from '$app/components/layout/CollapseMenuButton';
+import Resizer from '$app/components/layout/SideBar/Resizer';
+import UserInfo from '$app/components/layout/SideBar/UserInfo';
+import WorkspaceManager from '$app/components/layout/WorkspaceManager';
+
+function SideBar() {
+  const { isCollapsed, width, isResizing } = useAppSelector((state) => state.sidebar);
+  const isDark = useAppSelector((state) => state.currentUser?.userSetting?.themeMode === ThemeMode.Dark);
+
+  return (
+    <>
+      <div
+        style={{
+          width: isCollapsed ? 0 : width,
+          transition: isResizing ? 'none' : 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)',
+        }}
+        className={'relative h-screen select-none overflow-hidden'}
+      >
+        <div className={'flex h-[100vh] flex-col overflow-hidden border-r border-line-divider bg-bg-base'}>
+          <div className={'flex h-[64px] justify-between px-6 py-5'}>
+            {isDark ? <AppflowyLogoDark /> : <AppflowyLogoLight />}
+            <CollapseMenuButton />
+          </div>
+          <div className={'flex h-[36px] items-center'}>
+            <UserInfo />
+          </div>
+
+          <div
+            style={{
+              height: 'calc(100% - 64px - 36px)',
+            }}
+          >
+            <WorkspaceManager />
+          </div>
+        </div>
+      </div>
+      <Resizer />
+    </>
+  );
+}
+
+export default SideBar;

+ 26 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/FontSizeConfig.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { ButtonGroup, Divider } from '@mui/material';
+import Button from '@mui/material/Button';
+
+function FontSizeConfig() {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <div className={'flex flex-col justify-center p-4'}>
+        <div className={'py-2 text-sm text-text-caption'}>{t('moreAction.fontSize')}</div>
+        <div className={'flex items-center justify-around pt-2'}>
+          <ButtonGroup variant='text' color={'inherit'}>
+            <Button>{t('moreAction.small')}</Button>
+            <Button color={'primary'}>{t('moreAction.medium')}</Button>
+            <Button>{t('moreAction.large')}</Button>
+          </ButtonGroup>
+        </div>
+      </div>
+      <Divider />
+    </>
+  );
+}
+
+export default FontSizeConfig;

+ 33 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreButton.tsx

@@ -0,0 +1,33 @@
+import React, { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Drawer, IconButton } from '@mui/material';
+import { Details2Svg } from '$app/components/_shared/svg/Details2Svg';
+import { LogoutOutlined } from '@mui/icons-material';
+import Tooltip from '@mui/material/Tooltip';
+import MoreOptions from '$app/components/layout/TopBar/MoreOptions';
+import { useMoreOptionsConfig } from '$app/components/layout/TopBar/MoreOptions.hooks';
+
+function MoreButton() {
+  const { t } = useTranslation();
+  const [open, setOpen] = React.useState(false);
+  const toggleDrawer = useCallback((open: boolean) => {
+    setOpen(open);
+  }, []);
+  const { showMoreButton } = useMoreOptionsConfig();
+
+  if (!showMoreButton) return null;
+  return (
+    <>
+      <Tooltip placement={'bottom-end'} title={t('moreAction.moreOptions')}>
+        <IconButton onClick={(e) => toggleDrawer(true)} className={'h-8 w-8 text-icon-primary'}>
+          <Details2Svg />
+        </IconButton>
+      </Tooltip>
+      <Drawer anchor={'right'} open={open} onClose={() => toggleDrawer(false)}>
+        <MoreOptions />
+      </Drawer>
+    </>
+  );
+}
+
+export default MoreButton;

+ 29 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.hooks.ts

@@ -0,0 +1,29 @@
+import { useLocation } from 'react-router-dom';
+import { useMemo } from 'react';
+
+export function useMoreOptionsConfig() {
+  const location = useLocation();
+
+  const { type, pageType, id } = useMemo(() => {
+    const [_, type, pageType, id] = location.pathname.split('/');
+
+    return {
+      type,
+      pageType,
+      id,
+    };
+  }, [location.pathname]);
+
+  const showMoreButton = useMemo(() => {
+    return type === 'page';
+  }, [type]);
+
+  const showStyleOptions = useMemo(() => {
+    return type === 'page' && pageType === 'document';
+  }, [pageType, type]);
+
+  return {
+    showMoreButton,
+    showStyleOptions,
+  };
+}

+ 15 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/MoreOptions.tsx

@@ -0,0 +1,15 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import FontSizeConfig from '$app/components/layout/TopBar/FontSizeConfig';
+import { Divider } from '@mui/material';
+import { useLocation } from 'react-router-dom';
+import { useMoreOptionsConfig } from '$app/components/layout/TopBar/MoreOptions.hooks';
+
+function MoreOptions() {
+  const { t } = useTranslation();
+  const { showStyleOptions } = useMoreOptionsConfig();
+
+  return <div className={'flex w-[220px] flex-col'}>{showStyleOptions && <FontSizeConfig />}</div>;
+}
+
+export default MoreOptions;

+ 34 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/TopBar/index.tsx

@@ -0,0 +1,34 @@
+import React from 'react';
+import CollapseMenuButton from '$app/components/layout/CollapseMenuButton';
+import { useAppSelector } from '$app/stores/store';
+import Breadcrumb from '$app/components/layout/Breadcrumb';
+import ShareButton from '$app/components/layout/Share';
+import MoreButton from '$app/components/layout/TopBar/MoreButton';
+
+function TopBar() {
+  const sidebarIsCollapsed = useAppSelector((state) => state.sidebar.isCollapsed);
+
+  return (
+    <div className={'flex h-[64px] select-none border-b border-line-divider p-4'}>
+      {sidebarIsCollapsed && (
+        <div className={'mr-2 py-1'}>
+          <CollapseMenuButton />
+        </div>
+      )}
+      <div className={'flex flex-1 items-center justify-between'}>
+        <div className={'flex-1'}>
+          <Breadcrumb />
+        </div>
+        <div className={'flex items-center justify-end'}>
+          <div className={'mr-2'}>
+            <ShareButton />
+          </div>
+
+          <MoreButton />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export default TopBar;

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/SettingPanel.tsx

@@ -1,7 +1,7 @@
 import React, { useMemo } from 'react';
 import { MenuItem } from './Menu';
-import AppearanceSetting from '$app/components/layout/UserSetting/AppearanceSetting';
-import LanguageSetting from '$app/components/layout/UserSetting/LanguageSetting';
+import AppearanceSetting from './AppearanceSetting';
+import LanguageSetting from './LanguageSetting';
 
 import { UserSetting } from '$app/interfaces';
 

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx

@@ -3,8 +3,8 @@ import Dialog from '@mui/material/Dialog';
 import DialogContent from '@mui/material/DialogContent';
 import DialogTitle from '@mui/material/DialogTitle';
 import Slide, { SlideProps } from '@mui/material/Slide';
-import UserSettingMenu, { MenuItem } from '$app/components/layout/UserSetting/Menu';
-import UserSettingPanel from '$app/components/layout/UserSetting/SettingPanel';
+import UserSettingMenu, { MenuItem } from './Menu';
+import UserSettingPanel from './SettingPanel';
 import { Theme, UserSetting } from '$app/interfaces';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { currentUserActions } from '$app_reducers/current-user/slice';

+ 0 - 69
frontend/appflowy_tauri/src/appflowy_app/components/layout/Workspace.hooks.ts

@@ -1,69 +0,0 @@
-import { foldersActions } from '$app_reducers/folders/slice';
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { IPage, pagesActions } from '$app_reducers/pages/slice';
-import { workspaceActions } from '$app_reducers/workspace/slice';
-import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
-import { useEffect, useState } from 'react';
-import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
-
-export const useWorkspace = () => {
-  const currentUser = useAppSelector((state) => state.currentUser);
-  const appDispatch = useAppDispatch();
-
-  const [userService, setUserService] = useState<UserBackendService | null>(null);
-  const [workspaceService, setWorkspaceService] = useState<WorkspaceBackendService | null>(null);
-  const [isReady, setIsReady] = useState(false);
-
-  useEffect(() => {
-    if (currentUser.id) {
-      setUserService(new UserBackendService(currentUser.id));
-    }
-  }, [currentUser]);
-
-  useEffect(() => {
-    if (!userService) return;
-
-    void (async () => {
-      try {
-        const workspaceSettingPB = await userService.getCurrentWorkspace();
-        const workspace = workspaceSettingPB.workspace;
-        appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
-        appDispatch(foldersActions.clearFolders());
-        appDispatch(pagesActions.clearPages());
-
-        setWorkspaceService(new WorkspaceBackendService(workspace.id));
-      } catch (e1) {
-        // create workspace for first start
-        const workspace = await userService.createWorkspace({ name: 'New Workspace', desc: '' });
-        appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
-
-        appDispatch(foldersActions.clearFolders());
-        appDispatch(pagesActions.clearPages());
-      }
-    })();
-  }, [userService]);
-
-  useEffect(() => {
-    if (!workspaceService) return;
-    void (async () => {
-      const rootViews = await workspaceService.getAllViews();
-      if (rootViews.ok) {
-        appDispatch(
-          pagesActions.addInsidePages({
-            currentPageId: workspaceService.workspaceId,
-            insidePages: rootViews.val.map<IPage>((v) => ({
-              id: v.id,
-              title: v.name,
-              pageType: v.layout,
-              showPagesInside: false,
-              parentPageId: workspaceService.workspaceId,
-            })),
-          })
-        );
-        setIsReady(true);
-      }
-    })();
-  }, [workspaceService]);
-
-  return {};
-};

+ 64 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/MoreButton.tsx

@@ -0,0 +1,64 @@
+import React, { useMemo } from 'react';
+import { WorkspaceItem } from '$app_reducers/workspace/slice';
+import { IconButton } from '@mui/material';
+import MoreIcon from '@mui/icons-material/MoreHoriz';
+import SettingsIcon from '@mui/icons-material/Settings';
+import { useTranslation } from 'react-i18next';
+import { DeleteOutline } from '@mui/icons-material';
+import ButtonPopoverList from '$app/components/_shared/ButtonPopoverList';
+
+function MoreButton({
+  workspace,
+  isHovered,
+  onDelete,
+}: {
+  isHovered: boolean;
+  workspace: WorkspaceItem;
+  onDelete: (id: string) => void;
+}) {
+  const { t } = useTranslation();
+
+  const options = useMemo(() => {
+    return [
+      {
+        key: 'settings',
+        icon: <SettingsIcon />,
+        label: t('settings.title'),
+        onClick: () => {
+          //
+        },
+      },
+      {
+        key: 'delete',
+        icon: <DeleteOutline />,
+        label: t('button.delete'),
+        onClick: () => onDelete(workspace.id),
+      },
+    ];
+  }, [onDelete, t, workspace.id]);
+
+  return (
+    <>
+      <ButtonPopoverList
+        isVisible={isHovered}
+        popoverOrigin={{
+          anchorOrigin: {
+            vertical: 'bottom',
+            horizontal: 'left',
+          },
+          transformOrigin: {
+            vertical: 'top',
+            horizontal: 'left',
+          },
+        }}
+        popoverOptions={options}
+      >
+        <IconButton>
+          <MoreIcon />
+        </IconButton>
+      </ButtonPopoverList>
+    </>
+  );
+}
+
+export default MoreButton;

+ 20 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import { useAppSelector } from '$app/stores/store';
+import NestedPage from '$app/components/layout/NestedPage';
+import { List } from '@mui/material';
+
+function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) {
+  const pageIds = useAppSelector((state) => {
+    return state.pages.childPages[workspaceId];
+  });
+
+  return (
+    <List className={'h-[100%] overflow-y-auto overflow-x-hidden'}>
+      {pageIds?.map((pageId) => (
+        <NestedPage key={pageId} pageId={pageId} />
+      ))}
+    </List>
+  );
+}
+
+export default WorkspaceNestedPages;

+ 44 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx

@@ -0,0 +1,44 @@
+import React, { useEffect, useMemo } from 'react';
+import AddSvg from '$app/components/_shared/svg/AddSvg';
+import { useTranslation } from 'react-i18next';
+import { WorkspaceController } from '$app/stores/effects/workspace/workspace_controller';
+import { ViewLayoutPB } from '@/services/backend';
+import { useNavigate } from 'react-router-dom';
+
+function NewPageButton({ workspaceId }: { workspaceId: string }) {
+  const { t } = useTranslation();
+  const controller = useMemo(() => new WorkspaceController(workspaceId), [workspaceId]);
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    return () => {
+      controller.dispose();
+    };
+  }, [controller]);
+
+  return (
+    <div className={'flex h-[60px] w-full items-center border-t border-line-divider px-6 py-5'}>
+      <button
+        onClick={async () => {
+          const { id } = await controller.createView({
+            name: t('document.title.placeholder'),
+            layout: ViewLayoutPB.Document,
+            parent_view_id: workspaceId,
+          });
+
+          navigate(`/page/document/${id}`);
+        }}
+        className={'flex items-center hover:text-fill-default'}
+      >
+        <div className={'mr-2 rounded-full bg-fill-default'}>
+          <div className={'h-[24px] w-[24px] text-content-on-fill'}>
+            <AddSvg />
+          </div>
+        </div>
+        {t('newPageText')}
+      </button>
+    </div>
+  );
+}
+
+export default NewPageButton;

+ 32 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
+import MenuItem from '@mui/material/MenuItem';
+import { useTranslation } from 'react-i18next';
+import { useLocation, useNavigate } from 'react-router-dom';
+
+function TrashButton() {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const currentPathType = useLocation().pathname.split('/')[1];
+  const navigateToTrash = () => {
+    navigate('/trash');
+  };
+
+  return (
+    <MenuItem
+      selected={currentPathType === 'trash'}
+      onClick={navigateToTrash}
+      style={{
+        borderRadius: '8px',
+      }}
+      className={'flex w-[100%] items-center'}
+    >
+      <div className='h-6 w-6'>
+        <TrashSvg />
+      </div>
+      <span className={'ml-2'}>{t('trash.text')}</span>
+    </MenuItem>
+  );
+}
+
+export default TrashButton;

+ 116 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts

@@ -0,0 +1,116 @@
+import { useCallback, useEffect, useMemo } from 'react';
+import { WorkspaceController } from '$app/stores/effects/workspace/workspace_controller';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { workspaceActions, WorkspaceItem } from '$app_reducers/workspace/slice';
+import { WorkspaceManagerController } from '$app/stores/effects/workspace/workspace_manager_controller';
+import { Page, pagesActions } from '$app_reducers/pages/slice';
+
+export function useLoadWorkspaces() {
+  const dispatch = useAppDispatch();
+  const { workspaces, currentWorkspace } = useAppSelector((state) => state.workspace);
+
+  const onWorkspacesChanged = useCallback(
+    (data: { workspaces: WorkspaceItem[]; currentWorkspace: WorkspaceItem }) => {
+      dispatch(workspaceActions.onWorkspacesChanged(data));
+    },
+    [dispatch]
+  );
+
+  const controller = useMemo(() => {
+    return new WorkspaceManagerController();
+  }, []);
+
+  useEffect(() => {
+    void (async () => {
+      const workspaces = await controller.getWorkspaces();
+      const currentWorkspace = await controller.getCurrentWorkspace();
+
+      await controller.subscribe({
+        onWorkspacesChanged,
+      });
+      dispatch(
+        workspaceActions.initWorkspaces({
+          workspaces,
+          currentWorkspace,
+        })
+      );
+    })();
+
+    return () => {
+      controller.dispose();
+    };
+  }, [controller, dispatch, onWorkspacesChanged]);
+
+  return {
+    workspaces,
+    currentWorkspace,
+  };
+}
+
+export function useLoadWorkspace(workspace: WorkspaceItem) {
+  const { id } = workspace;
+  const dispatch = useAppDispatch();
+
+  const controller = useMemo(() => {
+    return new WorkspaceController(id);
+  }, [id]);
+
+  const onWorkspaceChanged = useCallback(
+    (data: WorkspaceItem) => {
+      dispatch(workspaceActions.onWorkspaceChanged(data));
+    },
+    [dispatch]
+  );
+
+  const onWorkspaceDeleted = useCallback(() => {
+    dispatch(workspaceActions.onWorkspaceDeleted(id));
+  }, [dispatch, id]);
+
+  const openWorkspace = useCallback(async () => {
+    await controller.open();
+  }, [controller]);
+
+  const deleteWorkspace = useCallback(async () => {
+    await controller.delete();
+  }, [controller]);
+
+  const onChildPagesChanged = useCallback(
+    (childPages: Page[]) => {
+      dispatch(
+        pagesActions.addChildPages({
+          id,
+          childPages,
+        })
+      );
+    },
+    [dispatch, id]
+  );
+
+  useEffect(() => {
+    void (async () => {
+      const childPages = await controller.getChildPages();
+
+      dispatch(
+        pagesActions.addChildPages({
+          id,
+          childPages,
+        })
+      );
+      await controller.subscribe({
+        onWorkspaceChanged,
+        onWorkspaceDeleted,
+        onChildPagesChanged,
+      });
+    })();
+
+    return () => {
+      controller.dispose();
+    };
+  }, [controller, dispatch, id, onChildPagesChanged, onWorkspaceChanged, onWorkspaceDeleted]);
+
+  return {
+    openWorkspace,
+    controller,
+    deleteWorkspace,
+  };
+}

+ 26 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import { WorkspaceItem } from '$app_reducers/workspace/slice';
+import NestedViews from '$app/components/layout/WorkspaceManager/NestedPages';
+import { useLoadWorkspace } from '$app/components/layout/WorkspaceManager/Workspace.hooks';
+import WorkspaceTitle from '$app/components/layout/WorkspaceManager/WorkspaceTitle';
+
+function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) {
+  const { openWorkspace, deleteWorkspace } = useLoadWorkspace(workspace);
+
+  return (
+    <div className={'flex flex-col'}>
+      <div
+        style={{
+          height: opened ? 'auto' : 0,
+          overflow: 'hidden',
+          transition: 'height 0.2s ease-in-out',
+        }}
+      >
+        {/*<WorkspaceTitle workspace={workspace} openWorkspace={openWorkspace} onDelete={onDelete} />*/}
+        <NestedViews workspaceId={workspace.id} />
+      </div>
+    </div>
+  );
+}
+
+export default Workspace;

+ 38 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/WorkspaceTitle.tsx

@@ -0,0 +1,38 @@
+import React, { useState } from 'react';
+import MoreButton from '$app/components/layout/WorkspaceManager/MoreButton';
+import MenuItem from '@mui/material/MenuItem';
+import { WorkspaceItem } from '$app_reducers/workspace/slice';
+
+function WorkspaceTitle({
+  workspace,
+  openWorkspace,
+  onDelete,
+}: {
+  openWorkspace: () => void;
+  onDelete: (id: string) => void;
+  workspace: WorkspaceItem;
+}) {
+  const [isHovered, setIsHovered] = useState(false);
+
+  return (
+    <MenuItem
+      onClick={() => openWorkspace()}
+      onMouseEnter={() => {
+        setIsHovered(true);
+      }}
+      onMouseLeave={() => {
+        setIsHovered(false);
+      }}
+      className={'hover:bg-fill-list-active'}
+    >
+      <div className={'flex w-[100%] items-center justify-between'}>
+        <div className={'flex-1 font-bold text-text-caption'}>{workspace.name}</div>
+        <div className='flex h-[23px] w-auto items-center justify-end'>
+          <MoreButton workspace={workspace} isHovered={isHovered} onDelete={onDelete} />
+        </div>
+      </div>
+    </MenuItem>
+  );
+}
+
+export default WorkspaceTitle;

+ 26 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import NewPageButton from '$app/components/layout/WorkspaceManager/NewPageButton';
+import { useLoadWorkspaces } from '$app/components/layout/WorkspaceManager/Workspace.hooks';
+import Workspace from './Workspace';
+import { List } from '@mui/material';
+import TrashButton from '$app/components/layout/WorkspaceManager/TrashButton';
+
+function WorkspaceManager() {
+  const { workspaces, currentWorkspace } = useLoadWorkspaces();
+
+  return (
+    <div className={'flex h-[100%] flex-col justify-between'}>
+      <List className={'flex-1 overflow-y-auto overflow-x-hidden'}>
+        {workspaces.map((workspace) => (
+          <Workspace opened={currentWorkspace?.id === workspace.id} key={workspace.id} workspace={workspace} />
+        ))}
+      </List>
+      <div className={'flex h-[48px] w-[100%] items-center px-2'}>
+        <TrashButton />
+      </div>
+      {currentWorkspace && <NewPageButton workspaceId={currentWorkspace.id} />}
+    </div>
+  );
+}
+
+export default WorkspaceManager;

+ 27 - 10
frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts

@@ -27,20 +27,16 @@ import { TypeOptionController } from '../../stores/effects/database/field/type_o
 import { makeSingleSelectTypeOptionContext } from '../../stores/effects/database/field/type_option/type_option_context';
 import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
 import { Log } from '$app/utils/log';
-import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
-import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
+import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller';
 
-// Create a database view for specific layout type
+// Create a database page for specific layout type
 // Do not use it production code. Just for testing
 export async function createTestDatabaseView(layout: ViewLayoutPB): Promise<ViewPB> {
   const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
-  const wsSvc = new WorkspaceBackendService(workspaceSetting.workspace.id);
-  const viewRes = await wsSvc.createView({ name: 'New Grid', layoutType: layout });
-  if (viewRes.ok) {
-    return viewRes.val;
-  } else {
-    throw Error(viewRes.val.msg);
-  }
+  const wsSvc = new WorkspaceController(workspaceSetting.workspace.id);
+  const viewRes = await wsSvc.createView({ name: 'New Grid', layout });
+
+  return viewRes;
 }
 
 export async function openTestDatabase(viewId: string): Promise<DatabaseController> {
@@ -56,9 +52,11 @@ export async function assertTextCell(
   const cellController = await makeTextCellController(fieldId, rowInfo, databaseController).then((result) =>
     result.unwrap()
   );
+
   cellController.subscribeChanged({
     onCellChanged: (value) => {
       const cellContent = value.unwrap();
+
       if (cellContent !== expectedContent) {
         throw Error('Text cell content is not match');
       }
@@ -76,6 +74,7 @@ export async function editTextCell(
   const cellController = await makeTextCellController(fieldId, rowInfo, databaseController).then((result) =>
     result.unwrap()
   );
+
   await cellController.saveCellData(content);
 }
 
@@ -87,6 +86,7 @@ export async function makeTextCellController(
   const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.RichText, databaseController).then(
     (result) => result.unwrap()
   );
+
   return Some(builder.build() as TextCellController);
 }
 
@@ -98,6 +98,7 @@ export async function makeNumberCellController(
   const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Number, databaseController).then(
     (result) => result.unwrap()
   );
+
   return Some(builder.build() as NumberCellController);
 }
 
@@ -109,6 +110,7 @@ export async function makeSingleSelectCellController(
   const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.SingleSelect, databaseController).then(
     (result) => result.unwrap()
   );
+
   return Some(builder.build() as SelectOptionCellController);
 }
 
@@ -120,6 +122,7 @@ export async function makeMultiSelectCellController(
   const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.MultiSelect, databaseController).then(
     (result) => result.unwrap()
   );
+
   return Some(builder.build() as SelectOptionCellController);
 }
 
@@ -131,6 +134,7 @@ export async function makeDateCellController(
   const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
     (result) => result.unwrap()
   );
+
   return Some(builder.build() as DateCellController);
 }
 
@@ -142,6 +146,7 @@ export async function makeCheckboxCellController(
   const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.Checkbox, databaseController).then(
     (result) => result.unwrap()
   );
+
   return Some(builder.build() as CheckboxCellController);
 }
 
@@ -153,6 +158,7 @@ export async function makeURLCellController(
   const builder = await makeCellControllerBuilder(fieldId, rowInfo, FieldType.DateTime, databaseController).then(
     (result) => result.unwrap()
   );
+
   return Some(builder.build() as URLCellController);
 }
 
@@ -167,8 +173,10 @@ export async function makeCellControllerBuilder(
   const fieldController = databaseController.fieldController;
   const rowController = new RowController(rowInfo, fieldController, rowCache);
   const cellByFieldId = await rowController.loadCells();
+
   for (const cellIdentifier of cellByFieldId.values()) {
     const builder = new CellControllerBuilder(cellIdentifier, cellCache, fieldController);
+
     if (cellIdentifier.fieldId === fieldId) {
       return Some(builder);
     }
@@ -179,6 +187,7 @@ export async function makeCellControllerBuilder(
 
 export function findFirstFieldInfoWithFieldType(rowInfo: RowInfo, fieldType: FieldType) {
   const fieldInfo = rowInfo.fieldInfos.find((element) => element.field.field_type === fieldType);
+
   if (fieldInfo === undefined) {
     return None;
   } else {
@@ -189,6 +198,7 @@ export function findFirstFieldInfoWithFieldType(rowInfo: RowInfo, fieldType: Fie
 export async function assertFieldName(viewId: string, fieldId: string, fieldType: FieldType, expected: string) {
   const svc = new TypeOptionBackendService(viewId);
   const typeOptionPB = await svc.getTypeOption(fieldId, fieldType).then((result) => result.unwrap());
+
   if (typeOptionPB.field.name !== expected) {
     throw Error('Expect field name:' + expected + 'but receive:' + typeOptionPB.field.name);
   }
@@ -197,6 +207,7 @@ export async function assertFieldName(viewId: string, fieldId: string, fieldType
 export async function assertNumberOfFields(viewId: string, expected: number) {
   const svc = new DatabaseBackendService(viewId);
   const databasePB = await svc.openDatabase().then((result) => result.unwrap());
+
   if (databasePB.fields.length !== expected) {
     throw Error('Expect number of fields:' + expected + 'but receive:' + databasePB.fields.length);
   }
@@ -205,6 +216,7 @@ export async function assertNumberOfFields(viewId: string, expected: number) {
 export async function assertNumberOfRows(viewId: string, expected: number) {
   const svc = new DatabaseBackendService(viewId);
   const databasePB = await svc.openDatabase().then((result) => result.unwrap());
+
   if (databasePB.rows.length !== expected) {
     throw Error('Expect number of rows:' + expected + 'but receive:' + databasePB.rows.length);
   }
@@ -212,9 +224,11 @@ export async function assertNumberOfRows(viewId: string, expected: number) {
 
 export async function assertNumberOfRowsInGroup(viewId: string, groupId: string, expected: number) {
   const svc = new DatabaseBackendService(viewId);
+
   await svc.openDatabase();
 
   const group = await svc.getGroup(groupId).then((result) => result.unwrap());
+
   if (group.rows.length !== expected) {
     throw Error('Expect number of rows in group:' + expected + 'but receive:' + group.rows.length);
   }
@@ -229,10 +243,13 @@ export async function createSingleSelectOptions(viewId: string, fieldInfo: Field
     .then((result) => result.unwrap());
 
   const backendSvc = new SelectOptionBackendService(viewId, fieldInfo.field.id);
+
   for (const optionName of optionNames) {
     const option = await backendSvc.createOption({ name: optionName }).then((result) => result.unwrap());
+
     singleSelectTypeOptionPB.options.splice(0, 0, option);
   }
+
   await singleSelectTypeOptionContext.setTypeOption(singleSelectTypeOptionPB);
   return singleSelectTypeOptionContext;
 }

+ 5 - 9
frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts

@@ -1,15 +1,11 @@
 import { ViewLayoutPB, WorkspaceSettingPB } from '@/services/backend';
 import { FolderEventGetCurrentWorkspace } from '@/services/backend/events/flowy-folder2';
-import {WorkspaceBackendService} from "$app/stores/effects/folder/workspace/workspace_bd_svc";
+import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller';
 
 export async function createTestDocument() {
   const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
-  const appService = new WorkspaceBackendService(workspaceSetting.workspace.id);
-  const result = await appService.createView({ name: 'New Document', layoutType: ViewLayoutPB.Document });
-  if (result.ok) {
-    return result.val;
-  }
-  else {
-    throw Error(result.val.msg);
-  }
+  const appService = new WorkspaceController(workspaceSetting.workspace.id);
+  const result = await appService.createView({ name: 'New Document', layout: ViewLayoutPB.Document });
+
+  return result;
 }

+ 14 - 9
frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
 import { useAppSelector } from '$app/stores/store';
-import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
+import { WorkspaceController } from '../../stores/effects/workspace/workspace_controller';
 import { ViewLayoutPB, ViewPB } from '@/services/backend';
 
 const testCreateFolder = async (userId?: number) => {
@@ -9,36 +9,41 @@ const testCreateFolder = async (userId?: number) => {
     console.log('user is not logged in');
     return;
   }
+
   console.log('test create views');
   const userBackendService: UserBackendService = new UserBackendService(userId);
   const workspaces = await userBackendService.getWorkspaces();
+
   if (workspaces.ok) {
     console.log('workspaces: ', workspaces.val.toObject());
   }
+
   const currentWorkspace = await userBackendService.getCurrentWorkspace();
 
-  const workspaceService = new WorkspaceBackendService(currentWorkspace.workspace.id);
+  const workspaceService = new WorkspaceController(currentWorkspace.workspace.id);
   const rootViews: ViewPB[] = [];
+
   for (let i = 1; i <= 3; i++) {
     const result = await workspaceService.createView({
       name: `test board ${i}`,
       desc: 'test description',
-      layoutType: ViewLayoutPB.Board,
+      layout: ViewLayoutPB.Board,
     });
-    if (result.ok) {
-      rootViews.push(result.val);
-    }
+
+    rootViews.push(result);
   }
+
   for (let i = 1; i <= 3; i++) {
     const result = await workspaceService.createView({
       name: `test board 1 ${i}`,
       desc: 'test description',
-      layoutType: ViewLayoutPB.Board,
-      parentViewId: rootViews[0].id,
+      layout: ViewLayoutPB.Board,
+      parent_view_id: rootViews[0].id,
     });
   }
 
-  const allApps = await workspaceService.getAllViews();
+  const allApps = await workspaceService.getChildPages();
+
   console.log(allApps);
 };
 

+ 44 - 0
frontend/appflowy_tauri/src/appflowy_app/components/trash/ConfirmDialog.tsx

@@ -0,0 +1,44 @@
+import React from 'react';
+import DialogContent from '@mui/material/DialogContent';
+import { Button, DialogActions } from '@mui/material';
+import Dialog from '@mui/material/Dialog';
+import { useTranslation } from 'react-i18next';
+
+interface Props {
+  open: boolean;
+  title: string;
+  caption: string;
+  onOk: () => Promise<void>;
+  onClose: () => void;
+}
+
+function ConfirmDialog({ open, title, caption, onOk, onClose }: Props) {
+  const { t } = useTranslation();
+
+  return (
+    <Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
+      <DialogContent className={'flex w-[540px] flex-col items-center justify-center'}>
+        <div className={'text-md m-2 font-bold'}>{title}</div>
+        <div className={'m-1 text-sm text-text-caption'}>{caption}</div>
+      </DialogContent>
+      <DialogActions>
+        <Button variant={'outlined'} onClick={onClose}>
+          {t('button.Cancel')}
+        </Button>
+        <Button
+          variant={'contained'}
+          onClick={async () => {
+            try {
+              await onOk();
+              onClose();
+            } catch (e) {}
+          }}
+        >
+          {t('button.delete')}
+        </Button>
+      </DialogActions>
+    </Dialog>
+  );
+}
+
+export default ConfirmDialog;

+ 82 - 0
frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.hooks.ts

@@ -0,0 +1,82 @@
+import { useEffect, useMemo, useState } from 'react';
+import { TrashController } from '$app/stores/effects/workspace/trash/controller';
+import { TrashPB } from '@/services/backend';
+
+export function useLoadTrash() {
+  const [trash, setTrash] = useState<TrashPB[]>([]);
+
+  const controller = useMemo(() => {
+    return new TrashController();
+  }, []);
+
+  useEffect(() => {
+    void (async () => {
+      const trash = await controller.getTrash();
+
+      setTrash(trash);
+    })();
+  }, [controller]);
+
+  useEffect(() => {
+    controller.subscribe({
+      onTrashChanged: (trash) => {
+        setTrash(trash);
+      },
+    });
+    return () => {
+      controller.dispose();
+    };
+  }, [controller]);
+
+  return {
+    trash,
+  };
+}
+
+export function useTrashActions() {
+  const [restoreAllDialogOpen, setRestoreAllDialogOpen] = useState(false);
+  const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
+
+  const controller = useMemo(() => {
+    return new TrashController();
+  }, []);
+
+  useEffect(() => {
+    return () => {
+      controller.dispose();
+    };
+  }, [controller]);
+
+  const onClickRestoreAll = () => {
+    setRestoreAllDialogOpen(true);
+  };
+
+  const onClickDeleteAll = () => {
+    setDeleteAllDialogOpen(true);
+  };
+
+  const closeDislog = () => {
+    setRestoreAllDialogOpen(false);
+    setDeleteAllDialogOpen(false);
+  };
+
+  return {
+    onPutback: async (id: string) => {
+      await controller.putback(id);
+    },
+    onDelete: async (ids: string[]) => {
+      await controller.delete(ids);
+    },
+    onDeleteAll: async () => {
+      await controller.deleteAll();
+    },
+    onRestoreAll: async () => {
+      await controller.restoreAll();
+    },
+    onClickRestoreAll,
+    onClickDeleteAll,
+    restoreAllDialogOpen,
+    deleteAllDialogOpen,
+    closeDislog,
+  };
+}

+ 78 - 0
frontend/appflowy_tauri/src/appflowy_app/components/trash/Trash.tsx

@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import Button from '@mui/material/Button';
+import { DeleteOutline, RestoreOutlined } from '@mui/icons-material';
+import { useLoadTrash, useTrashActions } from '$app/components/trash/Trash.hooks';
+import { Divider, List } from '@mui/material';
+import TrashItem from '$app/components/trash/TrashItem';
+import ConfirmDialog from '$app/components/trash/ConfirmDialog';
+
+function Trash() {
+  const { t } = useTranslation();
+  const { trash } = useLoadTrash();
+  const {
+    onPutback,
+    onDelete,
+    onClickRestoreAll,
+    onClickDeleteAll,
+    restoreAllDialogOpen,
+    deleteAllDialogOpen,
+    onRestoreAll,
+    onDeleteAll,
+    closeDislog,
+  } = useTrashActions();
+  const [hoverId, setHoverId] = useState('');
+
+  return (
+    <div className={'flex flex-col'}>
+      <div className={'flex items-center justify-between'}>
+        <div className={'text-2xl font-bold'}>{t('trash.text')}</div>
+        <div className={'flex items-center justify-end'}>
+          <Button color={'inherit'} onClick={(e) => onClickRestoreAll()}>
+            <RestoreOutlined />
+            <span className={'ml-1'}>{t('trash.restoreAll')}</span>
+          </Button>
+          <Button color={'error'} onClick={(e) => onClickDeleteAll()}>
+            <DeleteOutline />
+            <span className={'ml-1'}>{t('trash.deleteAll')}</span>
+          </Button>
+        </div>
+      </div>
+      <div className={'flex justify-around p-6 px-2 text-text-caption'}>
+        <div className={'w-[40%]'}>{t('trash.pageHeader.fileName')}</div>
+        <div className={'flex-1'}>{t('trash.pageHeader.lastModified')}</div>
+        <div className={'flex-1'}>{t('trash.pageHeader.created')}</div>
+        <div className={'w-[64px]'}></div>
+      </div>
+      <Divider />
+      <List>
+        {trash.map((item) => (
+          <TrashItem
+            item={item}
+            key={item.id}
+            onPutback={onPutback}
+            onDelete={onDelete}
+            hoverId={hoverId}
+            setHoverId={setHoverId}
+          />
+        ))}
+      </List>
+      <ConfirmDialog
+        open={restoreAllDialogOpen}
+        title={t('trash.confirmRestoreAll.title')}
+        caption={t('trash.confirmRestoreAll.caption')}
+        onOk={onRestoreAll}
+        onClose={closeDislog}
+      />
+      <ConfirmDialog
+        open={deleteAllDialogOpen}
+        title={t('trash.confirmDeleteAll.title')}
+        caption={t('trash.confirmDeleteAll.caption')}
+        onOk={onDeleteAll}
+        onClose={closeDislog}
+      />
+    </div>
+  );
+}
+
+export default Trash;

+ 64 - 0
frontend/appflowy_tauri/src/appflowy_app/components/trash/TrashItem.tsx

@@ -0,0 +1,64 @@
+import React from 'react';
+import dayjs from 'dayjs';
+import { IconButton, ListItem } from '@mui/material';
+import { DeleteOutline, RestoreOutlined } from '@mui/icons-material';
+import { TrashPB } from '@/services/backend';
+import Tooltip from '@mui/material/Tooltip';
+import { useTranslation } from 'react-i18next';
+
+function TrashItem({
+  item,
+  hoverId,
+  setHoverId,
+
+  onDelete,
+  onPutback,
+}: {
+  setHoverId: (id: string) => void;
+  item: TrashPB;
+  hoverId: string;
+  onPutback: (id: string) => void;
+  onDelete: (ids: string[]) => void;
+}) {
+  const { t } = useTranslation();
+
+  return (
+    <ListItem
+      onMouseEnter={(e) => {
+        setHoverId(item.id);
+      }}
+      onMouseLeave={(e) => {
+        setHoverId('');
+      }}
+      key={item.id}
+      style={{
+        paddingInline: 0,
+      }}
+    >
+      <div className={'flex w-[100%] items-center justify-around rounded-lg px-2 py-3 hover:bg-fill-list-hover'}>
+        <div className={'w-[40%] text-left'}>{item.name}</div>
+        <div className={'flex-1'}>{dayjs.unix(item.modified_time).format('MM/DD/YYYY hh:mm A')}</div>
+        <div className={'flex-1'}>{dayjs.unix(item.create_time).format('MM/DD/YYYY hh:mm A')}</div>
+        <div
+          style={{
+            visibility: hoverId === item.id ? 'visible' : 'hidden',
+          }}
+          className={'w-[64px]'}
+        >
+          <Tooltip placement={'top-start'} title={t('button.putback')}>
+            <IconButton onClick={(e) => onPutback(item.id)} className={'mr-2'}>
+              <RestoreOutlined />
+            </IconButton>
+          </Tooltip>
+          <Tooltip placement={'top-start'} title={t('button.delete')}>
+            <IconButton color={'error'} onClick={(e) => onDelete([item.id])}>
+              <DeleteOutline />
+            </IconButton>
+          </Tooltip>
+        </div>
+      </div>
+    </ListItem>
+  );
+}
+
+export default TrashItem;

+ 8 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/index.ts

@@ -0,0 +1,8 @@
+import { ViewLayoutPB } from '@/services/backend';
+
+export const pageTypeMap = {
+  [ViewLayoutPB.Document]: 'document',
+  [ViewLayoutPB.Board]: 'board',
+  [ViewLayoutPB.Grid]: 'grid',
+  [ViewLayoutPB.Calendar]: 'calendar',
+};

+ 0 - 49
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_bd_svc.ts

@@ -1,49 +0,0 @@
-import { UpdateViewPayloadPB, RepeatedViewIdPB, ViewPB, FlowyError, ViewIdPB } from '@/services/backend';
-import {
-  FolderEventDeleteView,
-  FolderEventDuplicateView,
-  FolderEventReadView,
-  FolderEventUpdateView,
-} from '@/services/backend/events/flowy-folder2';
-import { Ok, Result } from 'ts-results';
-
-export class ViewBackendService {
-  constructor(public readonly viewId: string) {}
-
-  getChildViews = async (): Promise<Result<ViewPB[], FlowyError>> => {
-    const payload = ViewIdPB.fromObject({ value: this.viewId });
-    const result = await FolderEventReadView(payload);
-    if (result.ok) {
-      return Ok(result.val.child_views);
-    } else {
-      return result;
-    }
-  };
-
-  update = (params: { name?: string; desc?: string }) => {
-    const payload = UpdateViewPayloadPB.fromObject({ view_id: this.viewId });
-
-    if (params.name !== undefined) {
-      payload.name = params.name;
-    }
-    if (params.desc !== undefined) {
-      payload.desc = params.desc;
-    }
-
-    return FolderEventUpdateView(payload);
-  };
-
-  delete = () => {
-    const payload = RepeatedViewIdPB.fromObject({ items: [this.viewId] });
-    return FolderEventDeleteView(payload);
-  };
-
-  duplicate = async () => {
-    const view = await FolderEventReadView(ViewIdPB.fromObject({ value: this.viewId }));
-    if (view.ok) {
-      return FolderEventDuplicateView(view.val);
-    } else {
-      return view;
-    }
-  };
-}

+ 0 - 101
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_observer.ts

@@ -1,101 +0,0 @@
-import { Ok, Result } from 'ts-results';
-import { DeletedViewPB, FolderNotification, ViewPB, FlowyError } from '@/services/backend';
-import { ChangeNotifier } from '$app/utils/change_notifier';
-import { FolderNotificationObserver } from '../notifications/observer';
-
-type DeleteViewNotifyValue = Result<ViewPB, FlowyError>;
-type UpdateViewNotifyValue = Result<ViewPB, FlowyError>;
-type RestoreViewNotifyValue = Result<ViewPB, FlowyError>;
-type MoveToTrashViewNotifyValue = Result<DeletedViewPB, FlowyError>;
-
-export class ViewObserver {
-  private _deleteViewNotifier = new ChangeNotifier<DeleteViewNotifyValue>();
-  private _updateViewNotifier = new ChangeNotifier<UpdateViewNotifyValue>();
-  private _restoreViewNotifier = new ChangeNotifier<RestoreViewNotifyValue>();
-  private _moveToTrashNotifier = new ChangeNotifier<MoveToTrashViewNotifyValue>();
-  private _childViewsNotifier = new ChangeNotifier<void>();
-  private _listener?: FolderNotificationObserver;
-
-  constructor(public readonly viewId: string) {}
-
-  subscribe = async (callbacks: {
-    onViewUpdate?: (value: UpdateViewNotifyValue) => void;
-    onViewDelete?: (value: DeleteViewNotifyValue) => void;
-    onViewRestored?: (value: RestoreViewNotifyValue) => void;
-    onViewMoveToTrash?: (value: MoveToTrashViewNotifyValue) => void;
-    onChildViewsChanged?: () => void;
-  }) => {
-    if (callbacks.onViewDelete !== undefined) {
-      this._deleteViewNotifier.observer?.subscribe(callbacks.onViewDelete);
-    }
-
-    if (callbacks.onViewUpdate !== undefined) {
-      this._updateViewNotifier.observer?.subscribe(callbacks.onViewUpdate);
-    }
-
-    if (callbacks.onViewRestored !== undefined) {
-      this._restoreViewNotifier.observer?.subscribe(callbacks.onViewRestored);
-    }
-
-    if (callbacks.onViewMoveToTrash !== undefined) {
-      this._moveToTrashNotifier.observer?.subscribe(callbacks.onViewMoveToTrash);
-    }
-
-    if (callbacks.onChildViewsChanged !== undefined) {
-      this._childViewsNotifier.observer?.subscribe(callbacks.onChildViewsChanged);
-    }
-
-    this._listener = new FolderNotificationObserver({
-      viewId: this.viewId,
-      parserHandler: (notification, result) => {
-        switch (notification) {
-          case FolderNotification.DidUpdateView:
-            if (result.ok) {
-              this._updateViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
-            } else {
-              this._updateViewNotifier.notify(result);
-            }
-            break;
-          case FolderNotification.DidDeleteView:
-            if (result.ok) {
-              this._deleteViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
-            } else {
-              this._deleteViewNotifier.notify(result);
-            }
-            break;
-          case FolderNotification.DidRestoreView:
-            if (result.ok) {
-              this._restoreViewNotifier.notify(Ok(ViewPB.deserializeBinary(result.val)));
-            } else {
-              this._restoreViewNotifier.notify(result);
-            }
-            break;
-          case FolderNotification.DidMoveViewToTrash:
-            if (result.ok) {
-              this._moveToTrashNotifier.notify(Ok(DeletedViewPB.deserializeBinary(result.val)));
-            } else {
-              this._moveToTrashNotifier.notify(result);
-            }
-            break;
-          case FolderNotification.DidUpdateChildViews:
-            if (result.ok) {
-              this._childViewsNotifier?.notify();
-            }
-            break;
-          default:
-            break;
-        }
-      },
-    });
-    await this._listener.start();
-  };
-
-  unsubscribe = async () => {
-    this._deleteViewNotifier.unsubscribe();
-    this._updateViewNotifier.unsubscribe();
-    this._restoreViewNotifier.unsubscribe();
-    this._moveToTrashNotifier.unsubscribe();
-    this._childViewsNotifier.unsubscribe();
-    await this._listener?.stop();
-  };
-}

+ 0 - 71
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_bd_svc.ts

@@ -1,71 +0,0 @@
-import { Err, Ok, Result } from 'ts-results';
-import {
-  FolderEventCreateView,
-  FolderEventMoveView,
-  FolderEventReadWorkspaceViews,
-  FolderEventReadAllWorkspaces,
-  ViewPB,
-} from '@/services/backend/events/flowy-folder2';
-import { CreateViewPayloadPB, FlowyError, MoveViewPayloadPB, ViewLayoutPB, WorkspaceIdPB } from '@/services/backend';
-import assert from 'assert';
-
-export class WorkspaceBackendService {
-  constructor(public readonly workspaceId: string) {}
-
-  createView = async (params: {
-    name: string;
-    desc?: string;
-    layoutType: ViewLayoutPB;
-    parentViewId?: string;
-    /// The initial data should be the JSON of the document
-    /// For example: {"document":{"type":"editor","children":[]}}
-    initialData?: string;
-  }) => {
-    const encoder = new TextEncoder();
-    const payload = CreateViewPayloadPB.fromObject({
-      parent_view_id: params.parentViewId ?? this.workspaceId,
-      name: params.name,
-      desc: params.desc || '',
-      layout: params.layoutType,
-      initial_data: encoder.encode(params.initialData || ''),
-    });
-
-    return FolderEventCreateView(payload);
-  };
-
-  getWorkspace = () => {
-    const payload = WorkspaceIdPB.fromObject({ value: this.workspaceId });
-    return FolderEventReadAllWorkspaces(payload).then((result) => {
-      if (result.ok) {
-        const workspaces = result.val.items;
-        if (workspaces.length === 0) {
-          return Err(FlowyError.fromObject({ msg: 'workspace not found' }));
-        } else {
-          assert(workspaces.length === 1);
-          return Ok(workspaces[0]);
-        }
-      } else {
-        return Err(result.val);
-      }
-    });
-  };
-
-  getAllViews: () => Promise<Result<ViewPB[], FlowyError>> = async () => {
-    const payload = WorkspaceIdPB.fromObject({ value: this.workspaceId });
-    const result = await FolderEventReadWorkspaceViews(payload);
-    if (result.ok) {
-      return Ok(result.val.items);
-    } else {
-      return result;
-    }
-  };
-
-  moveView = (params: { viewId: string; fromIndex: number; toIndex: number }) => {
-    const payload = MoveViewPayloadPB.fromObject({
-      view_id: params.viewId,
-      from: params.fromIndex,
-      to: params.toIndex,
-    });
-    return FolderEventMoveView(payload);
-  };
-}

+ 0 - 57
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_observer.ts

@@ -1,57 +0,0 @@
-import { Ok, Result } from "ts-results";
-import { FolderNotification, WorkspacePB, FlowyError, RepeatedViewPB, ViewPB } from "@/services/backend";
-import { ChangeNotifier } from "$app/utils/change_notifier";
-import { FolderNotificationObserver } from "../notifications/observer";
-
-export type AppListNotifyValue = Result<ViewPB[], FlowyError>;
-export type AppListNotifyCallback = (value: AppListNotifyValue) => void;
-export type WorkspaceNotifyValue = Result<WorkspacePB, FlowyError>;
-export type WorkspaceNotifyCallback = (value: WorkspaceNotifyValue) => void;
-
-export class WorkspaceObserver {
-  private appListNotifier = new ChangeNotifier<AppListNotifyValue>();
-  private workspaceNotifier = new ChangeNotifier<WorkspaceNotifyValue>();
-  private listener?: FolderNotificationObserver;
-
-  constructor(public readonly workspaceId: string) {
-  }
-
-  subscribe = async (callbacks: {
-    onAppListChanged: AppListNotifyCallback;
-    onWorkspaceChanged: WorkspaceNotifyCallback;
-  }) => {
-    this.appListNotifier?.observer?.subscribe(callbacks.onAppListChanged);
-    this.workspaceNotifier?.observer?.subscribe(callbacks.onWorkspaceChanged);
-
-    this.listener = new FolderNotificationObserver({
-      viewId: this.workspaceId,
-      parserHandler: (notification, result) => {
-        switch (notification) {
-          case FolderNotification.DidUpdateWorkspace:
-            if (result.ok) {
-              this.workspaceNotifier?.notify(Ok(WorkspacePB.deserializeBinary(result.val)));
-            } else {
-              this.workspaceNotifier?.notify(result);
-            }
-            break;
-          case FolderNotification.DidUpdateWorkspaceViews:
-            if (result.ok) {
-              this.appListNotifier?.notify(Ok(RepeatedViewPB.deserializeBinary(result.val).items));
-            } else {
-              this.appListNotifier?.notify(result);
-            }
-            break;
-          default:
-            break;
-        }
-      }
-    });
-    await this.listener.start();
-  };
-
-  unsubscribe = async () => {
-    this.appListNotifier.unsubscribe();
-    this.workspaceNotifier.unsubscribe();
-    await this.listener?.stop();
-  };
-}

+ 6 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/notifications/observer.ts → frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/notifications/observer.ts

@@ -1,16 +1,17 @@
 import { OnNotificationError, AFNotificationObserver } from '@/services/backend/notifications';
-import { FolderNotificationParser } from './parser';
 import { FlowyError, FolderNotification } from '@/services/backend';
 import { Result } from 'ts-results';
+import { WorkspaceNotificationParser } from './parser';
 
 export type ParserHandler = (notification: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
 
-export class FolderNotificationObserver extends AFNotificationObserver<FolderNotification> {
-  constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
-    const parser = new FolderNotificationParser({
+export class WorkspaceNotificationObserver extends AFNotificationObserver<FolderNotification> {
+  constructor(params: { id?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) {
+    const parser = new WorkspaceNotificationParser({
       callback: params.parserHandler,
-      id: params.viewId,
+      id: params.id,
     });
+
     super(parser);
   }
 }

+ 4 - 3
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/notifications/parser.ts → frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/notifications/parser.ts

@@ -2,14 +2,15 @@ import { NotificationParser, OnNotificationError } from '@/services/backend/noti
 import { FlowyError, FolderNotification } from '@/services/backend';
 import { Result } from 'ts-results';
 
-declare type FolderNotificationCallback = (ty: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
+declare type WorkspaceNotificationCallback = (ty: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
 
-export class FolderNotificationParser extends NotificationParser<FolderNotification> {
-  constructor(params: { id?: string; callback: FolderNotificationCallback; onError?: OnNotificationError }) {
+export class WorkspaceNotificationParser extends NotificationParser<FolderNotification> {
+  constructor(params: { id?: string; callback: WorkspaceNotificationCallback; onError?: OnNotificationError }) {
     super(
       params.callback,
       (ty) => {
         const notification = FolderNotification[ty];
+
         if (isFolderNotification(notification)) {
           return FolderNotification[notification];
         } else {

+ 83 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts

@@ -0,0 +1,83 @@
+import {
+  FolderEventReadView,
+  FolderEventCreateView,
+  FolderEventUpdateView,
+  FolderEventDeleteView,
+  FolderEventDuplicateView,
+  FolderEventCloseView,
+  FolderEventImportData,
+  ViewIdPB,
+  CreateViewPayloadPB,
+  UpdateViewPayloadPB,
+  RepeatedViewIdPB,
+  ViewPB,
+  ImportPB,
+} from '@/services/backend/events/flowy-folder2';
+import { Page } from '$app_reducers/pages/slice';
+
+export class PageBackendService {
+  constructor() {
+    //
+  }
+
+  getPage = async (viewId: string) => {
+    const payload = new ViewIdPB({
+      value: viewId,
+    });
+
+    return FolderEventReadView(payload);
+  };
+
+  createPage = async (params: ReturnType<typeof CreateViewPayloadPB.prototype.toObject>) => {
+    const payload = CreateViewPayloadPB.fromObject(params);
+
+    return FolderEventCreateView(payload);
+  };
+
+  updatePage = async (page: { id: string } & Partial<Page>) => {
+    const payload = new UpdateViewPayloadPB();
+
+    payload.view_id = page.id;
+    if (page.name !== undefined) {
+      payload.name = page.name;
+    }
+
+    if (page.cover !== undefined) {
+      payload.cover_url = page.cover;
+    }
+
+    if (page.icon !== undefined) {
+      payload.icon_url = page.icon;
+    }
+
+    return FolderEventUpdateView(payload);
+  };
+
+  deletePage = async (viewId: string) => {
+    const payload = new RepeatedViewIdPB({
+      items: [viewId],
+    });
+
+    return FolderEventDeleteView(payload);
+  };
+
+  duplicatePage = async (params: ReturnType<typeof ViewPB.prototype.toObject>) => {
+    const payload = ViewPB.fromObject(params);
+
+    return FolderEventDuplicateView(payload);
+  };
+
+  closePage = async (viewId: string) => {
+    const payload = new ViewIdPB({
+      value: viewId,
+    });
+
+    return FolderEventCloseView(payload);
+  };
+
+  importData = async (params: ReturnType<typeof ImportPB.prototype.toObject>) => {
+    const payload = ImportPB.fromObject(params);
+
+    return FolderEventImportData(payload);
+  };
+}

+ 112 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts

@@ -0,0 +1,112 @@
+import { CreateViewPayloadPB, UpdateViewPayloadPB, ViewLayoutPB } from '@/services/backend';
+import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
+import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
+import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
+import { AsyncQueue } from '$app/utils/async_queue';
+
+export class PageController {
+  private readonly backendService: PageBackendService = new PageBackendService();
+
+  private readonly observer: WorkspaceObserver = new WorkspaceObserver();
+  private onChangeQueue?: AsyncQueue;
+  constructor(private readonly id: string) {
+    //
+  }
+
+  dispose = () => {
+    this.observer.unsubscribe();
+  };
+
+  createPage = async (params: { name: string; layout: ViewLayoutPB }): Promise<string> => {
+    const result = await this.backendService.createPage({
+      name: params.name,
+      layout: params.layout,
+      parent_view_id: this.id,
+    });
+
+    if (result.ok) {
+      return result.val.id;
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  getChildPages = async (): Promise<Page[]> => {
+    const result = await this.backendService.getPage(this.id);
+
+    if (result.ok) {
+      return result.val.child_views.map(parserViewPBToPage);
+    }
+
+    return [];
+  };
+
+  getPage = async (id?: string): Promise<Page> => {
+    const result = await this.backendService.getPage(id || this.id);
+
+    if (result.ok) {
+      return parserViewPBToPage(result.val);
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  getParentPage = async (): Promise<Page> => {
+    const page = await this.getPage();
+    const parentPageId = page.parentId;
+
+    return this.getPage(parentPageId);
+  };
+
+  subscribe = async (callbacks: { onChildPagesChanged?: (childPages: Page[]) => void }) => {
+    const onChildPagesChanged = async () => {
+      const childPages = await this.getChildPages();
+
+      callbacks.onChildPagesChanged?.(childPages);
+    };
+
+    this.onChangeQueue = new AsyncQueue(onChildPagesChanged);
+    await this.observer.subscribeView(this.id, {
+      didUpdateChildViews: this.didUpdateChildPages,
+    });
+  };
+
+  unsubscribe = async () => {
+    await this.observer.unsubscribe();
+  };
+
+  updatePage = async (page: { id: string } & Partial<Page>) => {
+    const result = await this.backendService.updatePage(page);
+
+    if (result.ok) {
+      return result.val.toObject();
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  deletePage = async () => {
+    const result = await this.backendService.deletePage(this.id);
+
+    if (result.ok) {
+      return result.val;
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  duplicatePage = async () => {
+    const page = await this.getPage();
+    const result = await this.backendService.duplicatePage(page);
+
+    if (result.ok) {
+      return result.val;
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  private didUpdateChildPages = (payload: Uint8Array) => {
+    this.onChangeQueue?.enqueue(Math.random());
+  };
+}

+ 44 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/bd_svc.ts

@@ -0,0 +1,44 @@
+import {
+  FolderEventReadTrash,
+  FolderEventPutbackTrash,
+  FolderEventDeleteAllTrash,
+  FolderEventRestoreAllTrash,
+  FolderEventDeleteTrash,
+  TrashIdPB,
+  RepeatedTrashIdPB,
+} from '@/services/backend/events/flowy-folder2';
+
+export class TrashBackendService {
+  constructor() {
+    //
+  }
+
+  getTrash = async () => {
+    return FolderEventReadTrash();
+  };
+
+  putback = async (id: string) => {
+    const payload = new TrashIdPB({
+      id,
+    });
+
+    return FolderEventPutbackTrash(payload);
+  };
+
+  delete = async (ids: string[]) => {
+    const items = ids.map((id) => new TrashIdPB({ id }));
+    const payload = new RepeatedTrashIdPB({
+      items,
+    });
+
+    return FolderEventDeleteTrash(payload);
+  };
+
+  deleteAll = async () => {
+    return FolderEventDeleteAllTrash();
+  };
+
+  restoreAll = async () => {
+    return FolderEventRestoreAllTrash();
+  };
+}

+ 74 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/trash/controller.ts

@@ -0,0 +1,74 @@
+import { TrashBackendService } from '$app/stores/effects/workspace/trash/bd_svc';
+import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
+import { RepeatedTrashPB, TrashPB } from '@/services/backend';
+
+export class TrashController {
+  private readonly observer: WorkspaceObserver = new WorkspaceObserver();
+
+  private readonly backendService: TrashBackendService = new TrashBackendService();
+
+  subscribe = (callbacks: { onTrashChanged?: (trash: TrashPB[]) => void }) => {
+    const didUpdateTrash = (payload: Uint8Array) => {
+      const res = RepeatedTrashPB.deserializeBinary(payload);
+
+      callbacks.onTrashChanged?.(res.items);
+    };
+
+    this.observer.subscribeTrash({
+      didUpdateTrash,
+    });
+  };
+
+  dispose = () => {
+    this.observer.unsubscribe();
+  };
+  getTrash = async () => {
+    const res = await this.backendService.getTrash();
+
+    if (res.ok) {
+      return res.val.items;
+    }
+
+    return [];
+  };
+
+  putback = async (id: string) => {
+    const res = await this.backendService.putback(id);
+
+    if (res.ok) {
+      return res.val;
+    }
+
+    return Promise.reject(res.err);
+  };
+
+  delete = async (ids: string[]) => {
+    const res = await this.backendService.delete(ids);
+
+    if (res.ok) {
+      return res.val;
+    }
+
+    return Promise.reject(res.err);
+  };
+
+  deleteAll = async () => {
+    const res = await this.backendService.deleteAll();
+
+    if (res.ok) {
+      return res.val;
+    }
+
+    return Promise.reject(res.err);
+  };
+
+  restoreAll = async () => {
+    const res = await this.backendService.restoreAll();
+
+    if (res.ok) {
+      return res.val;
+    }
+
+    return Promise.reject(res.err);
+  };
+}

+ 61 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_bd_svc.ts

@@ -0,0 +1,61 @@
+import {
+  FolderEventCreateWorkspace,
+  FolderEventGetCurrentWorkspace,
+  CreateWorkspacePayloadPB,
+  FolderEventReadAllWorkspaces,
+  FolderEventOpenWorkspace,
+  FolderEventDeleteWorkspace,
+  WorkspaceIdPB,
+  FolderEventReadWorkspaceViews,
+} from '@/services/backend/events/flowy-folder2';
+
+export class WorkspaceBackendService {
+  constructor() {
+    //
+  }
+
+  createWorkspace = async (params: ReturnType<typeof CreateWorkspacePayloadPB.prototype.toObject>) => {
+    const { name, desc } = params;
+    const payload = CreateWorkspacePayloadPB.fromObject({
+      name,
+      desc,
+    });
+
+    return FolderEventCreateWorkspace(payload);
+  };
+
+  openWorkspace = async (workspaceId: string) => {
+    const payload = new WorkspaceIdPB({
+      value: workspaceId,
+    });
+
+    return FolderEventOpenWorkspace(payload);
+  };
+
+  deleteWorkspace = async (workspaceId: string) => {
+    const payload = new WorkspaceIdPB({
+      value: workspaceId,
+    });
+
+    return FolderEventDeleteWorkspace(payload);
+  };
+
+  getWorkspaces = async () => {
+    // if workspaceId is not provided, it will return all workspaces
+    const workspaceId = new WorkspaceIdPB();
+
+    return FolderEventReadAllWorkspaces(workspaceId);
+  };
+
+  getCurrentWorkspace = async () => {
+    return FolderEventGetCurrentWorkspace();
+  };
+
+  getChildPages = async (workspaceId: string) => {
+    const payload = new WorkspaceIdPB({
+      value: workspaceId,
+    });
+
+    return FolderEventReadWorkspaceViews(payload);
+  };
+}

+ 99 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts

@@ -0,0 +1,99 @@
+import { WorkspaceBackendService } from '$app/stores/effects/workspace/workspace_bd_svc';
+import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
+import { CreateViewPayloadPB } from '@/services/backend';
+import { WorkspaceItem } from '$app_reducers/workspace/slice';
+import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
+import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
+import { AsyncQueue } from '$app/utils/async_queue';
+
+export class WorkspaceController {
+  private readonly observer: WorkspaceObserver = new WorkspaceObserver();
+  private readonly pageBackendService: PageBackendService;
+  private readonly backendService: WorkspaceBackendService;
+  private onWorkspaceChanged?: (data: WorkspaceItem) => void;
+  private onWorkspaceDeleted?: () => void;
+  private onChangeQueue?: AsyncQueue;
+  constructor(private readonly workspaceId: string) {
+    this.pageBackendService = new PageBackendService();
+    this.backendService = new WorkspaceBackendService();
+  }
+
+  dispose = () => {
+    this.observer.unsubscribe();
+  };
+
+  open = async () => {
+    const result = await this.backendService.openWorkspace(this.workspaceId);
+
+    if (result.ok) {
+      return result.val;
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  delete = async () => {
+    const result = await this.backendService.deleteWorkspace(this.workspaceId);
+
+    if (result.ok) {
+      return result.val;
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  subscribe = async (callbacks: {
+    onWorkspaceChanged?: (data: WorkspaceItem) => void;
+    onWorkspaceDeleted?: () => void;
+    onChildPagesChanged?: (childPages: Page[]) => void;
+  }) => {
+    this.onWorkspaceChanged = callbacks.onWorkspaceChanged;
+    this.onWorkspaceDeleted = callbacks.onWorkspaceDeleted;
+    const onChildPagesChanged = async () => {
+      const childPages = await this.getChildPages();
+
+      callbacks.onChildPagesChanged?.(childPages);
+    };
+
+    this.onChangeQueue = new AsyncQueue(onChildPagesChanged);
+    await this.observer.subscribeWorkspace(this.workspaceId, {
+      didUpdateWorkspace: this.didUpdateWorkspace,
+      didDeleteWorkspace: this.didDeleteWorkspace,
+      didUpdateChildViews: this.didUpdateChildPages,
+    });
+  };
+
+  createView = async (params: ReturnType<typeof CreateViewPayloadPB.prototype.toObject>) => {
+    const result = await this.pageBackendService.createPage(params);
+
+    if (result.ok) {
+      const view = result.val;
+
+      return view;
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  getChildPages = async (): Promise<Page[]> => {
+    const result = await this.backendService.getChildPages(this.workspaceId);
+
+    if (result.ok) {
+      return result.val.items.map(parserViewPBToPage);
+    }
+
+    return [];
+  };
+
+  private didUpdateWorkspace = (payload: Uint8Array) => {
+    // this.onWorkspaceChanged?.(payload.toObject());
+  };
+
+  private didDeleteWorkspace = (payload: Uint8Array) => {
+    this.onWorkspaceDeleted?.();
+  };
+
+  private didUpdateChildPages = (payload: Uint8Array) => {
+    this.onChangeQueue?.enqueue(Math.random());
+  };
+}

+ 72 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_manager_controller.ts

@@ -0,0 +1,72 @@
+import { WorkspaceBackendService } from './workspace_bd_svc';
+import { CreateWorkspacePayloadPB, RepeatedWorkspacePB } from '@/services/backend';
+import { WorkspaceItem } from '$app_reducers/workspace/slice';
+import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
+
+export class WorkspaceManagerController {
+  private readonly observer: WorkspaceObserver;
+  private readonly backendService: WorkspaceBackendService = new WorkspaceBackendService();
+  private onWorkspacesChanged?: (data: { workspaces: WorkspaceItem[]; currentWorkspace: WorkspaceItem }) => void;
+
+  constructor() {
+    this.observer = new WorkspaceObserver();
+  }
+
+  subscribe = async (callbacks: {
+    onWorkspacesChanged?: (data: { workspaces: WorkspaceItem[]; currentWorkspace: WorkspaceItem }) => void;
+  }) => {
+    // this.observer.subscribeWorkspaces(this.didCreateWorkspace);
+    this.onWorkspacesChanged = callbacks.onWorkspacesChanged;
+  };
+
+  createWorkspace = async (params: ReturnType<typeof CreateWorkspacePayloadPB.prototype.toObject>) => {
+    const result = await this.backendService.createWorkspace(params);
+
+    if (result.ok) {
+      return result.val;
+    }
+
+    return Promise.reject(result.err);
+  };
+
+  getWorkspaces = async (): Promise<WorkspaceItem[]> => {
+    const result = await this.backendService.getWorkspaces();
+
+    if (result.ok) {
+      const items = result.val.items;
+
+      return items.map((item) => {
+        return {
+          id: item.id,
+          name: item.name,
+        };
+      });
+    }
+
+    return [];
+  };
+
+  getCurrentWorkspace = async (): Promise<WorkspaceItem | null> => {
+    const result = await this.backendService.getCurrentWorkspace();
+
+    if (result.ok) {
+      const workspace = result.val.workspace;
+
+      return {
+        id: workspace.id,
+        name: workspace.name,
+      };
+    }
+
+    return null;
+  };
+
+  dispose = async () => {
+    await this.observer.unsubscribe();
+  };
+
+  private didCreateWorkspace = (payload: Uint8Array) => {
+    const data = RepeatedWorkspacePB.deserializeBinary(payload);
+    // onWorkspacesChanged(data.toObject().items);
+  };
+}

+ 99 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_observer.ts

@@ -0,0 +1,99 @@
+import { FolderNotification } from '@/services/backend';
+import { WorkspaceNotificationObserver } from '$app/stores/effects/workspace/notifications/observer';
+
+export class WorkspaceObserver {
+  private listener?: WorkspaceNotificationObserver;
+  constructor() {
+    //
+  }
+
+  subscribeWorkspaces = async (callback: (payload: Uint8Array) => void) => {
+    this.listener = new WorkspaceNotificationObserver({
+      parserHandler: (notification, result) => {
+        switch (notification) {
+          case FolderNotification.DidCreateWorkspace:
+            if (!result.ok) break;
+            callback(result.val);
+            break;
+          default:
+            break;
+        }
+      },
+    });
+    await this.listener.start();
+  };
+
+  subscribeWorkspace = async (
+    workspaceId: string,
+    callbacks: {
+      didUpdateChildViews: (payload: Uint8Array) => void;
+      didUpdateWorkspace: (payload: Uint8Array) => void;
+      didDeleteWorkspace: (payload: Uint8Array) => void;
+    }
+  ) => {
+    this.listener = new WorkspaceNotificationObserver({
+      id: workspaceId,
+      parserHandler: (notification, result) => {
+        switch (notification) {
+          case FolderNotification.DidUpdateWorkspace:
+            if (!result.ok) break;
+            callbacks.didUpdateWorkspace(result.val);
+            break;
+          case FolderNotification.DidUpdateChildViews:
+            if (!result.ok) break;
+            callbacks.didUpdateChildViews(result.val);
+            break;
+          // case FolderNotification.DidDeleteWorkspace:
+          //   if (!result.ok) break;
+          //   callbacks.didDeleteWorkspace(result.val);
+          //   break;
+          default:
+            break;
+        }
+      },
+    });
+    await this.listener.start();
+  };
+
+  subscribeView = async (
+    viewId: string,
+    callbacks: {
+      didUpdateChildViews: (payload: Uint8Array) => void;
+    }
+  ) => {
+    this.listener = new WorkspaceNotificationObserver({
+      id: viewId,
+      parserHandler: (notification, result) => {
+        switch (notification) {
+          case FolderNotification.DidUpdateChildViews:
+            if (!result.ok) break;
+            callbacks.didUpdateChildViews(result.val);
+            break;
+          default:
+            break;
+        }
+      },
+    });
+    await this.listener.start();
+  };
+
+  subscribeTrash = async (callbacks: { didUpdateTrash: (payload: Uint8Array) => void }) => {
+    this.listener = new WorkspaceNotificationObserver({
+      parserHandler: (notification, result) => {
+        switch (notification) {
+          case FolderNotification.DidUpdateTrash:
+            if (!result.ok) break;
+            callbacks.didUpdateTrash(result.val);
+            break;
+          default:
+            break;
+        }
+      },
+    });
+    await this.listener.start();
+  };
+
+  unsubscribe = async () => {
+    await this.listener?.stop();
+  };
+}

+ 0 - 12
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/active-page-id/slice.ts

@@ -1,12 +0,0 @@
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-export const activePageIdSlice = createSlice({
-  name: 'activePageId',
-  initialState: '',
-  reducers: {
-    setActivePageId(state, action: PayloadAction<string>) {
-      return action.payload;
-    },
-  },
-});
-
-export const activePageIdActions = activePageIdSlice.actions;

+ 0 - 33
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/folders/slice.ts

@@ -1,33 +0,0 @@
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-
-export interface IFolder {
-  id: string;
-  title: string;
-  showPages?: boolean;
-}
-
-const initialState: IFolder[] = [];
-
-export const foldersSlice = createSlice({
-  name: 'folders',
-  initialState: initialState,
-  reducers: {
-    addFolder(state, action: PayloadAction<IFolder>) {
-      state.push(action.payload);
-    },
-    renameFolder(state, action: PayloadAction<{ id: string; newTitle: string }>) {
-      return state.map((f) => (f.id === action.payload.id ? { ...f, title: action.payload.newTitle } : f));
-    },
-    deleteFolder(state, action: PayloadAction<{ id: string }>) {
-      return state.filter((f) => f.id !== action.payload.id);
-    },
-    clearFolders() {
-      return [];
-    },
-    setShowPages(state, action: PayloadAction<{ id: string; showPages: boolean }>) {
-      return state.map((f) => (f.id === action.payload.id ? { ...f, showPages: action.payload.showPages } : f));
-    },
-  },
-});
-
-export const foldersActions = foldersSlice.actions;

+ 0 - 17
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/navigation-width/slice.ts

@@ -1,17 +0,0 @@
-import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-
-export const NAVIGATION_MIN_WIDTH = 200;
-
-const initialState = 250;
-
-export const navigationWidthSlice = createSlice({
-  name: 'navigationWidth',
-  initialState: initialState,
-  reducers: {
-    changeWidth(state, action: PayloadAction<number>) {
-      return action.payload;
-    },
-  },
-});
-
-export const navigationWidthActions = navigationWidthSlice.actions;

+ 67 - 28
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts

@@ -1,43 +1,82 @@
+import { ViewLayoutPB, ViewPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-import { ViewLayoutPB } from '@/services/backend';
 
-export interface IPage {
+export interface Page {
   id: string;
-  title: string;
-  pageType: ViewLayoutPB;
-  parentPageId: string;
-  showPagesInside: boolean;
+  parentId: string;
+  name: string;
+  layout: ViewLayoutPB;
+  icon?: string;
+  cover?: string;
 }
 
-const initialState: IPage[] = [];
+export function parserViewPBToPage(view: ViewPB) {
+  return {
+    id: view.id,
+    name: view.name,
+    parentId: view.parent_view_id,
+    layout: view.layout,
+    cover: view.cover_url,
+    icon: view.icon_url,
+  };
+}
+
+export interface PageState {
+  map: Record<string, Page>;
+  childPages: Record<string, string[]>;
+  expandedPages: Record<string, boolean>;
+}
+
+export const initialState: PageState = {
+  map: {},
+  childPages: {},
+  expandedPages: {},
+};
 
 export const pagesSlice = createSlice({
   name: 'pages',
-  initialState: initialState,
+  initialState,
   reducers: {
-    addInsidePages(state, action: PayloadAction<{ insidePages: IPage[]; currentPageId: string }>) {
-      return state
-        .filter((page) => page.parentPageId !== action.payload.currentPageId)
-        .concat(action.payload.insidePages);
-    },
-    addPage(state, action: PayloadAction<IPage>) {
-      state.push(action.payload);
-    },
-    toggleShowPages(state, action: PayloadAction<{ id: string }>) {
-      return state.map<IPage>((page: IPage) =>
-        page.id === action.payload.id ? { ...page, showPagesInside: !page.showPagesInside } : page
-      );
+    addChildPages(
+      state,
+      action: PayloadAction<{
+        childPages: Page[];
+        id: string;
+      }>
+    ) {
+      const { childPages, id } = action.payload;
+      const pageMap: Record<string, Page> = {};
+
+      const children: string[] = [];
+
+      childPages.forEach((page) => {
+        pageMap[page.id] = page;
+        children.push(page.id);
+      });
+
+      state.map = {
+        ...state.map,
+        ...pageMap,
+      };
+      state.childPages[id] = children;
     },
-    renamePage(state, action: PayloadAction<{ id: string; newTitle: string }>) {
-      return state.map<IPage>((page: IPage) =>
-        page.id === action.payload.id ? { ...page, title: action.payload.newTitle } : page
-      );
+
+    removeChildPages(state, action: PayloadAction<string>) {
+      const parentId = action.payload;
+
+      delete state.childPages[parentId];
     },
-    deletePage(state, action: PayloadAction<{ id: string }>) {
-      return state.filter((page) => page.id !== action.payload.id);
+
+    expandPage(state, action: PayloadAction<string>) {
+      const id = action.payload;
+
+      state.expandedPages[id] = true;
     },
-    clearPages() {
-      return [];
+
+    collapsePage(state, action: PayloadAction<string>) {
+      const id = action.payload;
+
+      state.expandedPages[id] = false;
     },
   },
 });

+ 34 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts

@@ -0,0 +1,34 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+interface SidebarState {
+  isCollapsed: boolean;
+  width: number;
+  isResizing: boolean;
+}
+
+const initialState: SidebarState = {
+  isCollapsed: false,
+  width: 250,
+  isResizing: false,
+};
+
+export const sidebarSlice = createSlice({
+  name: 'sidebar',
+  initialState: initialState,
+  reducers: {
+    toggleCollapse(state) {
+      state.isCollapsed = !state.isCollapsed;
+    },
+    changeWidth(state, action: PayloadAction<number>) {
+      state.width = action.payload;
+    },
+    startResizing(state) {
+      state.isResizing = true;
+    },
+    stopResizing(state) {
+      state.isResizing = false;
+    },
+  },
+});
+
+export const sidebarActions = sidebarSlice.actions;

+ 49 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/workspace/slice.ts

@@ -1,17 +1,61 @@
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 
-export interface IWorkspace {
-  id?: string;
-  name?: string;
+export interface WorkspaceItem {
+  id: string;
+  name: string;
 }
 
+interface WorkspaceState {
+  workspaces: WorkspaceItem[];
+  currentWorkspace: WorkspaceItem | null;
+}
+
+const initialState: WorkspaceState = {
+  workspaces: [],
+  currentWorkspace: null,
+};
+
 export const workspaceSlice = createSlice({
   name: 'workspace',
-  initialState: {} as IWorkspace,
+  initialState,
   reducers: {
-    updateWorkspace: (state, action: PayloadAction<IWorkspace>) => {
+    initWorkspaces: (
+      state,
+      action: PayloadAction<{
+        workspaces: WorkspaceItem[];
+        currentWorkspace: WorkspaceItem | null;
+      }>
+    ) => {
+      return action.payload;
+    },
+
+    onWorkspacesChanged: (
+      state,
+      action: PayloadAction<{
+        workspaces: WorkspaceItem[];
+        currentWorkspace: WorkspaceItem | null;
+      }>
+    ) => {
       return action.payload;
     },
+
+    onWorkspaceChanged: (state, action: PayloadAction<WorkspaceItem>) => {
+      const { id } = action.payload;
+      const index = state.workspaces.findIndex((workspace) => workspace.id === id);
+
+      if (index !== -1) {
+        state.workspaces[index] = action.payload;
+      }
+    },
+
+    onWorkspaceDeleted: (state, action: PayloadAction<string>) => {
+      const id = action.payload;
+      const index = state.workspaces.findIndex((workspace) => workspace.id === id);
+
+      if (index !== -1) {
+        state.workspaces.splice(index, 1);
+      }
+    },
   },
 });
 

+ 2 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/store.ts

@@ -8,7 +8,6 @@ import {
   addListener,
 } from '@reduxjs/toolkit';
 import { pagesSlice } from './reducers/pages/slice';
-import { navigationWidthSlice } from './reducers/navigation-width/slice';
 import { currentUserSlice } from './reducers/current-user/slice';
 import { gridSlice } from './reducers/grid/slice';
 import { workspaceSlice } from './reducers/workspace/slice';
@@ -16,7 +15,7 @@ import { databaseSlice } from './reducers/database/slice';
 import { documentReducers } from './reducers/document/slice';
 import { boardSlice } from './reducers/board/slice';
 import { errorSlice } from './reducers/error/slice';
-import { activePageIdSlice } from '$app_reducers/active-page-id/slice';
+import { sidebarSlice } from '$app_reducers/sidebar/slice';
 
 const listenerMiddlewareInstance = createListenerMiddleware({
   onError: () => console.error,
@@ -25,14 +24,13 @@ const listenerMiddlewareInstance = createListenerMiddleware({
 const store = configureStore({
   reducer: {
     [pagesSlice.name]: pagesSlice.reducer,
-    [activePageIdSlice.name]: activePageIdSlice.reducer,
-    [navigationWidthSlice.name]: navigationWidthSlice.reducer,
     [currentUserSlice.name]: currentUserSlice.reducer,
     [gridSlice.name]: gridSlice.reducer,
     [databaseSlice.name]: databaseSlice.reducer,
     [boardSlice.name]: boardSlice.reducer,
     [workspaceSlice.name]: workspaceSlice.reducer,
     [errorSlice.name]: errorSlice.reducer,
+    [sidebarSlice.name]: sidebarSlice.reducer,
     ...documentReducers,
   },
   middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware),

+ 2 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts

@@ -1,6 +1,6 @@
 import { Log } from '$app/utils/log';
 
-export class AsyncQueue<T> {
+export class AsyncQueue<T = unknown> {
   private queue: T[] = [];
   private isProcessing = false;
   private executeFunction: (item: T) => Promise<void>;
@@ -20,6 +20,7 @@ export class AsyncQueue<T> {
     }
 
     const item = this.queue.shift();
+
     this.isProcessing = true;
 
     const executeFn = async (item: T) => {

+ 5 - 4
frontend/appflowy_tauri/src/appflowy_app/views/BoardPage.tsx

@@ -7,14 +7,15 @@ export const BoardPage = () => {
   const params = useParams();
   const [viewId, setViewId] = useState('');
   const pagesStore = useAppSelector((state) => state.pages);
+  const page = useAppSelector((state) => (params.id ? state.pages.map[params.id] : undefined));
   const [title, setTitle] = useState('');
 
   useEffect(() => {
-    if (params?.id?.length) {
-      setViewId(params.id);
-      setTitle(pagesStore.find((page) => page.id === params.id)?.title || '');
+    if (page) {
+      setViewId(page.id);
+      setTitle(page.name);
     }
-  }, [params, pagesStore]);
+  }, [params, pagesStore, page]);
 
   return (
     <div className='flex h-full flex-col gap-8 px-8 pt-8'>

+ 12 - 0
frontend/appflowy_tauri/src/appflowy_app/views/TrashPage.tsx

@@ -0,0 +1,12 @@
+import React from 'react';
+import Trash from '$app/components/trash/Trash';
+
+function TrashPage() {
+  return (
+    <div className='flex h-full flex-col gap-8 px-8 pt-8'>
+      <Trash />
+    </div>
+  );
+}
+
+export default TrashPage;

+ 11 - 3
frontend/appflowy_tauri/src/styles/mui.css

@@ -12,7 +12,7 @@
 }
 
 [class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root:hover {
-    background-color: var(--fill-list-hover);
+    background-color: var(--fill-list-active);
 }
 
 .MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
@@ -24,7 +24,6 @@
 }
 
 .MuiButtonBase-root.MuiIconButton-root.MuiIconButton-sizeMedium {
-    color: var(--icon-primary);
     border-radius: 4px;
 }
 
@@ -33,7 +32,6 @@
 }
 
 .MuiButtonBase-root.MuiIconButton-root {
-    color: var(--icon-primary);
     border-radius: 4px;
     padding: 2px;
 }
@@ -52,4 +50,14 @@
 
 .MuiInput-input[class$='-MuiSelect-select-MuiInputBase-input-MuiInput-input']:focus {
     background: transparent;
+}
+.MuiList-root .MuiMenuItem-root {
+    border-radius: 8px;
+    margin-left: 0.5em;
+    margin-right: 0.5em;
+    padding: 0.5em 1em;
+}
+
+.MuiDivider-root.MuiDivider-fullWidth {
+    border-color: var(--line-divider);
 }

+ 1 - 1
frontend/appflowy_tauri/src/styles/variables/dark.variables.css

@@ -1,6 +1,6 @@
 /**
 * Do not edit directly
-* Generated on Tue, 11 Jul 2023 06:48:47 GMT
+* Generated on Wed, 12 Jul 2023 07:09:42 GMT
 * Generated from $pnpm css:variables 
 */
 

+ 2 - 2
frontend/appflowy_tauri/src/styles/variables/light.variables.css

@@ -1,10 +1,10 @@
 /**
 * Do not edit directly
-* Generated on Tue, 11 Jul 2023 06:48:47 GMT
+* Generated on Wed, 12 Jul 2023 07:09:42 GMT
 * Generated from $pnpm css:variables 
 */
 
-:root[data-dark-mode=false] {
+:root {
   --base-light-neutral-50: #f9fafd;
   --base-light-neutral-100: #edeef2;
   --base-light-neutral-200: #e2e4eb;

+ 1 - 1
frontend/appflowy_tauri/style-dictionary/config.cjs

@@ -46,7 +46,7 @@ StyleDictionary.extend({
         {
           format: 'css/variables',
           destination: 'light.variables.css',
-          selector: '[data-dark-mode=false]',
+          selector: '',
           options: {
             outputReferences: true
           }

+ 1 - 1
frontend/appflowy_tauri/style-dictionary/tailwind/box-shadow.cjs

@@ -1,6 +1,6 @@
 /**
 * Do not edit directly
-* Generated on Tue, 11 Jul 2023 06:48:47 GMT
+* Generated on Wed, 12 Jul 2023 07:09:42 GMT
 * Generated from $pnpm css:variables 
 */
 

+ 1 - 1
frontend/appflowy_tauri/style-dictionary/tailwind/colors.cjs

@@ -1,6 +1,6 @@
 /**
 * Do not edit directly
-* Generated on Tue, 11 Jul 2023 06:48:47 GMT
+* Generated on Wed, 12 Jul 2023 07:09:42 GMT
 * Generated from $pnpm css:variables 
 */
 

+ 1 - 1
frontend/appflowy_tauri/tailwind.config.cjs

@@ -12,7 +12,7 @@ module.exports = {
   theme: {
     extend: {
       colors,
-      boxShadow
+      boxShadow,
     },
   },
   plugins: [],

+ 15 - 2
frontend/resources/translations/en.json

@@ -80,6 +80,14 @@
       "fileName": "File name",
       "lastModified": "Last Modified",
       "created": "Created"
+    },
+    "confirmDeleteAll": {
+      "title": "Are you sure to delete all pages in Trash?",
+      "caption": "This action cannot be undone."
+    },
+    "confirmRestoreAll": {
+        "title": "Are you sure to restore all pages in Trash?",
+        "caption": "This action cannot be undone."
     }
   },
   "deletePagePrompt": {
@@ -169,7 +177,8 @@
     "edit": "Edit",
     "delete": "Delete",
     "duplicate": "Duplicate",
-    "done": "Done"
+    "done": "Done",
+    "putback": "Put Back"
   },
   "label": {
     "welcome": "Welcome!",
@@ -580,5 +589,9 @@
       "fail": "Unable to copy"
     }
   },
-  "unSupportBlock": "The current version does not support this Block."
+  "unSupportBlock": "The current version does not support this Block.",
+  "views": {
+    "deleteContentTitle": "Are you sure want to delete the {pageType}?",
+    "deleteContentCaption": "if you delete this {pageType}, you can restore it from the trash."
+  }
 }