Taichi Masuyama 4 anni fa
parent
commit
e42c3d980e
63 ha cambiato i file con 1597 aggiunte e 610 eliminazioni
  1. 1 0
      packages/app/config/logger/config.dev.js
  2. 3 1
      packages/app/package.json
  3. 6 2
      packages/app/resource/locales/en_US/translation.json
  4. 6 2
      packages/app/resource/locales/ja_JP/translation.json
  5. 6 2
      packages/app/resource/locales/zh_CN/translation.json
  6. 3 0
      packages/app/resource/search/mappings.json
  7. 2 7
      packages/app/src/client/app.jsx
  8. 31 31
      packages/app/src/client/legacy/crowi.js
  9. 26 9
      packages/app/src/client/services/ContextExtractor.tsx
  10. 55 137
      packages/app/src/client/services/NavigationContainer.js
  11. 5 5
      packages/app/src/client/services/PageContainer.js
  12. 0 0
      packages/app/src/client/services/user-ui-settings.ts
  13. 1 1
      packages/app/src/client/util/apiv3-client.ts
  14. 2 1
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  15. 5 1
      packages/app/src/components/Fab.jsx
  16. 9 11
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  17. 4 2
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  18. 3 3
      packages/app/src/components/Icons/GrowiLogo.jsx
  19. 0 115
      packages/app/src/components/Navbar/GrowiNavbar.jsx
  20. 128 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  21. 8 4
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  22. 13 12
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  23. 17 15
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  24. 4 2
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  25. 3 3
      packages/app/src/components/Navbar/SubNavButtons.jsx
  26. 13 9
      packages/app/src/components/Page/DisplaySwitcher.jsx
  27. 13 13
      packages/app/src/components/Page/NotFoundAlert.jsx
  28. 8 7
      packages/app/src/components/PageCreateModal.jsx
  29. 15 9
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  30. 23 29
      packages/app/src/components/PaginationWrapper.tsx
  31. 2 1
      packages/app/src/components/SearchForm.jsx
  32. 163 25
      packages/app/src/components/SearchPage.jsx
  33. 62 0
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  34. 42 0
      packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx
  35. 104 0
      packages/app/src/components/SearchPage/SearchControl.tsx
  36. 31 13
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  37. 52 0
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  38. 50 0
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  39. 0 64
      packages/app/src/components/SearchPage/SearchResultList.jsx
  40. 49 0
      packages/app/src/components/SearchPage/SearchResultList.tsx
  41. 140 0
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  42. 4 3
      packages/app/src/components/Sidebar.tsx
  43. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  44. 6 9
      packages/app/src/components/StickyStretchableScroller.jsx
  45. 18 0
      packages/app/src/interfaces/search.ts
  46. 3 1
      packages/app/src/server/events/page.js
  47. 2 0
      packages/app/src/server/models/obsolete-page.js
  48. 1 1
      packages/app/src/server/routes/apiv3/response.js
  49. 0 1
      packages/app/src/server/routes/avoid-session-routes.js
  50. 24 13
      packages/app/src/server/routes/search.js
  51. 1 0
      packages/app/src/server/service/page.js
  52. 77 4
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  53. 187 0
      packages/app/src/server/service/search.js
  54. 29 0
      packages/app/src/server/service/search.ts
  55. 2 0
      packages/app/src/server/views/layout-growi/base/layout.html
  56. 1 1
      packages/app/src/server/views/search.html
  57. 39 10
      packages/app/src/stores/ui.tsx
  58. 35 16
      packages/app/src/styles/_search.scss
  59. 9 1
      packages/app/src/styles/_sidebar.scss
  60. 8 9
      packages/app/src/styles/theme/_apply-colors.scss
  61. 8 0
      packages/ui/src/components/PagePath/PageListMeta.jsx
  62. 8 0
      packages/ui/src/components/PagePath/PagePathLabel.jsx
  63. 26 4
      yarn.lock

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -35,5 +35,6 @@ module.exports = {
   'growi:services:*': 'debug',
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  'growi:searchResultList': 'debug',
 
 
 };
 };

+ 3 - 1
packages/app/package.json

@@ -122,7 +122,7 @@
     "nodemailer": "^6.6.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "=2.5.0",
     "openid-client": "=2.5.0",
-    "passport": "^0.4.0",
+    "passport": "^0.5.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-http": "^0.3.0",
     "passport-http": "^0.3.0",
@@ -134,6 +134,7 @@
     "re2": "^1.16.0",
     "re2": "^1.16.0",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
+    "react-multiline-clamp": "^2.0.0",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "rimraf": "^3.0.0",
@@ -161,6 +162,7 @@
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
+    "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
     "@types/multer": "^1.4.5",
     "@types/react-dom": "^17.0.9",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",

+ 6 - 2
packages/app/resource/locales/en_US/translation.json

@@ -64,6 +64,7 @@
   "Include Attachment File": "Include Attachment File",
   "Include Attachment File": "Include Attachment File",
   "Include Comment": "Include Comment",
   "Include Comment": "Include Comment",
   "Include Subordinated Page": "Include Subordinated Page",
   "Include Subordinated Page": "Include Subordinated Page",
+  "Include Subordinated Target Page": "include {{target}}",
   "All Subordinated Page": "All Subordinated Page",
   "All Subordinated Page": "All Subordinated Page",
   "Specify Hierarchy": "Specify Hierarchy",
   "Specify Hierarchy": "Specify Hierarchy",
   "Submitted the request to create the archive": "Submitted the request to create the archive",
   "Submitted the request to create the archive": "Submitted the request to create the archive",
@@ -148,6 +149,7 @@
   "Sign out": "Logout",
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
+  "Add to bookmark": "Add to bookmark",
   "Recent Created": "Recent Created",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
   "Page Tree": "Page Tree",
@@ -568,13 +570,15 @@
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
   },
   },
   "search_result": {
   "search_result": {
-    "result_meta": "Found \"{{keyword}}\" in {{total}}.",
+    "result_meta": "Search results for:",
     "deletion_mode_btn_lavel": "Select and delete page",
     "deletion_mode_btn_lavel": "Select and delete page",
     "cancel": "Cancel",
     "cancel": "Cancel",
     "delete": "Delete",
     "delete": "Delete",
     "check_all": "Check all",
     "check_all": "Check all",
     "deletion_modal_header": "Delete page",
     "deletion_modal_header": "Delete page",
-    "delete_completely": "Delete completely"
+    "delete_completely": "Delete completely",
+    "include_certain_path" : "Include {{pathToInclude}} path ",
+    "delete_all_selected_page" : "Delete All"
   },
   },
   "security_setting": {
   "security_setting": {
     "Guest Users Access": "Guest users access",
     "Guest Users Access": "Guest users access",

+ 6 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -64,6 +64,7 @@
   "Include Attachment File": "添付ファイルも含める",
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
   "Include Subordinated Page": "配下ページも含める",
+  "Include Subordinated Target Page": "{{target}} 下を含む",
   "All Subordinated Page": "全ての配下ページ",
   "All Subordinated Page": "全ての配下ページ",
   "Specify Hierarchy": "階層の深さを指定",
   "Specify Hierarchy": "階層の深さを指定",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
@@ -150,6 +151,7 @@
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
+  "Add to bookmark": "ブックマークに追加",
   "Recent Created": "最新の作成",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
   "Page Tree": "ページツリー",
@@ -568,13 +570,15 @@
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
   },
   },
   "search_result": {
   "search_result": {
-    "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
+    "result_meta": "検索結果:",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "cancel": "キャンセル",
     "cancel": "キャンセル",
     "delete": "削除",
     "delete": "削除",
     "check_all": "すべてチェック",
     "check_all": "すべてチェック",
     "deletion_modal_header": "以下のページを削除",
     "deletion_modal_header": "以下のページを削除",
-    "delete_completely": "完全に削除する"
+    "delete_completely": "完全に削除する",
+    "include_certain_path": "{{pathToInclude}}下を含む ",
+    "delete_all_selected_page" : "一括削除"
   },
   },
   "security_setting": {
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Guest Users Access": "ゲストユーザーのアクセス",

+ 6 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -65,6 +65,7 @@
   "Include Attachment File": "包含附件",
   "Include Attachment File": "包含附件",
   "Include Comment": "包含评论",
   "Include Comment": "包含评论",
   "Include Subordinated Page": "包括子页面",
   "Include Subordinated Page": "包括子页面",
+  "Include Subordinated Target Page": "包括 {{target}}",
   "All Subordinated Page": "所有子页面",
   "All Subordinated Page": "所有子页面",
   "Specify Hierarchy": "指定层级",
   "Specify Hierarchy": "指定层级",
   "Submitted the request to create the archive": "提交创建归档请求",
   "Submitted the request to create the archive": "提交创建归档请求",
@@ -156,6 +157,7 @@
 	"Sign out": "退出",
 	"Sign out": "退出",
   "Disassociate": "解除关联",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
+  "Add to bookmark": "添加到书签",
 	"Recent Created": "最新创建",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
   "Page Tree": "页面树",
@@ -841,13 +843,15 @@
 		"use_os_settings": "使用操作系统设置"
 		"use_os_settings": "使用操作系统设置"
 	},
 	},
 	"search_result": {
 	"search_result": {
-		"result_meta": "在{{total}中找到了{{keyword}。",
+		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"cancel": "取消",
 		"cancel": "取消",
 		"delete": "删除",
 		"delete": "删除",
 		"check_all": "全部检查",
 		"check_all": "全部检查",
 		"deletion_modal_header": "删除页",
 		"deletion_modal_header": "删除页",
-		"delete_completely": "完全删除"
+		"delete_completely": "完全删除",
+    "include_certain_path": "包含 {{pathToInclude}} 路径 ",
+    "delete_all_selected_page": "删除所有"
 	},
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 	"login": {

+ 3 - 0
packages/app/resource/search/mappings.json

@@ -88,6 +88,9 @@
         "bookmark_count": {
         "bookmark_count": {
           "type": "integer"
           "type": "integer"
         },
         },
+        "seenUsers_count":{
+          "type": "integer"
+        },
         "like_count": {
         "like_count": {
           "type": "integer"
           "type": "integer"
         },
         },

+ 2 - 7
packages/app/src/client/app.jsx

@@ -41,6 +41,7 @@ import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 
 
+import ContextExtractor from '~/client/services/ContextExtractor';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
@@ -50,7 +51,6 @@ import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import ContextExtractor from '~/client/services/ContextExtractor';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 
 
@@ -99,7 +99,6 @@ Object.assign(componentMappings, {
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
   'not-found-alert': <NotFoundAlert
   'not-found-alert': <NotFoundAlert
-    onPageCreateClicked={navigationContainer.setEditorMode}
     isGuestUserMode={appContainer.isGuestUser}
     isGuestUserMode={appContainer.isGuestUser}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
   />,
@@ -118,8 +117,6 @@ Object.assign(componentMappings, {
   'duplicated-alert': <DuplicatedAlert />,
   'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'renamed-alert': <RenamedAlert />,
   'renamed-alert': <RenamedAlert />,
-
-  'growi-context-extractor': <ContextExtractor />, // use static swr
 });
 });
 
 
 // additional definitions if data exists
 // additional definitions if data exists
@@ -133,7 +130,6 @@ if (pageContainer.state.pageId != null) {
 
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
-    'page-context': <ContextExtractor />, // use static swr
   });
   });
 
 
   // show the Page accessory modal when query of "compare" is requested
   // show the Page accessory modal when query of "compare" is requested
@@ -182,7 +178,7 @@ const elem = document.getElementById('growi-context-extractor');
 if (elem != null) {
 if (elem != null) {
   ReactDOM.render(
   ReactDOM.render(
     <SWRConfig value={swrGlobalConfiguration}>
     <SWRConfig value={swrGlobalConfiguration}>
-      {componentMappings['growi-context-extractor']}
+      <ContextExtractor></ContextExtractor>
     </SWRConfig>,
     </SWRConfig>,
     elem,
     elem,
     renderMainComponents,
     renderMainComponents,
@@ -192,6 +188,5 @@ else {
   renderMainComponents();
   renderMainComponents();
 }
 }
 
 
-
 // initialize scrollpos-styler
 // initialize scrollpos-styler
 ScrollPosStyler.init();
 ScrollPosStyler.init();

+ 31 - 31
packages/app/src/client/legacy/crowi.js

@@ -17,7 +17,7 @@ window.Crowi = Crowi;
 Crowi.setCaretLineData = function(line) {
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
   const { appContainer } = window;
   const navigationContainer = appContainer.getContainer('NavigationContainer');
   const navigationContainer = appContainer.getContainer('NavigationContainer');
-  navigationContainer.setEditorMode('edit');
+  // navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
   pageEditorDom.setAttribute('data-caret-line', line);
 };
 };
@@ -154,32 +154,32 @@ Crowi.blinkSelectedSection = function(hash) {
   }
   }
 };
 };
 
 
-window.addEventListener('load', () => {
-  const { appContainer } = window;
-  const pageContainer = appContainer.getContainer('PageContainer');
-
-  // Do nothing if the page does not exist
-  // ex.) admin page,login page
-  if (pageContainer == null) {
-    return null;
-  }
-  const { isAbleToOpenPageEditor } = pageContainer;
-
-  // hash on page
-  if (window.location.hash) {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-    if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-      navigationContainer.setEditorMode('edit');
-
-      // focus
-      Crowi.setCaretLineAndFocusToEditor();
-    }
-    else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
-    }
-  }
-});
+// window.addEventListener('load', () => {
+//   const { appContainer } = window;
+//   const pageContainer = appContainer.getContainer('PageContainer');
+
+//   // Do nothing if the page does not exist
+//   // ex.) admin page,login page
+//   if (pageContainer == null) {
+//     return null;
+//   }
+//   const { isAbleToOpenPageEditor } = pageContainer;
+
+//   // hash on page
+//   if (window.location.hash) {
+//     const navigationContainer = appContainer.getContainer('NavigationContainer');
+
+//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
+//       navigationContainer.setEditorMode('edit');
+
+//       // focus
+//       Crowi.setCaretLineAndFocusToEditor();
+//     }
+//     else if (window.location.hash === '#hackmd') {
+//       navigationContainer.setEditorMode('hackmd');
+//     }
+//   }
+// });
 
 
 window.addEventListener('load', () => {
 window.addEventListener('load', () => {
   const crowi = window.crowi;
   const crowi = window.crowi;
@@ -228,18 +228,18 @@ window.addEventListener('hashchange', (e) => {
   Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
   Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
   Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
   Crowi.modifyScrollTop();
-  const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
+  // const { appContainer } = window;
+  // const navigationContainer = appContainer.getContainer('NavigationContainer');
 
 
 
 
   // hash on page
   // hash on page
   if (window.location.hash) {
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
     if (window.location.hash === '#edit') {
-      navigationContainer.setEditorMode('edit');
+      // navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
       Crowi.setCaretLineAndFocusToEditor();
     }
     }
     else if (window.location.hash === '#hackmd') {
     else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
+      // navigationContainer.setEditorMode('hackmd');
     }
     }
   }
   }
 });
 });

+ 26 - 9
packages/app/src/client/services/ContextExtractor.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
 import {
 import {
@@ -9,14 +9,25 @@ import {
 } from '../../stores/context';
 } from '../../stores/context';
 
 
 import {
 import {
-  useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  EditorMode, useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 
 const jsonNull = 'null';
 const jsonNull = 'null';
 
 
-const ContextExtractor: FC = () => {
+const getInitialEditorMode = (): EditorMode => {
+  switch (window.location.hash) {
+    case '#edit':
+      return EditorMode.Editor;
+    case '#hackmd':
+      return EditorMode.HackMD;
+    default:
+      return EditorMode.View;
+  }
+};
+
+const ContextExtractorOnce: FC = () => {
 
 
   const mainContent = document.querySelector('#content-main');
   const mainContent = document.querySelector('#content-main');
 
 
@@ -63,7 +74,7 @@ const ContextExtractor: FC = () => {
   useCurrentUser(currentUser);
   useCurrentUser(currentUser);
 
 
   // Navigation
   // Navigation
-  useEditorMode();
+  useEditorMode(getInitialEditorMode());
   usePreferDrawerModeByUser();
   usePreferDrawerModeByUser();
   usePreferDrawerModeOnEditByUser();
   usePreferDrawerModeOnEditByUser();
   useIsDeviceSmallerThanMd();
   useIsDeviceSmallerThanMd();
@@ -97,11 +108,17 @@ const ContextExtractor: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useTargetAndAncestors(targetAndAncestors);
 
 
-  return (
-    <div>
-      {/* Render nothing */}
-    </div>
-  );
+  return null;
 };
 };
 
 
+const ContextExtractor: FC = React.memo(() => {
+  const [isRunOnce, setRunOnce] = useState(false);
+
+  useEffect(() => {
+    setRunOnce(true);
+  }, []);
+
+  return isRunOnce ? null : <ContextExtractorOnce></ContextExtractorOnce>;
+});
+
 export default ContextExtractor;
 export default ContextExtractor;

+ 55 - 137
packages/app/src/client/services/NavigationContainer.js

@@ -22,26 +22,12 @@ export default class NavigationContainer extends Container {
     const { localStorage } = window;
     const { localStorage } = window;
 
 
     this.state = {
     this.state = {
-      editorMode: 'view',
-
-      isDeviceSmallerThanMd: null,
-      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
-      preferDrawerModeOnEditByUser: // default: true
-        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
-      isDrawerMode: null,
-      isDrawerOpened: false,
-
-      sidebarContentsId: localStorage.sidebarContentsId || 'recent',
+      // editorMode: 'view',
 
 
       isScrollTop: true,
       isScrollTop: true,
-
-      isPageCreateModalShown: false,
     };
     };
 
 
-    this.openPageCreateModal = this.openPageCreateModal.bind(this);
-    this.closePageCreateModal = this.closePageCreateModal.bind(this);
-    this.setEditorMode = this.setEditorMode.bind(this);
-    this.initDeviceSize();
+    // this.setEditorMode = this.setEditorMode.bind(this);
     this.initScrollEvent();
     this.initScrollEvent();
   }
   }
 
 
@@ -56,26 +42,6 @@ export default class NavigationContainer extends Container {
     return this.appContainer.getContainer('PageContainer');
     return this.appContainer.getContainer('PageContainer');
   }
   }
 
 
-  initDeviceSize() {
-    const mdOrAvobeHandler = async(mql) => {
-      let isDeviceSmallerThanMd;
-
-      // sm -> md
-      if (mql.matches) {
-        isDeviceSmallerThanMd = false;
-      }
-      // md -> sm
-      else {
-        isDeviceSmallerThanMd = true;
-      }
-
-      this.setState({ isDeviceSmallerThanMd });
-      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
-    };
-
-    this.appContainer.addBreakpointListener('md', mdOrAvobeHandler, true);
-  }
-
   initScrollEvent() {
   initScrollEvent() {
     window.addEventListener('scroll', () => {
     window.addEventListener('scroll', () => {
       const currentYOffset = window.pageYOffset;
       const currentYOffset = window.pageYOffset;
@@ -91,80 +57,49 @@ export default class NavigationContainer extends Container {
     });
     });
   }
   }
 
 
-  setEditorMode(editorMode) {
-    const { isNotCreatable } = this.getPageContainer().state;
-
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to edit the page or use hackmd.');
-      return;
-    }
-
-    if (isNotCreatable) {
-      logger.warn('This page could not edit.');
-      return;
-    }
-
-    this.setState({ editorMode });
-    if (editorMode === 'view') {
-      $('body').removeClass('on-edit');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
-    }
-
-    if (editorMode === 'edit') {
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      // editing /Sidebar
-      if (window.location.pathname === '/Sidebar') {
-        $('body').addClass('pathname-sidebar');
-      }
-      window.location.hash = '#edit';
-    }
-
-    if (editorMode === 'hackmd') {
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
-    }
-
-    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  }
-
-  toggleDrawer() {
-    const { isDrawerOpened } = this.state;
-    this.setState({ isDrawerOpened: !isDrawerOpened });
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreference(bool) {
-    this.setState({ preferDrawerModeByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeByUser = bool;
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreferenceOnEdit(bool) {
-    this.setState({ preferDrawerModeOnEditByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeOnEditByUser = bool;
-  }
+  // setEditorMode(editorMode) {
+  //   const { isNotCreatable } = this.getPageContainer().state;
+
+  //   if (this.appContainer.currentUser == null) {
+  //     logger.warn('Please login or signup to edit the page or use hackmd.');
+  //     return;
+  //   }
+
+  //   if (isNotCreatable) {
+  //     logger.warn('This page could not edit.');
+  //     return;
+  //   }
+
+  //   this.setState({ editorMode });
+  //   if (editorMode === 'view') {
+  //     $('body').removeClass('on-edit');
+  //     $('body').removeClass('builtin-editor');
+  //     $('body').removeClass('hackmd');
+  //     $('body').removeClass('pathname-sidebar');
+  //     window.history.replaceState(null, '', window.location.pathname);
+  //   }
+
+  //   if (editorMode === 'edit') {
+  //     $('body').addClass('on-edit');
+  //     $('body').addClass('builtin-editor');
+  //     $('body').removeClass('hackmd');
+  //     // editing /Sidebar
+  //     if (window.location.pathname === '/Sidebar') {
+  //       $('body').addClass('pathname-sidebar');
+  //     }
+  //     window.location.hash = '#edit';
+  //   }
+
+  //   if (editorMode === 'hackmd') {
+  //     $('body').addClass('on-edit');
+  //     $('body').addClass('hackmd');
+  //     $('body').removeClass('builtin-editor');
+  //     $('body').removeClass('pathname-sidebar');
+  //     window.location.hash = '#hackmd';
+  //   }
+
+  //   this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
+  // }
 
 
   /**
   /**
    * Update drawer related state by specified 'newState' object
    * Update drawer related state by specified 'newState' object
@@ -176,36 +111,19 @@ export default class NavigationContainer extends Container {
    *
    *
    * because updating state of unstated container will be delayed unless you use await
    * because updating state of unstated container will be delayed unless you use await
    */
    */
-  updateDrawerMode(newState) {
-    const {
-      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-    } = newState;
-
-    // get preference on view or edit
-    const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-    const isDrawerOpened = false; // close Drawer anyway
-
-    this.setState({ isDrawerMode, isDrawerOpened });
-  }
+  // updateDrawerMode(newState) {
+  //   const {
+  //     editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
+  //   } = newState;
 
 
-  selectSidebarContents(contentsId) {
-    window.localStorage.setItem('sidebarContentsId', contentsId);
-    this.setState({ sidebarContentsId: contentsId });
-  }
+  //   // get preference on view or edit
+  //   const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
 
 
-  openPageCreateModal() {
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to create a new page.');
-      return;
-    }
-    this.setState({ isPageCreateModalShown: true });
-  }
+  //   const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
+  //   const isDrawerOpened = false; // close Drawer anyway
 
 
-  closePageCreateModal() {
-    this.setState({ isPageCreateModalShown: false });
-  }
+  //   this.setState({ isDrawerMode, isDrawerOpened });
+  // }
 
 
   /**
   /**
    * Function that implements the click event for realizing smooth scroll
    * Function that implements the click event for realizing smooth scroll

+ 5 - 5
packages/app/src/client/services/PageContainer.js

@@ -162,12 +162,12 @@ export default class PageContainer extends Container {
   }
   }
 
 
 
 
-  get isAbleToOpenPageEditor() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isGuestUser } = this.appContainer;
+  // get isAbleToOpenPageEditor() {
+  //   const { isNotCreatable, isTrashPage } = this.state;
+  //   const { isGuestUser } = this.appContainer;
 
 
-    return (!isNotCreatable && !isTrashPage && !isGuestUser);
-  }
+  //   return (!isNotCreatable && !isTrashPage && !isGuestUser);
+  // }
 
 
   /**
   /**
    * whether to display reaction buttons
    * whether to display reaction buttons

+ 0 - 0
packages/app/src/services/user-ui-settings.ts → packages/app/src/client/services/user-ui-settings.ts


+ 1 - 1
packages/app/src/client/util/apiv3-client.ts

@@ -36,7 +36,7 @@ const apiv3ErrorHandler = (_err) => {
 export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
 export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
   try {
   try {
     const res = await axios[method](urljoin(apiv3Root, path), params);
     const res = await axios[method](urljoin(apiv3Root, path), params);
-    return res.data;
+    return res;
   }
   }
   catch (err) {
   catch (err) {
     const errors = apiv3ErrorHandler(err);
     const errors = apiv3ErrorHandler(err);

+ 2 - 1
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -70,7 +70,8 @@ class ElasticsearchManagement extends React.Component {
     const { appContainer } = this.props;
     const { appContainer } = this.props;
 
 
     try {
     try {
-      const { info } = await appContainer.apiv3Get('/search/indices');
+      const { data } = await appContainer.apiv3Get('/search/indices');
+      const { info } = data;
 
 
       this.setState({
       this.setState({
         isConnected: true,
         isConnected: true,

+ 5 - 1
packages/app/src/components/Fab.jsx

@@ -6,6 +6,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { usePageCreateModalOpened } from '~/stores/ui';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
@@ -16,6 +18,8 @@ const Fab = (props) => {
   const { navigationContainer, appContainer } = props;
   const { navigationContainer, appContainer } = props;
   const { currentUser } = appContainer;
   const { currentUser } = appContainer;
 
 
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
   const [buttonClasses, setButtonClasses] = useState('');
 
 
@@ -52,7 +56,7 @@ const Fab = (props) => {
           <button
           <button
             type="button"
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
-            onClick={navigationContainer.openPageCreateModal}
+            onClick={() => mutatePageCreateModalOpened(true)}
           >
           >
             <CreatePageIcon />
             <CreatePageIcon />
           </button>
           </button>

+ 9 - 11
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,31 +1,29 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 
-const CreatePage = (props) => {
+const CreatePage = React.memo((props) => {
+
+  const { mutate } = usePageCreateModalOpened();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
-    props.navigationContainer.openPageCreateModal();
+    mutate(true);
 
 
     // remove this
     // remove this
     props.onDeleteRender(this);
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutate, props]);
 
 
   return <></>;
   return <></>;
-};
+});
 
 
 CreatePage.propTypes = {
 CreatePage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 };
 
 
-const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
-
-CreatePageWrapper.getHotkeyStrokes = () => {
+CreatePage.getHotkeyStrokes = () => {
   return [['c']];
   return [['c']];
 };
 };
 
 
-export default CreatePageWrapper;
+export default CreatePage;

+ 4 - 2
packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx

@@ -3,8 +3,10 @@ import PropTypes from 'prop-types';
 
 
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 const EditPage = (props) => {
 const EditPage = (props) => {
+  const { mutate: mutateEditorMode } = useEditorMode();
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
@@ -13,11 +15,11 @@ const EditPage = (props) => {
       return;
       return;
     }
     }
 
 
-    props.navigationContainer.setEditorMode('edit');
+    mutateEditorMode(EditorMode.Editor);
 
 
     // remove this
     // remove this
     props.onDeleteRender(this);
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutateEditorMode, props]);
 
 
   return <></>;
   return <></>;
 };
 };

+ 3 - 3
packages/app/src/components/Icons/GrowiLogo.jsx

@@ -1,6 +1,6 @@
-import React from 'react';
+import React, { memo } from 'react';
 
 
-const GrowiLogo = () => (
+const GrowiLogo = memo(() => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     width="32"
     width="32"
@@ -29,6 +29,6 @@ const GrowiLogo = () => (
     >
     >
     </path>
     </path>
   </svg>
   </svg>
-);
+));
 
 
 export default GrowiLogo;
 export default GrowiLogo;

+ 0 - 115
packages/app/src/components/Navbar/GrowiNavbar.jsx

@@ -1,115 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { UncontrolledTooltip } from 'reactstrap';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import AppContainer from '~/client/services/AppContainer';
-
-
-import GrowiLogo from '../Icons/GrowiLogo';
-
-import PersonalDropdown from './PersonalDropdown';
-import GlobalSearch from './GlobalSearch';
-
-class GrowiNavbar extends React.Component {
-
-  renderNavbarRight() {
-    const { t, appContainer, navigationContainer } = this.props;
-    const { currentUser } = appContainer;
-
-    // render login button
-    if (currentUser == null) {
-      return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
-    }
-
-    return (
-      <>
-        <li className="nav-item d-none d-md-block">
-          <button className="px-md-2 nav-link btn-create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
-            <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('New') }</span>
-          </button>
-        </li>
-
-        <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
-          <PersonalDropdown />
-        </li>
-      </>
-    );
-  }
-
-  renderConfidential() {
-    const { appContainer } = this.props;
-    const { crowi } = appContainer.config;
-
-    return (
-      <li className="nav-item confidential text-light">
-        <i id="confidentialTooltip" className="icon-info d-md-none" />
-        <span className="d-none d-md-inline">
-          {crowi.confidential}
-        </span>
-        <UncontrolledTooltip
-          placement="bottom"
-          target="confidentialTooltip"
-          className="d-md-none"
-        >
-          {crowi.confidential}
-        </UncontrolledTooltip>
-      </li>
-    );
-  }
-
-  render() {
-    const { appContainer, navigationContainer } = this.props;
-    const { crowi, isSearchServiceConfigured } = appContainer.config;
-    const { isDeviceSmallerThanMd } = navigationContainer.state;
-
-    return (
-      <>
-
-        {/* Brand Logo  */}
-        <div className="navbar-brand mr-0">
-          <a className="grw-logo d-block" href="/">
-            <GrowiLogo />
-          </a>
-        </div>
-
-        <div className="grw-app-title d-none d-md-block">
-          {crowi.title}
-        </div>
-
-
-        {/* Navbar Right  */}
-        <ul className="navbar-nav ml-auto">
-          {this.renderNavbarRight()}
-          {crowi.confidential != null && this.renderConfidential()}
-        </ul>
-
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
-          <div className="grw-global-search grw-global-search-top position-absolute">
-            <GlobalSearch />
-          </div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer, NavigationContainer]);
-
-
-GrowiNavbar.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withTranslation()(GrowiNavbarWrapper);

+ 128 - 0
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -0,0 +1,128 @@
+import React, { FC, memo } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '~/interfaces/user';
+import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import GrowiLogo from '../Icons/GrowiLogo';
+
+import PersonalDropdown from './PersonalDropdown';
+import GlobalSearch from './GlobalSearch';
+
+type NavbarRightProps = {
+  currentUser: IUser,
+}
+const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+  const { t } = useTranslation();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
+  const { currentUser } = props;
+
+  // render login button
+  if (currentUser == null) {
+    return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+  }
+
+  return (
+    <>
+      <li className="nav-item d-none d-md-block">
+        <button
+          className="px-md-2 nav-link btn-create-page border-0 bg-transparent"
+          type="button"
+          onClick={() => mutatePageCreateModalOpened(true)}
+        >
+          <i className="icon-pencil mr-2"></i>
+          <span className="d-none d-lg-block">{ t('New') }</span>
+        </button>
+      </li>
+
+      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+        <PersonalDropdown />
+      </li>
+    </>
+  );
+});
+
+type ConfidentialProps = {
+  confidential?: string,
+}
+const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
+  const { confidential } = props;
+
+  if (confidential == null) {
+    return null;
+  }
+
+  return (
+    <li className="nav-item confidential text-light">
+      <i id="confidentialTooltip" className="icon-info d-md-none" />
+      <span className="d-none d-md-inline">
+        {confidential}
+      </span>
+      <UncontrolledTooltip
+        placement="bottom"
+        target="confidentialTooltip"
+        className="d-md-none"
+      >
+        {confidential}
+      </UncontrolledTooltip>
+    </li>
+  );
+});
+
+
+const GrowiNavbar = (props) => {
+
+  const { appContainer } = props;
+  const { currentUser } = appContainer;
+  const { crowi, isSearchServiceConfigured } = appContainer.config;
+
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
+  return (
+    <>
+      {/* Brand Logo  */}
+      <div className="navbar-brand mr-0">
+        <a className="grw-logo d-block" href="/">
+          <GrowiLogo />
+        </a>
+      </div>
+
+      <div className="grw-app-title d-none d-md-block">
+        {crowi.title}
+      </div>
+
+
+      {/* Navbar Right  */}
+      <ul className="navbar-nav ml-auto">
+        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <Confidential confidential={crowi.confidential}></Confidential>
+      </ul>
+
+      { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+        <div className="grw-global-search grw-global-search-top position-absolute">
+          <GlobalSearch />
+        </div>
+      ) }
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer]);
+
+
+GrowiNavbar.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default GrowiNavbarWrapper;

+ 8 - 4
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -2,8 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
 
+import { withUnstatedContainers } from '../UnstatedUtils';
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
 
 
 const GrowiNavbarBottom = (props) => {
 const GrowiNavbarBottom = (props) => {
@@ -11,7 +12,10 @@ const GrowiNavbarBottom = (props) => {
   const {
   const {
     navigationContainer,
     navigationContainer,
   } = props;
   } = props;
-  const { isDrawerOpened, isDeviceSmallerThanMd } = navigationContainer.state;
+
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
 
   const additionalClasses = ['grw-navbar-bottom'];
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
   if (isDrawerOpened) {
@@ -36,7 +40,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.toggleDrawer()}
+              onClick={() => mutateDrawerOpened(true)}
             >
             >
               <i className="icon-menu"></i>
               <i className="icon-menu"></i>
             </a>
             </a>
@@ -55,7 +59,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.openPageCreateModal()}
+              onClick={() => mutatePageCreateModalOpened(true)}
             >
             >
               <i className="icon-pencil"></i>
               <i className="icon-pencil"></i>
             </a>
             </a>

+ 13 - 12
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,16 +1,16 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { withTranslation } from 'react-i18next';
-
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
 
 
 import CopyDropdown from '../Page/CopyDropdown';
 import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
 import TagLabels from '../Page/TagLabels';
@@ -67,21 +67,24 @@ const PagePathNav = ({
 };
 };
 
 
 const GrowiSubNavigation = (props) => {
 const GrowiSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+
   const {
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, isCompactMode,
   } = props;
   } = props;
-  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
   } = pageContainer.state;
 
 
   const { isGuestUser } = appContainer;
   const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== 'view';
+  const isEditorMode = editorMode !== EditorMode.View;
   // Tags cannot be edited while the new page and editorMode is view
   // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
+  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
 
 
   function onPageEditorModeButtonClicked(viewType) {
   function onPageEditorModeButtonClicked(viewType) {
-    navigationContainer.setEditorMode(viewType);
+    mutateEditorMode(viewType);
   }
   }
 
 
   return (
   return (
@@ -145,16 +148,14 @@ const GrowiSubNavigation = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
 
 
 
 
 GrowiSubNavigation.propTypes = {
 GrowiSubNavigation.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
 
   isCompactMode: PropTypes.bool,
   isCompactMode: PropTypes.bool,
 };
 };
 
 
-export default withTranslation()(GrowiSubNavigationWrapper);
+export default GrowiSubNavigationWrapper;

+ 17 - 15
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -1,10 +1,12 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 /* eslint-disable react/prop-types */
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
 const PageEditorModeButtonWrapper = React.memo(({
@@ -36,14 +38,17 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 
 function PageEditorModeManager(props) {
 function PageEditorModeManager(props) {
   const {
   const {
-    t, appContainer,
-    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled,
   } = props;
   } = props;
 
 
+  const { t } = useTranslation();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
   const isAdmin = appContainer.isAdmin;
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
     if (isBtnDisabled) {
@@ -62,32 +67,32 @@ function PageEditorModeManager(props) {
         aria-label="page-editor-mode-manager"
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
       >
       >
-        {(!isDeviceSmallerThanMd || editorMode !== 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="view"
+            targetMode={EditorMode.View}
             icon={<i className="icon-control-play" />}
             icon={<i className="icon-control-play" />}
             label={t('view')}
             label={t('view')}
           />
           />
         )}
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="edit"
+            targetMode={EditorMode.Editor}
             icon={<i className="icon-note" />}
             icon={<i className="icon-note" />}
             label={t('Edit')}
             label={t('Edit')}
           />
           />
         )}
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
           <PageEditorModeButtonWrapper
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="hackmd"
+            targetMode={EditorMode.HackMD}
             icon={<i className="fa fa-file-text-o" />}
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
             label={t('hackmd.hack_md')}
             id="grw-page-editor-mode-manager-hackmd-button"
             id="grw-page-editor-mode-manager-hackmd-button"
@@ -110,18 +115,15 @@ function PageEditorModeManager(props) {
 }
 }
 
 
 PageEditorModeManager.propTypes = {
 PageEditorModeManager.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
   onPageEditorModeButtonClicked: PropTypes.func,
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
   editorMode: PropTypes.string,
-  isDeviceSmallerThanMd: PropTypes.bool,
 };
 };
 
 
 PageEditorModeManager.defaultProps = {
 PageEditorModeManager.defaultProps = {
   isBtnDisabled: false,
   isBtnDisabled: false,
-  isDeviceSmallerThanMd: false,
 };
 };
 
 
 /**
 /**
@@ -129,4 +131,4 @@ PageEditorModeManager.defaultProps = {
  */
  */
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 
 
-export default withTranslation()(PageEditorModeManagerWrapper);
+export default PageEditorModeManagerWrapper;

+ 4 - 2
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -6,11 +6,13 @@ import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
-import { scheduleToPutUserUISettings } from '~/services/user-ui-settings';
 
 
 import {
 import {
   isUserPreferenceExists,
   isUserPreferenceExists,

+ 3 - 3
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import BookmarkButton from '../BookmarkButton';
 import BookmarkButton from '../BookmarkButton';
@@ -14,7 +15,7 @@ const SubnavButtons = (props) => {
     appContainer, navigationContainer, pageContainer, isCompactMode,
     appContainer, navigationContainer, pageContainer, isCompactMode,
   } = props;
   } = props;
 
 
-  /* eslint-enable react/prop-types */
+  const { data: editorMode } = useEditorMode();
 
 
   /* eslint-disable react/prop-types */
   /* eslint-disable react/prop-types */
   const PageReactionButtons = ({ pageContainer }) => {
   const PageReactionButtons = ({ pageContainer }) => {
@@ -34,8 +35,7 @@ const SubnavButtons = (props) => {
   };
   };
   /* eslint-enable react/prop-types */
   /* eslint-enable react/prop-types */
 
 
-  const { editorMode } = navigationContainer.state;
-  const isViewMode = editorMode === 'view';
+  const isViewMode = editorMode === EditorMode.View;
 
 
   return (
   return (
     <>
     <>

+ 13 - 9
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import React from 'react';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
 import propTypes from 'prop-types';
 import propTypes from 'prop-types';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import Editor from '../PageEditor';
 import Editor from '../PageEditor';
 import Page from '../Page';
 import Page from '../Page';
 import UserInfo from '../User/UserInfo';
 import UserInfo from '../User/UserInfo';
@@ -16,15 +18,18 @@ import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 
 
 const DisplaySwitcher = (props) => {
 const DisplaySwitcher = (props) => {
   const {
   const {
-    navigationContainer, pageContainer,
+    pageContainer,
   } = props;
   } = props;
-  const { editorMode } = navigationContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
 
 
+  const { data: editorMode } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
   return (
     <>
     <>
       <TabContent activeTab={editorMode}>
       <TabContent activeTab={editorMode}>
-        <TabPane tabId="view">
+        <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
           <div className="d-flex flex-column flex-lg-row-reverse">
 
 
             <div className="grw-side-contents-container">
             <div className="grw-side-contents-container">
@@ -49,26 +54,25 @@ const DisplaySwitcher = (props) => {
 
 
           </div>
           </div>
         </TabPane>
         </TabPane>
-        <TabPane tabId="edit">
+        <TabPane tabId={EditorMode.Editor}>
           <div id="page-editor">
           <div id="page-editor">
             <Editor />
             <Editor />
           </div>
           </div>
         </TabPane>
         </TabPane>
-        <TabPane tabId="hackmd">
+        <TabPane tabId={EditorMode.HackMD}>
           <div id="page-editor-with-hackmd">
           <div id="page-editor-with-hackmd">
             <PageEditorByHackmd />
             <PageEditorByHackmd />
           </div>
           </div>
         </TabPane>
         </TabPane>
       </TabContent>
       </TabContent>
-      {editorMode !== 'view' && <EditorNavbarBottom /> }
+      {!isViewMode && <EditorNavbarBottom /> }
     </>
     </>
   );
   );
 };
 };
 
 
 DisplaySwitcher.propTypes = {
 DisplaySwitcher.propTypes = {
-  navigationContainer: propTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
 
 
-export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer, PageContainer]);
+export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 13 - 13
packages/app/src/components/Page/NotFoundAlert.jsx

@@ -1,24 +1,26 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 
 
 const NotFoundAlert = (props) => {
 const NotFoundAlert = (props) => {
-  const { t, isHidden, isGuestUserMode } = props;
-  function clickHandler(viewType) {
+  const { t } = useTranslation();
+  const { isHidden, isGuestUserMode } = props;
 
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const clickHandler = useCallback(() => {
     // check guest user,
     // check guest user,
     // disabled of button cannot be used for using tooltip.
     // disabled of button cannot be used for using tooltip.
     if (isGuestUserMode) {
     if (isGuestUserMode) {
       return;
       return;
     }
     }
 
 
-    if (props.onPageCreateClicked === null) {
-      return;
-    }
-    props.onPageCreateClicked(viewType);
-  }
+    mutateEditorMode(EditorMode.Editor);
+
+  }, [isGuestUserMode, mutateEditorMode]);
 
 
   if (isHidden) {
   if (isHidden) {
     return null;
     return null;
@@ -38,7 +40,7 @@ const NotFoundAlert = (props) => {
           <button
           <button
             type="button"
             type="button"
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={() => { clickHandler('edit') }}
+            onClick={clickHandler}
           >
           >
             <i className="icon-note icon-fw" />
             <i className="icon-note icon-fw" />
             {t('not_found_page.Create Page')}
             {t('not_found_page.Create Page')}
@@ -58,10 +60,8 @@ const NotFoundAlert = (props) => {
 
 
 
 
 NotFoundAlert.propTypes = {
 NotFoundAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  onPageCreateClicked: PropTypes.func,
   isHidden: PropTypes.bool.isRequired,
   isHidden: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
 };
 };
 
 
-export default withTranslation()(NotFoundAlert);
+export default NotFoundAlert;

+ 8 - 7
packages/app/src/components/PageCreateModal.jsx

@@ -11,9 +11,9 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
@@ -22,7 +22,9 @@ const {
 } = pagePathUtils;
 } = pagePathUtils;
 
 
 const PageCreateModal = (props) => {
 const PageCreateModal = (props) => {
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
+
+  const { data: isPageCreateModalOpened, mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
 
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
@@ -264,12 +266,12 @@ const PageCreateModal = (props) => {
   return (
   return (
     <Modal
     <Modal
       size="lg"
       size="lg"
-      isOpen={navigationContainer.state.isPageCreateModalShown}
-      toggle={navigationContainer.closePageCreateModal}
+      isOpen={isPageCreateModalOpened}
+      toggle={() => mutatePageCreateModalOpened(false)}
       className="grw-create-page"
       className="grw-create-page"
       autoFocus={false}
       autoFocus={false}
     >
     >
-      <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={() => mutatePageCreateModalOpened(false)} className="bg-primary text-light">
         {t('New Page')}
         {t('New Page')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -286,13 +288,12 @@ const PageCreateModal = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer, NavigationContainer]);
+const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
 
 
 
 
 PageCreateModal.propTypes = {
 PageCreateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(ModalControlWrapper);
 export default withTranslation()(ModalControlWrapper);

+ 15 - 9
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx

@@ -3,9 +3,12 @@ import PropTypes from 'prop-types';
 
 
 import { Collapse, Button } from 'reactstrap';
 import { Collapse, Button } from 'reactstrap';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import {
+  EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
+
 import SlackNotification from '../SlackNotification';
 import SlackNotification from '../SlackNotification';
 import SlackLogo from '../SlackLogo';
 import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -16,20 +19,24 @@ import OptionsSelector from './OptionsSelector';
 
 
 const EditorNavbarBottom = (props) => {
 const EditorNavbarBottom = (props) => {
 
 
+  const { data: editorMode } = useEditorMode();
+
   const [isExpanded, setExpanded] = useState(false);
   const [isExpanded, setExpanded] = useState(false);
 
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
 
-  const {
-    navigationContainer,
-  } = props;
-  const { editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
+  const { mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
 
 
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
 
   const renderDrawerButton = () => (
   const renderDrawerButton = () => (
-    <button type="button" className="btn btn-outline-secondary border-0" onClick={() => navigationContainer.toggleDrawer()}>
+    <button
+      type="button"
+      className="btn btn-outline-secondary border-0"
+      onClick={() => mutateDrawerOpened(true)}
+    >
       <i className="icon-menu"></i>
       <i className="icon-menu"></i>
     </button>
     </button>
   );
   );
@@ -55,7 +62,7 @@ const EditorNavbarBottom = (props) => {
     </div>
     </div>
   );
   );
 
 
-  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isOptionsSelectorEnabled = editorMode !== EditorMode.HackMD;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
 
 
   return (
   return (
@@ -127,9 +134,8 @@ const EditorNavbarBottom = (props) => {
 };
 };
 
 
 EditorNavbarBottom.propTypes = {
 EditorNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer, EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);

+ 23 - 29
packages/app/src/components/PaginationWrapper.jsx → packages/app/src/components/PaginationWrapper.tsx

@@ -1,18 +1,21 @@
-import React, { useCallback, useMemo } from 'react';
-import PropTypes from 'prop-types';
+import React, {
+  FC, memo, useCallback, useMemo,
+} from 'react';
 
 
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
 
-/**
- *
- * @author Mikitaka Itizawa <itizawa@weseek.co.jp>
- *
- * @export
- * @class PaginationWrapper
- * @extends {React.Component}
- */
 
 
-const PaginationWrapper = React.memo((props) => {
+type Props = {
+  activePage: number,
+  changePage?: (number) => void,
+  totalItemsCount: number,
+  pagingLimit?: number,
+  align?: string,
+  size?: string,
+};
+
+
+const PaginationWrapper: FC<Props> = memo((props: Props) => {
   const {
   const {
     activePage, changePage, totalItemsCount, pagingLimit, align,
     activePage, changePage, totalItemsCount, pagingLimit, align,
   } = props;
   } = props;
@@ -59,14 +62,14 @@ const PaginationWrapper = React.memo((props) => {
    * this function set << & <
    * this function set << & <
    */
    */
   const generateFirstPrev = useCallback(() => {
   const generateFirstPrev = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     if (activePage !== 1) {
     if (activePage !== 1) {
       paginationItems.push(
       paginationItems.push(
         <PaginationItem key="painationItemFirst">
         <PaginationItem key="painationItemFirst">
-          <PaginationLink first onClick={() => { return changePage(1) }} />
+          <PaginationLink first onClick={() => { return changePage != null && changePage(1) }} />
         </PaginationItem>,
         </PaginationItem>,
         <PaginationItem key="painationItemPrevious">
         <PaginationItem key="painationItemPrevious">
-          <PaginationLink previous onClick={() => { return changePage(activePage - 1) }} />
+          <PaginationLink previous onClick={() => { return changePage != null && changePage(activePage - 1) }} />
         </PaginationItem>,
         </PaginationItem>,
       );
       );
     }
     }
@@ -89,11 +92,11 @@ const PaginationWrapper = React.memo((props) => {
    * this function set  numbers
    * this function set  numbers
    */
    */
   const generatePaginations = useCallback(() => {
   const generatePaginations = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
       paginationItems.push(
         <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
         <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
-          <PaginationLink onClick={() => { return changePage(number) }}>
+          <PaginationLink onClick={() => { return changePage != null && changePage(number) }}>
             {number}
             {number}
           </PaginationLink>
           </PaginationLink>
         </PaginationItem>,
         </PaginationItem>,
@@ -108,14 +111,14 @@ const PaginationWrapper = React.memo((props) => {
    * this function set > & >>
    * this function set > & >>
    */
    */
   const generateNextLast = useCallback(() => {
   const generateNextLast = useCallback(() => {
-    const paginationItems = [];
+    const paginationItems: JSX.Element[] = [];
     if (totalPage !== activePage) {
     if (totalPage !== activePage) {
       paginationItems.push(
       paginationItems.push(
         <PaginationItem key="painationItemNext">
         <PaginationItem key="painationItemNext">
-          <PaginationLink next onClick={() => { return changePage(activePage + 1) }} />
+          <PaginationLink next onClick={() => { return changePage != null && changePage(activePage + 1) }} />
         </PaginationItem>,
         </PaginationItem>,
         <PaginationItem key="painationItemLast">
         <PaginationItem key="painationItemLast">
-          <PaginationLink last onClick={() => { return changePage(totalPage) }} />
+          <PaginationLink last onClick={() => { return changePage != null && changePage(totalPage) }} />
         </PaginationItem>,
         </PaginationItem>,
       );
       );
     }
     }
@@ -133,7 +136,7 @@ const PaginationWrapper = React.memo((props) => {
   }, [activePage, changePage, totalPage]);
   }, [activePage, changePage, totalPage]);
 
 
   const getListClassName = useMemo(() => {
   const getListClassName = useMemo(() => {
-    const listClassNames = [];
+    const listClassNames: string[] = [];
 
 
     if (align === 'center') {
     if (align === 'center') {
       listClassNames.push('justify-content-center');
       listClassNames.push('justify-content-center');
@@ -157,15 +160,6 @@ const PaginationWrapper = React.memo((props) => {
 
 
 });
 });
 
 
-PaginationWrapper.propTypes = {
-  activePage: PropTypes.number.isRequired,
-  changePage: PropTypes.func.isRequired,
-  totalItemsCount: PropTypes.number.isRequired,
-  pagingLimit: PropTypes.number,
-  align: PropTypes.string,
-  size: PropTypes.string,
-};
-
 PaginationWrapper.defaultProps = {
 PaginationWrapper.defaultProps = {
   align: 'left',
   align: 'left',
   size: 'md',
   size: 'md',

+ 2 - 1
packages/app/src/components/SearchForm.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
@@ -174,4 +175,4 @@ SearchForm.defaultProps = {
   onInputChange: () => {},
   onInputChange: () => {},
 };
 };
 
 
-export default SearchFormWrapper;
+export default withTranslation()(SearchFormWrapper);

+ 163 - 25
packages/app/src/components/SearchPage.jsx

@@ -8,24 +8,45 @@ import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 
 
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
+import SearchPageLayout from './SearchPage/SearchPageLayout';
+import SearchResultContent from './SearchPage/SearchResultContent';
+import SearchResultList from './SearchPage/SearchResultList';
+import SearchControl from './SearchPage/SearchControl';
 
 
-import SearchPageForm from './SearchPage/SearchPageForm';
-import SearchResult from './SearchPage/SearchResult';
+export const specificPathNames = {
+  user: '/user',
+  trash: '/trash',
+};
 
 
 class SearchPage extends React.Component {
 class SearchPage extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
-
+    // NOTE : selectedPages is deletion related state, will be used later in story 77535, 77565.
+    // deletionModal, deletion related functions are all removed, add them back when necessary.
+    // i.e ) in story 77525 or any tasks implementing deletion functionalities
     this.state = {
     this.state = {
       searchingKeyword: decodeURI(this.props.query.q) || '',
       searchingKeyword: decodeURI(this.props.query.q) || '',
       searchedKeyword: '',
       searchedKeyword: '',
       searchedPages: [],
       searchedPages: [],
       searchResultMeta: {},
       searchResultMeta: {},
+      focusedPage: {},
+      selectedPages: new Set(),
+      searchResultCount: 0,
+      activePage: 1,
+      pagingLimit: 10, // change to an appropriate limit number
+      excludeUsersHome: true,
+      excludeTrash: true,
     };
     };
 
 
-    this.search = this.search.bind(this);
     this.changeURL = this.changeURL.bind(this);
     this.changeURL = this.changeURL.bind(this);
+    this.search = this.search.bind(this);
+    this.searchHandler = this.searchHandler.bind(this);
+    this.selectPage = this.selectPage.bind(this);
+    this.toggleCheckBox = this.toggleCheckBox.bind(this);
+    this.onExcludeUsersHome = this.onExcludeUsersHome.bind(this);
+    this.onExcludeTrash = this.onExcludeTrash.bind(this);
+    this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
@@ -47,6 +68,14 @@ class SearchPage extends React.Component {
     return query;
     return query;
   }
   }
 
 
+  onExcludeUsersHome() {
+    this.setState({ excludeUsersHome: !this.state.excludeUsersHome });
+  }
+
+  onExcludeTrash() {
+    this.setState({ excludeTrash: !this.state.excludeTrash });
+  }
+
   changeURL(keyword, refreshHash) {
   changeURL(keyword, refreshHash) {
     let hash = window.location.hash || '';
     let hash = window.location.hash || '';
     // TODO 整理する
     // TODO 整理する
@@ -58,13 +87,48 @@ class SearchPage extends React.Component {
     }
     }
   }
   }
 
 
-  search(data) {
+  createSearchQuery(keyword) {
+    let query = keyword;
+
+    // pages included in specific path are not retrived when prefix is added
+    if (this.state.excludeTrash) {
+      query = `${query} -prefix:${specificPathNames.trash}`;
+    }
+    if (this.state.excludeUsersHome) {
+      query = `${query} -prefix:${specificPathNames.user}`;
+    }
+
+    return query;
+  }
+
+  /**
+   * this method is called when user changes paging number
+   */
+  async onPagingNumberChanged(activePage) {
+    // this.setState does not change the state immediately and following calls of this.search outside of this.setState will have old activePage state.
+    // To prevent above, pass this.search as a callback function to make sure this.search will have the latest activePage state.
+    this.setState({ activePage }, () => this.search({ keyword: this.state.searchedKeyword }));
+  }
+
+  /**
+   * this method is called when user searches by pressing Enter or using searchbox
+   */
+  async searchHandler(data) {
+    // this.setState does not change the state immediately and following calls of this.search outside of this.setState will have old activePage state.
+    // To prevent above, pass this.search as a callback function to make sure this.search will have the latest activePage state.
+    this.setState({ activePage: 1 }, () => this.search(data));
+  }
+
+  async search(data) {
     const keyword = data.keyword;
     const keyword = data.keyword;
     if (keyword === '') {
     if (keyword === '') {
       this.setState({
       this.setState({
         searchingKeyword: '',
         searchingKeyword: '',
+        searchedKeyword: '',
         searchedPages: [],
         searchedPages: [],
         searchResultMeta: {},
         searchResultMeta: {},
+        searchResultCount: 0,
+        activePage: 1,
       });
       });
 
 
       return true;
       return true;
@@ -73,37 +137,111 @@ class SearchPage extends React.Component {
     this.setState({
     this.setState({
       searchingKeyword: keyword,
       searchingKeyword: keyword,
     });
     });
-
-    this.props.appContainer.apiGet('/search', { q: keyword })
-      .then((res) => {
-        this.changeURL(keyword);
-
+    const pagingLimit = this.state.pagingLimit;
+    const offset = (this.state.activePage * pagingLimit) - pagingLimit;
+    try {
+      const res = await this.props.appContainer.apiGet('/search', {
+        q: this.createSearchQuery(keyword),
+        limit: pagingLimit,
+        offset,
+      });
+      this.changeURL(keyword);
+      if (res.data.length > 0) {
         this.setState({
         this.setState({
           searchedKeyword: keyword,
           searchedKeyword: keyword,
           searchedPages: res.data,
           searchedPages: res.data,
           searchResultMeta: res.meta,
           searchResultMeta: res.meta,
+          searchResultCount: res.meta.total,
+          focusedPage: res.data[0],
+          // reset active page if keyword changes, otherwise set the current state
+          activePage: this.state.searchedKeyword === keyword ? this.state.activePage : 1,
         });
         });
-      })
-      .catch((err) => {
-        toastError(err);
-      });
+      }
+      else {
+        this.setState({
+          searchedKeyword: keyword,
+          searchedPages: [],
+          searchResultMeta: {},
+          searchResultCount: 0,
+          focusedPage: {},
+          activePage: 1,
+        });
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  selectPage= (pageId) => {
+    const index = this.state.searchedPages.findIndex((page) => {
+      return page._id === pageId;
+    });
+    this.setState({
+      focusedPage: this.state.searchedPages[index],
+    });
+  }
+
+  toggleCheckBox = (page) => {
+    if (this.state.selectedPages.has(page)) {
+      this.state.selectedPages.delete(page);
+    }
+    else {
+      this.state.selectedPages.add(page);
+    }
+  }
+
+  renderSearchResultContent = () => {
+    return (
+      <SearchResultContent
+        appContainer={this.props.appContainer}
+        searchingKeyword={this.state.searchingKeyword}
+        focusedPage={this.state.focusedPage}
+      >
+      </SearchResultContent>
+    );
+  }
+
+  renderSearchResultList = () => {
+    return (
+      <SearchResultList
+        pages={this.state.searchedPages || []}
+        focusedPage={this.state.focusedPage}
+        selectedPages={this.state.selectedPages || []}
+        searchResultCount={this.state.searchResultCount}
+        activePage={this.state.activePage}
+        pagingLimit={this.state.pagingLimit}
+        onClickInvoked={this.selectPage}
+        onChangedInvoked={this.toggleCheckBox}
+        onPagingNumberChanged={this.onPagingNumberChanged}
+      />
+    );
+  }
+
+  renderSearchControl = () => {
+    return (
+      <SearchControl
+        searchingKeyword={this.state.searchingKeyword}
+        appContainer={this.props.appContainer}
+        onSearchInvoked={this.searchHandler}
+        onExcludeUsersHome={this.onExcludeUsersHome}
+        onExcludeTrash={this.onExcludeTrash}
+      >
+      </SearchControl>
+    );
   }
   }
 
 
   render() {
   render() {
     return (
     return (
       <div>
       <div>
-        <div className="search-page-input sps sps--abv">
-          <SearchPageForm
-            t={this.props.t}
-            onSearchFormChanged={this.search}
-            keyword={this.state.searchingKeyword}
-          />
-        </div>
-        <SearchResult
-          pages={this.state.searchedPages}
-          searchingKeyword={this.state.searchingKeyword}
+        <SearchPageLayout
+          SearchControl={this.renderSearchControl}
+          SearchResultList={this.renderSearchResultList}
+          SearchResultContent={this.renderSearchResultContent}
           searchResultMeta={this.state.searchResultMeta}
           searchResultMeta={this.state.searchResultMeta}
-        />
+          searchingKeyword={this.state.searchedKeyword}
+        >
+        </SearchPageLayout>
       </div>
       </div>
     );
     );
   }
   }

+ 62 - 0
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -0,0 +1,62 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import loggerFactory from '~/utils/logger';
+import { CheckboxType } from '../../interfaces/search';
+
+const logger = loggerFactory('growi:searchResultList');
+
+type Props = {
+  checkboxState: CheckboxType,
+  onClickInvoked?: () => void,
+  onCheckInvoked?: (string:CheckboxType) => void,
+}
+
+const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
+  const { t } = useTranslation();
+  const {
+    checkboxState, onClickInvoked, onCheckInvoked,
+  } = props;
+
+  const changeCheckboxStateHandler = () => {
+    console.log(`changeCheckboxStateHandler is called. current changebox state is ${checkboxState}`);
+    // Todo: determine next checkboxState from one of the following and tell the parent component
+    // to change the checkboxState by passing onCheckInvoked function the next checkboxState
+    // - NONE_CHECKED
+    // - INDETERMINATE
+    // - ALL_CHECKED
+    // https://estoc.weseek.co.jp/redmine/issues/77525
+    // use CheckboxType by importing from packages/app/src/interfaces/
+    if (onCheckInvoked == null) { logger.error('onCheckInvoked is null') }
+    else { onCheckInvoked(CheckboxType.ALL_CHECKED) } // change this to an appropriate value
+  };
+
+
+  return (
+    <div className="d-flex align-items-center">
+      <input
+        id="check-all-pages"
+        type="checkbox"
+        name="check-all-pages"
+        className="custom-control custom-checkbox ml-1 align-self-center"
+        onChange={changeCheckboxStateHandler}
+        checked={checkboxState === CheckboxType.INDETERMINATE || checkboxState === CheckboxType.ALL_CHECKED}
+      />
+      <button
+        type="button"
+        className="btn text-danger font-weight-light p-0 ml-2"
+        onClick={() => {
+          if (onClickInvoked == null) { logger.error('onClickInvoked is null') }
+          else { onClickInvoked() }
+        }}
+      >
+        <i className="icon-trash"></i>
+        {t('search_result.delete_all_selected_page')}
+      </button>
+    </div>
+  );
+
+};
+
+DeleteSelectedPageGroup.propTypes = {
+};
+export default DeleteSelectedPageGroup;

+ 42 - 0
packages/app/src/components/SearchPage/IncludeSpecificPathButton.jsx

@@ -0,0 +1,42 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
+const IncludeSpecificPathButton = (props) => {
+  const { pathToInclude, checked } = props;
+  const { t } = useTranslation();
+
+  // TODO : implement this function
+  // 77526 story https://estoc.weseek.co.jp/redmine/issues/77526
+  // 77535 stroy https://estoc.weseek.co.jp/redmine/issues/77535
+  function includeSpecificPathInSearchResult(pathToInclude) {
+    console.log(`now including ${pathToInclude} in search result`);
+  }
+  return (
+    <div className="border px-2 btn btn-outline-secondary">
+      <label className="mb-0">
+        <span className="font-weight-light">
+          {pathToInclude === '/user'
+            ? t('search_result.include_certain_path', { pathToInclude: '/user' }) : t('search_result.include_certain_path', { pathToInclude: '/trash' })}
+        </span>
+        <input
+          type="checkbox"
+          name="check-include-specific-path"
+          onChange={() => {
+            if (checked) {
+              includeSpecificPathInSearchResult(pathToInclude);
+            }
+          }}
+        />
+      </label>
+    </div>
+  );
+
+};
+
+IncludeSpecificPathButton.propTypes = {
+  pathToInclude: PropTypes.string.isRequired,
+  checked: PropTypes.bool.isRequired,
+};
+
+export default IncludeSpecificPathButton;

+ 104 - 0
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -0,0 +1,104 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import SearchPageForm from './SearchPageForm';
+import AppContainer from '../../client/services/AppContainer';
+import DeleteSelectedPageGroup from './DeleteSelectedPageGroup';
+import { CheckboxType } from '../../interfaces/search';
+
+type Props = {
+  searchingKeyword: string,
+  appContainer: AppContainer,
+  onSearchInvoked: (data : any[]) => boolean,
+  onExcludeUsersHome?: () => void,
+  onExcludeTrash?: () => void,
+}
+
+const SearchControl: FC <Props> = (props: Props) => {
+  // Temporaly workaround for lint error
+  // later needs to be fixed: SearchControl to typescript componet
+  const SearchPageFormTypeAny : any = SearchPageForm;
+  const { t } = useTranslation('');
+
+  const onExcludeUsersHome = () => {
+    if (props.onExcludeUsersHome != null) {
+      props.onExcludeUsersHome();
+    }
+  };
+
+  const onExcludeTrash = () => {
+    if (props.onExcludeTrash != null) {
+      props.onExcludeTrash();
+    }
+  };
+
+  const onDeleteSelectedPageHandler = () => {
+    console.log('onDeleteSelectedPageHandler is called');
+    // TODO: implement this function to delete selected pages.
+    // https://estoc.weseek.co.jp/redmine/issues/77525
+  };
+
+  const onCheckAllPagesInvoked = (nextCheckboxState:CheckboxType) => {
+    console.log(`onCheckAllPagesInvoked is called with arg ${nextCheckboxState}`);
+    // Todo: set the checkboxState, isChecked, and indeterminate value of checkbox element according to the passed argument
+    // https://estoc.weseek.co.jp/redmine/issues/77525
+
+    // setting checkbox to indeterminate is required to use of useRef to access checkbox element.
+    // ref: https://getbootstrap.com/docs/4.5/components/forms/#checkboxes
+  };
+
+  return (
+    <>
+      <div className="search-page-nav d-flex py-3 align-items-center">
+        <div className="flex-grow-1 mx-4">
+          <SearchPageFormTypeAny
+            keyword={props.searchingKeyword}
+            appContainer={props.appContainer}
+            onSearchFormChanged={props.onSearchInvoked}
+          />
+        </div>
+        <div className="mr-4">
+          {/* TODO: replace the following button */}
+          <button type="button">related pages</button>
+        </div>
+      </div>
+      {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
+      <div className="d-flex align-items-center py-3 border-bottom border-gray">
+        <div className="d-flex mr-auto ml-3">
+          {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
+          <DeleteSelectedPageGroup
+            checkboxState={'' || CheckboxType.NONE_CHECKED} // Todo: change the left value to appropriate value
+            onClickInvoked={onDeleteSelectedPageHandler}
+            onCheckInvoked={onCheckAllPagesInvoked}
+          />
+        </div>
+        <div className="d-flex align-items-center mr-3">
+          <div className="border border-gray mr-3">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckDefault">
+              <input
+                className="mr-2"
+                type="checkbox"
+                id="flexCheckDefault"
+                onClick={() => onExcludeUsersHome()}
+              />
+              {t('Include Subordinated Target Page', { target: '/user' })}
+            </label>
+          </div>
+          <div className="border border-gray">
+            <label className="px-3 py-2 mb-0 d-flex align-items-center" htmlFor="flexCheckChecked">
+              <input
+                className="mr-2"
+                type="checkbox"
+                id="flexCheckChecked"
+                onClick={() => onExcludeTrash()}
+              />
+              {t('Include Subordinated Target Page', { target: '/trash' })}
+            </label>
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+export default SearchControl;

+ 31 - 13
packages/app/src/components/SearchPage/SearchPageForm.jsx

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import SearchForm from '../SearchForm';
 import SearchForm from '../SearchForm';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:searchPageForm');
 
 
 // Search.SearchForm
 // Search.SearchForm
 class SearchPageForm extends React.Component {
 class SearchPageForm extends React.Component {
@@ -21,9 +24,14 @@ class SearchPageForm extends React.Component {
   }
   }
 
 
   search() {
   search() {
-    const keyword = this.state.keyword;
-    this.props.onSearchFormChanged({ keyword });
-    this.setState({ searchedKeyword: keyword });
+    if (this.props.onSearchFormChanged != null) {
+      const keyword = this.state.keyword;
+      this.props.onSearchFormChanged({ keyword });
+      this.setState({ searchedKeyword: keyword });
+    }
+    else {
+      throw new Error('onSearchFormChanged method is null');
+    }
   }
   }
 
 
   onInputChange(input) { // for only submitting with button
   onInputChange(input) { // for only submitting with button
@@ -32,19 +40,30 @@ class SearchPageForm extends React.Component {
 
 
   render() {
   render() {
     return (
     return (
-      <div className="input-group mb-3 d-flex">
-        <div className="flex-fill">
+      // TODO: modify design after other component is created
+      <div className="grw-search-form-in-search-result-page d-flex align-items-center">
+        <div className="input-group flex-nowrap">
           <SearchForm
           <SearchForm
-            t={this.props.t}
             onSubmit={this.search}
             onSubmit={this.search}
             keyword={this.state.searchedKeyword}
             keyword={this.state.searchedKeyword}
             onInputChange={this.onInputChange}
             onInputChange={this.onInputChange}
           />
           />
-        </div>
-        <div className="input-group-append">
-          <button className="btn btn-secondary" type="button" id="button-addon2" onClick={this.search}>
-            <i className="icon-magnifier"></i>
-          </button>
+          <div className="btn-group-submit-search">
+            <button
+              className="btn border-0 pb-1"
+              type="button"
+              onClick={() => {
+                try {
+                  this.search();
+                }
+                catch (error) {
+                  logger.error(error);
+                }
+              }}
+            >
+              <i className="pr-2 icon-magnifier"></i>
+            </button>
+          </div>
         </div>
         </div>
       </div>
       </div>
     );
     );
@@ -58,11 +77,10 @@ class SearchPageForm extends React.Component {
 const SearchPageFormWrapper = withUnstatedContainers(SearchPageForm, [AppContainer]);
 const SearchPageFormWrapper = withUnstatedContainers(SearchPageForm, [AppContainer]);
 
 
 SearchPageForm.propTypes = {
 SearchPageForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
   keyword: PropTypes.string,
   keyword: PropTypes.string,
-  onSearchFormChanged: PropTypes.func.isRequired,
+  onSearchFormChanged: PropTypes.func,
 };
 };
 SearchPageForm.defaultProps = {
 SearchPageForm.defaultProps = {
 };
 };

+ 52 - 0
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -0,0 +1,52 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+type SearchResultMeta = {
+  took : number,
+  total : number,
+  results: number
+}
+
+type Props = {
+  SearchControl: React.FunctionComponent,
+  SearchResultList: React.FunctionComponent,
+  SearchResultContent: React.FunctionComponent,
+  searchResultMeta: SearchResultMeta,
+  searchingKeyword: string
+}
+
+const SearchPageLayout: FC<Props> = (props: Props) => {
+  const { t } = useTranslation('');
+  const {
+    SearchResultList, SearchControl, SearchResultContent, searchResultMeta, searchingKeyword,
+  } = props;
+
+  return (
+    <div className="content-main">
+      <div className="search-result row" id="search-result">
+        <div className="col-lg-6  page-list border boder-gray search-result-list px-0" id="search-result-list">
+
+          <nav><SearchControl></SearchControl></nav>
+          <div className="d-flex align-items-start justify-content-between mt-1">
+            <div className="search-result-meta">
+              <span className="font-weight-light">{t('search_result.result_meta')} </span>
+              <span className="h5">{`"${searchingKeyword}"`}</span>
+              {/* Todo: replace "1-10" to the appropriate value */}
+              <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
+            </div>
+          </div>
+
+          <div className="page-list">
+            <ul className="page-list-ul page-list-ul-flat nav nav-pills"><SearchResultList></SearchResultList></ul>
+          </div>
+        </div>
+        <div className="col-lg-6 d-none d-lg-block search-result-content">
+          <SearchResultContent></SearchResultContent>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+
+export default SearchPageLayout;

+ 50 - 0
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -0,0 +1,50 @@
+import React, { FC } from 'react';
+
+import RevisionLoader from '../Page/RevisionLoader';
+import AppContainer from '../../client/services/AppContainer';
+
+
+type Props ={
+  appContainer: AppContainer,
+  searchingKeyword:string,
+  focusedPage : any,
+}
+const SearchResultContent: FC<Props> = (props: Props) => {
+  // Temporaly workaround for lint error
+  // later needs to be fixed: RevisoinRender to typescriptcomponet
+  const RevisionRenderTypeAny: any = RevisionLoader;
+  const renderPage = (page) => {
+    const growiRenderer = props.appContainer.getRenderer('searchresult');
+    let showTags = false;
+    if (page.tags != null && page.tags.length > 0) { showTags = true }
+    return (
+      <div key={page._id} className="search-result-page mb-5">
+        <h2>
+          <a href={page.path} className="text-break">
+            {page.path}
+          </a>
+          {showTags && (
+            <div className="mt-1 small">
+              <i className="tag-icon icon-tag"></i> {page.tags.join(', ')}
+            </div>
+          )}
+        </h2>
+        <RevisionRenderTypeAny
+          growiRenderer={growiRenderer}
+          pageId={page._id}
+          pagePath={page.path}
+          revisionId={page.revision}
+          highlightKeywords={props.searchingKeyword}
+        />
+      </div>
+    );
+  };
+  const content = renderPage(props.focusedPage);
+  return (
+
+    <div>{content}</div>
+  );
+};
+
+
+export default SearchResultContent;

+ 0 - 64
packages/app/src/components/SearchPage/SearchResultList.jsx

@@ -1,64 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import RevisionLoader from '../Page/RevisionLoader';
-import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-class SearchResultList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.growiRenderer = this.props.appContainer.getRenderer('searchresult');
-  }
-
-  render() {
-    const resultList = this.props.pages.map((page) => {
-      const showTags = (page.tags != null) && (page.tags.length > 0);
-
-      return (
-        // Add prefix 'id_' in id attr, because scrollspy of bootstrap doesn't work when the first letter of id of target component is numeral.
-        <div id={`id_${page._id}`} key={page._id} className="search-result-page mb-5">
-          <h2>
-            <a href={page.path} className="text-break">{page.path}</a>
-            { showTags && (
-              <div className="mt-1 small"><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</div>
-            )}
-          </h2>
-          <RevisionLoader
-            growiRenderer={this.growiRenderer}
-            pageId={page._id}
-            pagePath={page.path}
-            revisionId={page.revision}
-            highlightKeywords={this.props.searchingKeyword}
-          />
-        </div>
-      );
-    });
-
-    return (
-      <div>
-        {resultList}
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SearchResultListWrapper = withUnstatedContainers(SearchResultList, [AppContainer]);
-
-SearchResultList.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  pages: PropTypes.array.isRequired,
-  searchingKeyword: PropTypes.string.isRequired,
-};
-
-SearchResultList.defaultProps = {
-};
-
-export default SearchResultListWrapper;

+ 49 - 0
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -0,0 +1,49 @@
+import React, { FC } from 'react';
+import SearchResultListItem from './SearchResultListItem';
+import PaginationWrapper from '../PaginationWrapper';
+import { IPageSearchResultData } from '../../interfaces/search';
+
+
+type Props = {
+  pages: IPageSearchResultData[],
+  selectedPages: IPageSearchResultData[],
+  onClickInvoked?: (pageId: string) => void,
+  searchResultCount?: number,
+  activePage?: number,
+  pagingLimit?: number,
+  onPagingNumberChanged?: (activePage: number) => void,
+  focusedPage?: IPageSearchResultData,
+}
+
+const SearchResultList: FC<Props> = (props:Props) => {
+  const { focusedPage } = props;
+  const focusedPageId = (focusedPage !== undefined && focusedPage.pageData !== undefined) ? focusedPage.pageData._id : '';
+  return (
+    <>
+      {props.pages.map((page) => {
+        return (
+          <SearchResultListItem
+            key={page.pageData._id}
+            page={page}
+            onClickInvoked={props.onClickInvoked}
+            isSelected={page.pageData._id === focusedPageId || false}
+          />
+        );
+      })}
+      {props.searchResultCount != null && props.searchResultCount > 0 && (
+        <div className="my-4 mx-auto">
+          <PaginationWrapper
+            activePage={props.activePage || 1}
+            changePage={props.onPagingNumberChanged}
+            totalItemsCount={props.searchResultCount || 0}
+            pagingLimit={props.pagingLimit}
+          />
+        </div>
+      )}
+
+    </>
+  );
+
+};
+
+export default SearchResultList;

+ 140 - 0
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -0,0 +1,140 @@
+import React, { FC } from 'react';
+
+import Clamp from 'react-multiline-clamp';
+
+import { useTranslation } from 'react-i18next';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+import { DevidedPagePath } from '@growi/core';
+import { IPageSearchResultData } from '../../interfaces/search';
+
+
+import loggerFactory from '~/utils/logger';
+import { IPageHasId } from '~/interfaces/page';
+
+const logger = loggerFactory('growi:searchResultList');
+
+type PageItemControlProps = {
+  page: IPageHasId,
+}
+
+const PageItemControl: FC<PageItemControlProps> = (props: {page: IPageHasId}) => {
+
+  const { page } = props;
+  const { t } = useTranslation('');
+
+  return (
+    <>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="fa fa-ellipsis-v text-muted"></i>
+      </button>
+      <div className="dropdown-menu dropdown-menu-right">
+
+        {/* TODO: if there is the following button in XD add it here
+        <button
+          type="button"
+          className="btn btn-link p-0"
+          value={page.path}
+          onClick={(e) => {
+            window.location.href = e.currentTarget.value;
+          }}
+        >
+          <i className="icon-login" />
+        </button>
+        */}
+
+        {/*
+          TODO: add function to the following buttons like using modal or others
+          ref: https://estoc.weseek.co.jp/redmine/issues/79026
+        */}
+        <button className="dropdown-item text-danger" type="button" onClick={() => console.log('delete modal show')}>
+          <i className="icon-fw icon-fire"></i>{t('Delete')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => console.log('duplicate modal show')}>
+          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => console.log('duplicate modal show')}>
+          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => console.log('rename function will be added')}>
+          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
+        </button>
+      </div>
+    </>
+  );
+
+};
+
+type Props = {
+  page: IPageSearchResultData,
+  isSelected: boolean,
+  onClickInvoked?: (pageId: string) => void,
+}
+
+const SearchResultListItem: FC<Props> = (props:Props) => {
+  const { page: { pageData, pageMeta }, isSelected } = props;
+
+  // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
+  const pageId = `#${pageData._id}`;
+
+  const dPagePath = new DevidedPagePath(pageData.path, false, true);
+  const pagePathElem = <PagePathLabel page={pageData} isFormerOnly />;
+
+  const onClickInvoked = (pageId) => {
+    if (props.onClickInvoked != null) {
+      props.onClickInvoked(pageId);
+    }
+  };
+
+  return (
+    <li key={pageData._id} className={`page-list-li search-page-item w-100 border-bottom px-4 list-group-item-action ${isSelected ? 'active' : ''}`}>
+      <a
+        className="d-block pt-3"
+        href={pageId}
+        onClick={() => onClickInvoked(pageData._id)}
+      >
+        <div className="d-flex">
+          {/* checkbox */}
+          <div className="form-check my-auto mr-3">
+            <input className="form-check-input my-auto" type="checkbox" value="" id="flexCheckDefault" />
+          </div>
+          <div className="w-100">
+            {/* page path */}
+            <small className="mb-1">
+              <i className="icon-fw icon-home"></i>
+              {pagePathElem}
+            </small>
+            <div className="d-flex my-1 align-items-center">
+              {/* page title */}
+              <h3 className="mb-0">
+                <UserPicture user={pageData.lastUpdateUser} />
+                <span className="mx-2">{dPagePath.latter}</span>
+              </h3>
+              {/* page meta */}
+              <div className="d-flex mx-2">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
+              </div>
+              {/* doropdown icon includes page control buttons */}
+              <div className="ml-auto">
+                <PageItemControl page={pageData} />
+              </div>
+            </div>
+            <div className="my-2">
+              <Clamp
+                lines={2}
+              >
+                {pageMeta.elasticSearchResult && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
+              </Clamp>
+            </div>
+          </div>
+        </div>
+        {/* TODO: adjust snippet position */}
+      </a>
+    </li>
+  );
+};
+
+export default SearchResultListItem;

+ 4 - 3
packages/app/src/components/Sidebar.tsx

@@ -2,6 +2,7 @@ import React, {
   FC, useCallback, useEffect, useRef, useState,
   FC, useCallback, useEffect, useRef, useState,
 } from 'react';
 } from 'react';
 
 
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import {
 import {
   useDrawerMode, useDrawerOpened,
   useDrawerMode, useDrawerOpened,
   useSidebarCollapsed,
   useSidebarCollapsed,
@@ -14,8 +15,8 @@ import DrawerToggler from './Navbar/DrawerToggler';
 
 
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarContents from './Sidebar/SidebarContents';
 import SidebarContents from './Sidebar/SidebarContents';
-import { scheduleToPutUserUISettings } from '~/services/user-ui-settings';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
+import StickyStretchableScroller from './StickyStretchableScroller';
 
 
 const sidebarMinWidth = 240;
 const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
 const sidebarMinimizeWidth = 20;
@@ -67,12 +68,12 @@ const SidebarContentsWrapper = () => {
 
 
   return (
   return (
     <>
     <>
-      {/* <StickyStretchableScroller
+      <StickyStretchableScroller
         scrollTargetSelector={scrollTargetSelector}
         scrollTargetSelector={scrollTargetSelector}
         contentsElemSelector="#grw-sidebar-content-container"
         contentsElemSelector="#grw-sidebar-content-container"
         stickyElemSelector=".grw-sidebar"
         stickyElemSelector=".grw-sidebar"
         calcViewHeightFunc={calcViewHeight}
         calcViewHeightFunc={calcViewHeight}
-      /> */}
+      />
 
 
       <div id="grw-sidebar-contents-scroll-target">
       <div id="grw-sidebar-contents-scroll-target">
         <div id="grw-sidebar-content-container">
         <div id="grw-sidebar-content-container">

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -1,7 +1,7 @@
 import React, { FC, memo, useCallback } from 'react';
 import React, { FC, memo, useCallback } from 'react';
 
 
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { scheduleToPutUserUISettings } from '~/services/user-ui-settings';
 import { useCurrentUser, useIsSharedUser } from '~/stores/context';
 import { useCurrentUser, useIsSharedUser } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 

+ 6 - 9
packages/app/src/components/StickyStretchableScroller.jsx

@@ -5,7 +5,6 @@ import { debounce } from 'throttle-debounce';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
@@ -49,7 +48,6 @@ const StickyStretchableScroller = (props) => {
 
 
   let { scrollTargetSelector } = props;
   let { scrollTargetSelector } = props;
   const {
   const {
-    navigationContainer,
     children, contentsElemSelector, stickyElemSelector,
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
     calcViewHeightFunc, calcContentsHeightFunc,
   } = props;
   } = props;
@@ -142,11 +140,11 @@ const StickyStretchableScroller = (props) => {
   }, [resetScrollbarDebounced]);
   }, [resetScrollbarDebounced]);
 
 
   // setup effect by isScrollTop
   // setup effect by isScrollTop
-  useEffect(() => {
-    if (navigationContainer.state.isScrollTop) {
-      resetScrollbar();
-    }
-  }, [navigationContainer.state.isScrollTop, resetScrollbar]);
+  // useEffect(() => {
+  //   if (navigationContainer.state.isScrollTop) {
+  //     resetScrollbar();
+  //   }
+  // }, [navigationContainer.state.isScrollTop, resetScrollbar]);
 
 
   // setup effect by update props
   // setup effect by update props
   useEffect(() => {
   useEffect(() => {
@@ -161,7 +159,6 @@ const StickyStretchableScroller = (props) => {
 };
 };
 
 
 StickyStretchableScroller.propTypes = {
 StickyStretchableScroller.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
 
 
   children: PropTypes.node,
   children: PropTypes.node,
@@ -172,4 +169,4 @@ StickyStretchableScroller.propTypes = {
   calcContentsHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };
 };
 
 
-export default withUnstatedContainers(StickyStretchableScroller, [NavigationContainer]);
+export default StickyStretchableScroller;

+ 18 - 0
packages/app/src/interfaces/search.ts

@@ -0,0 +1,18 @@
+import { IPageHasId } from './page';
+
+export enum CheckboxType {
+  NONE_CHECKED = 'noneChecked',
+  INDETERMINATE = 'indeterminate',
+  ALL_CHECKED = 'allChecked',
+}
+
+export type IPageSearchResultData = {
+  pageData: IPageHasId,
+  pageMeta: {
+    bookmarkCount: number,
+    elasticSearchResult: {
+      snippet: string,
+      matchedPath: string,
+    },
+  },
+}

+ 3 - 1
packages/app/src/server/events/page.js

@@ -18,5 +18,7 @@ PageEvent.prototype.onUpdate = function(page, user) {
 PageEvent.prototype.onCreateMany = function(pages, user) {
 PageEvent.prototype.onCreateMany = function(pages, user) {
   debug('onCreateMany event fired');
   debug('onCreateMany event fired');
 };
 };
-
+PageEvent.prototype.onAddSeenUsers = function(pages, user) {
+  debug('onAddSeenUsers event fired');
+};
 module.exports = PageEvent;
 module.exports = PageEvent;

+ 2 - 0
packages/app/src/server/models/obsolete-page.js

@@ -301,6 +301,7 @@ export const getPageSchema = (crowi) => {
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('create', pageEvent.onCreate);
     pageEvent.on('update', pageEvent.onUpdate);
     pageEvent.on('update', pageEvent.onUpdate);
     pageEvent.on('createMany', pageEvent.onCreateMany);
     pageEvent.on('createMany', pageEvent.onCreateMany);
+    pageEvent.on('addSeenUsers', pageEvent.onAddSeenUsers);
   }
   }
 
 
   function validateCrowi() {
   function validateCrowi() {
@@ -426,6 +427,7 @@ export const getPageSchema = (crowi) => {
     const saved = await this.save();
     const saved = await this.save();
 
 
     debug('seenUsers updated!', added);
     debug('seenUsers updated!', added);
+    pageEvent.emit('addSeenUsers', saved);
 
 
     return saved;
     return saved;
   };
   };

+ 1 - 1
packages/app/src/server/routes/apiv3/response.js

@@ -10,7 +10,7 @@ const addCustomFunctionToResponse = (express, crowi) => {
       throw new Error('invalid value supplied to res.apiv3');
       throw new Error('invalid value supplied to res.apiv3');
     }
     }
 
 
-    this.status(status).json({ data: obj });
+    this.status(status).json(obj);
   };
   };
 
 
   express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function
   express.response.apiv3Err = function(_err, status = 400, info) { // not arrow function

+ 0 - 1
packages/app/src/server/routes/avoid-session-routes.js

@@ -1,4 +1,3 @@
 module.exports = [
 module.exports = [
-  /^\/_hackmd\//,
   /^\/api-docs\//,
   /^\/api-docs\//,
 ];
 ];

+ 24 - 13
packages/app/src/server/routes/search.js

@@ -64,22 +64,33 @@ module.exports = function(crowi, app) {
     const ids = searchResult.data.map((page) => { return page._id });
     const ids = searchResult.data.map((page) => { return page._id });
     const findResult = await Page.findListByPageIds(ids);
     const findResult = await Page.findListByPageIds(ids);
 
 
-    // add tag data to result pages
-    findResult.pages.map((page) => {
-      const data = searchResult.data.find((data) => { return page.id === data._id });
-      page._doc.tags = data._source.tag_names;
-      return page;
+    // add tags data to page
+    findResult.pages.map((pageData) => {
+      const data = searchResult.data.find((data) => {
+        return pageData.id === data._id;
+      });
+      pageData._doc.tags = data._source.tag_names;
+      return pageData;
     });
     });
 
 
     result.meta = searchResult.meta;
     result.meta = searchResult.meta;
     result.totalCount = findResult.totalCount;
     result.totalCount = findResult.totalCount;
     result.data = findResult.pages
     result.data = findResult.pages
-      .map((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+      .map((pageData) => {
+        if (pageData.lastUpdateUser != null && pageData.lastUpdateUser instanceof User) {
+          pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
         }
         }
-        page.bookmarkCount = (page._source && page._source.bookmark_count) || 0;
-        return page;
+
+        const data = searchResult.data.find((data) => {
+          return pageData.id === data._id;
+        });
+
+        const pageMeta = {
+          bookmarkCount: data._source.bookmark_count || 0,
+          elasticSearchResult: data.elasticSearchResult,
+        };
+
+        return { pageData, pageMeta };
       })
       })
       .sort((page1, page2) => {
       .sort((page1, page2) => {
         // note: this do not consider NaN
         // note: this do not consider NaN
@@ -190,10 +201,10 @@ module.exports = function(crowi, app) {
 
 
     const searchOpts = { ...paginateOpts, type };
     const searchOpts = { ...paginateOpts, type };
 
 
-    let searchResult;
+    let _searchResult;
     let delegatorName;
     let delegatorName;
     try {
     try {
-      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts); // TODO: separate when not full-text search
+      [_searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts); // TODO: separate when not full-text search
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to search', err);
       logger.error('Failed to search', err);
@@ -202,12 +213,12 @@ module.exports = function(crowi, app) {
 
 
     let result;
     let result;
     try {
     try {
+      const searchResult = searchService.formatResult(_searchResult);
       result = await reshapeSearchResult(searchResult, delegatorName);
       result = await reshapeSearchResult(searchResult, delegatorName);
     }
     }
     catch (err) {
     catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
-
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 

+ 1 - 0
packages/app/src/server/service/page.js

@@ -26,6 +26,7 @@ class PageService {
     this.pageEvent.on('create', this.pageEvent.onCreate);
     this.pageEvent.on('create', this.pageEvent.onCreate);
     this.pageEvent.on('update', this.pageEvent.onUpdate);
     this.pageEvent.on('update', this.pageEvent.onUpdate);
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
+    this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
   }
   }
 
 
   async findPageAndMetaDataByViewer({ pageId, path, user }) {
   async findPageAndMetaDataByViewer({ pageId, path, user }) {

+ 77 - 4
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -329,6 +329,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
     };
 
 
     const bookmarkCount = page.bookmarkCount || 0;
     const bookmarkCount = page.bookmarkCount || 0;
+    const seenUsersCount = page.seenUsers.length || 0;
     let document = {
     let document = {
       path: page.path,
       path: page.path,
       body: page.revision.body,
       body: page.revision.body,
@@ -337,6 +338,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       comments: page.comments,
       comments: page.comments,
       comment_count: page.commentCount,
       comment_count: page.commentCount,
       bookmark_count: bookmarkCount,
       bookmark_count: bookmarkCount,
+      seenUsers_count: seenUsersCount,
       like_count: page.liker.length || 0,
       like_count: page.liker.length || 0,
       created_at: page.createdAt,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
       updated_at: page.updatedAt,
@@ -596,14 +598,19 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
         results: result.hits.hits.length,
         results: result.hits.hits.length,
       },
       },
       data: result.hits.hits.map((elm) => {
       data: result.hits.hits.map((elm) => {
-        return { _id: elm._id, _score: elm._score, _source: elm._source };
+        return {
+          _id: elm._id,
+          _score: elm._score,
+          _source: elm._source,
+          _highlight: elm.highlight,
+        };
       }),
       }),
     };
     };
   }
   }
 
 
   createSearchQuerySortedByUpdatedAt(option) {
   createSearchQuerySortedByUpdatedAt(option) {
     // getting path by default is almost for debug
     // getting path by default is almost for debug
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names'];
     if (option) {
     if (option) {
       fields = option.fields || fields;
       fields = option.fields || fields;
     }
     }
@@ -623,8 +630,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     return query;
     return query;
   }
   }
 
 
-  createSearchQuerySortedByScore(option?) {
-    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names', 'comments'];
+  createSearchQuerySortedByScore(option) {
+    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
     if (option) {
       fields = option.fields || fields;
       fields = option.fields || fields;
     }
     }
@@ -864,9 +871,26 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     };
     };
   }
   }
 
 
+<<<<<<< HEAD:packages/app/src/server/service/search-delegator/elasticsearch.js
+  appendHighlight(query) {
+    query.body.highlight = {
+      fields: {
+        '*': {
+          fragment_size: 40,
+          fragmenter: 'simple',
+          pre_tags: ["<em class='highlighted-keyword'>"],
+          post_tags: ['</em>'],
+        },
+      },
+    };
+  }
+
+  async searchKeyword(queryString, user, userGroups, option) {
+=======
   async search(data: SearchableData, user, userGroups, option): Promise<Result<Data> & MetaData> {
   async search(data: SearchableData, user, userGroups, option): Promise<Result<Data> & MetaData> {
     const { queryString, terms } = data;
     const { queryString, terms } = data;
 
 
+>>>>>>> feat/pt-dev-master:packages/app/src/server/service/search-delegator/elasticsearch.ts
     const from = option.offset || null;
     const from = option.offset || null;
     const size = option.limit || null;
     const size = option.limit || null;
     const query = this.createSearchQuerySortedByScore();
     const query = this.createSearchQuerySortedByScore();
@@ -876,7 +900,56 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
 
     this.appendResultSize(query, from, size);
     this.appendResultSize(query, from, size);
 
 
+<<<<<<< HEAD:packages/app/src/server/service/search-delegator/elasticsearch.js
+    this.appendFunctionScore(query, queryString);
+    this.appendHighlight(query);
+    return this.search(query);
+  }
+
+  parseQueryString(queryString) {
+    const matchWords = [];
+    const notMatchWords = [];
+    const phraseWords = [];
+    const notPhraseWords = [];
+    const prefixPaths = [];
+    const notPrefixPaths = [];
+    const tags = [];
+    const notTags = [];
+
+    queryString.trim();
+    queryString = queryString.replace(/\s+/g, ' '); // eslint-disable-line no-param-reassign
+
+    // First: Parse phrase keywords
+    const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+    const phrases = queryString.match(phraseRegExp);
+
+    if (phrases !== null) {
+      queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
+
+      phrases.forEach((phrase) => {
+        phrase.trim();
+        if (phrase.match(/^-/)) {
+          notPhraseWords.push(phrase.replace(/^-/, ''));
+        }
+        else {
+          phraseWords.push(phrase);
+        }
+      });
+    }
+
+    // Second: Parse other keywords (include minus keywords)
+    queryString.split(' ').forEach((word) => {
+      if (word === '') {
+        return;
+      }
+
+      // https://regex101.com/r/pN9XfK/1
+      const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
+      // https://regex101.com/r/3qw9FQ/1
+      const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
+=======
     await this.appendFunctionScore(query, queryString);
     await this.appendFunctionScore(query, queryString);
+>>>>>>> feat/pt-dev-master:packages/app/src/server/service/search-delegator/elasticsearch.ts
 
 
     return this.searchKeyword(query);
     return this.searchKeyword(query);
   }
   }

+ 187 - 0
packages/app/src/server/service/search.js

@@ -0,0 +1,187 @@
+import loggerFactory from '~/utils/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:service:search');
+const xss = require('xss');
+
+// options for filtering xss
+const filterXssOptions = {
+  whiteList: {
+    em: ['class'],
+  },
+};
+
+const filterXss = new xss.FilterXSS(filterXssOptions);
+
+class SearchService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+
+    this.isErrorOccuredOnHealthcheck = null;
+    this.isErrorOccuredOnSearching = null;
+
+    try {
+      this.delegator = this.generateDelegator();
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+    if (this.isConfigured) {
+      this.delegator.init();
+      this.registerUpdateEvent();
+    }
+  }
+
+  get isConfigured() {
+    return this.delegator != null;
+  }
+
+  get isReachable() {
+    return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
+  }
+
+  get isSearchboxEnabled() {
+    const uri = this.configManager.getConfig('crowi', 'app:searchboxSslUrl');
+    return uri != null && uri.length > 0;
+  }
+
+  get isElasticsearchEnabled() {
+    const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+    return uri != null && uri.length > 0;
+  }
+
+  generateDelegator() {
+    logger.info('Initializing search delegator');
+
+    if (this.isSearchboxEnabled) {
+      const SearchboxDelegator = require('./search-delegator/searchbox');
+      logger.info('Searchbox is enabled');
+      return new SearchboxDelegator(this.configManager, this.crowi.socketIoService);
+    }
+    if (this.isElasticsearchEnabled) {
+      const ElasticsearchDelegator = require('./search-delegator/elasticsearch');
+      logger.info('Elasticsearch (not Searchbox) is enabled');
+      return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
+    }
+
+    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
+  }
+
+  registerUpdateEvent() {
+    const pageEvent = this.crowi.event('page');
+    pageEvent.on('create', this.delegator.syncPageUpdated.bind(this.delegator));
+    pageEvent.on('update', this.delegator.syncPageUpdated.bind(this.delegator));
+    pageEvent.on('deleteCompletely', this.delegator.syncPagesDeletedCompletely.bind(this.delegator));
+    pageEvent.on('delete', this.delegator.syncPageDeleted.bind(this.delegator));
+    pageEvent.on('updateMany', this.delegator.syncPagesUpdated.bind(this.delegator));
+    pageEvent.on('syncDescendants', this.delegator.syncDescendantsPagesUpdated.bind(this.delegator));
+    pageEvent.on('addSeenUsers', this.delegator.syncPageUpdated.bind(this.delegator));
+
+    const bookmarkEvent = this.crowi.event('bookmark');
+    bookmarkEvent.on('create', this.delegator.syncBookmarkChanged.bind(this.delegator));
+    bookmarkEvent.on('delete', this.delegator.syncBookmarkChanged.bind(this.delegator));
+
+    const commentEvent = this.crowi.event('comment');
+    commentEvent.on('create', this.delegator.syncCommentChanged.bind(this.delegator));
+    commentEvent.on('update', this.delegator.syncCommentChanged.bind(this.delegator));
+    commentEvent.on('delete', this.delegator.syncCommentChanged.bind(this.delegator));
+
+    const tagEvent = this.crowi.event('tag');
+    tagEvent.on('update', this.delegator.syncTagChanged.bind(this.delegator));
+  }
+
+  resetErrorStatus() {
+    this.isErrorOccuredOnHealthcheck = false;
+    this.isErrorOccuredOnSearching = false;
+  }
+
+  async reconnectClient() {
+    logger.info('Try to reconnect...');
+    this.delegator.initClient();
+
+    try {
+      await this.getInfoForHealth();
+
+      logger.info('Reconnecting succeeded.');
+      this.resetErrorStatus();
+    }
+    catch (err) {
+      throw err;
+    }
+  }
+
+  async getInfo() {
+    try {
+      return await this.delegator.getInfo();
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
+  }
+
+  async getInfoForHealth() {
+    try {
+      const result = await this.delegator.getInfoForHealth();
+
+      this.isErrorOccuredOnHealthcheck = false;
+      return result;
+    }
+    catch (err) {
+      logger.error(err);
+
+      // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
+      this.isErrorOccuredOnHealthcheck = true;
+      throw err;
+    }
+  }
+
+  async getInfoForAdmin() {
+    return this.delegator.getInfoForAdmin();
+  }
+
+  async normalizeIndices() {
+    return this.delegator.normalizeIndices();
+  }
+
+  async rebuildIndex() {
+    return this.delegator.rebuildIndex();
+  }
+
+  async searchKeyword(keyword, user, userGroups, searchOpts) {
+    try {
+      return await this.delegator.searchKeyword(keyword, user, userGroups, searchOpts);
+    }
+    catch (err) {
+      logger.error(err);
+
+      // switch error flag, `isReachable` to be `false`
+      this.isErrorOccuredOnSearching = true;
+      throw err;
+    }
+  }
+
+  /**
+   * formatting result
+   */
+  formatResult(esResult) {
+    esResult.data.forEach((data) => {
+      const highlightData = data._highlight;
+      const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+      const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+
+      data.elasticSearchResult = {
+        snippet: filterXss.process(snippet),
+        // todo: use filter xss.process() for matchedPath;
+        matchedPath: pathMatch,
+      };
+    });
+    return esResult;
+  }
+
+}
+
+module.exports = SearchService;

+ 29 - 0
packages/app/src/server/service/search.ts

@@ -1,4 +1,5 @@
 import RE2 from 're2';
 import RE2 from 're2';
+import xss from 'xss';
 
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 
 
@@ -14,6 +15,15 @@ import loggerFactory from '~/utils/logger';
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 const logger = loggerFactory('growi:service:search');
 
 
+// options for filtering xss
+const filterXssOptions = {
+  whiteList: {
+    em: ['class'],
+  },
+};
+
+const filterXss = new xss.FilterXSS(filterXssOptions);
+
 const normalizeQueryString = (_queryString: string): string => {
 const normalizeQueryString = (_queryString: string): string => {
   let queryString = _queryString.trim();
   let queryString = _queryString.trim();
   queryString = queryString.replace(/\s+/g, ' ');
   queryString = queryString.replace(/\s+/g, ' ');
@@ -96,6 +106,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('addSeenUsers', this.delegator.syncPageUpdated.bind(this.delegator));
 
 
     const bookmarkEvent = this.crowi.event('bookmark');
     const bookmarkEvent = this.crowi.event('bookmark');
     bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
     bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
@@ -320,6 +331,24 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return terms;
     return terms;
   }
   }
 
 
+  /**
+   * formatting result
+   */
+  formatResult(esResult) {
+    esResult.data.forEach((data) => {
+      const highlightData = data._highlight;
+      const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+      const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+
+      data.elasticSearchResult = {
+        snippet: filterXss.process(snippet),
+        // todo: use filter xss.process() for matchedPath;
+        matchedPath: pathMatch,
+      };
+    });
+    return esResult;
+  }
+
 }
 }
 
 
 export default SearchService;
 export default SearchService;

+ 2 - 0
packages/app/src/server/views/layout-growi/base/layout.html

@@ -9,6 +9,8 @@
 {% block layout_main %}
 {% block layout_main %}
 <div class="h-100 d-flex flex-column justify-content-between">
 <div class="h-100 d-flex flex-column justify-content-between">
 
 
+  <div id="growi-context-extractor"></div>
+
   {% block content_header_wrapper %}
   {% block content_header_wrapper %}
     <header class="py-0">
     <header class="py-0">
       {% block content_header %}
       {% block content_header %}

+ 1 - 1
packages/app/src/server/views/search.html

@@ -17,7 +17,7 @@
 <div class="container-fluid">
 <div class="container-fluid">
 
 
   <div class="row">
   <div class="row">
-    <div id="main" class="main col-lg-12 search-page">
+    <div id="main" class="main col-lg-12 search-page mt-0">
       <div class="" id="search-page"></div>
       <div class="" id="search-page"></div>
     </div>
     </div>
   </div>
   </div>

+ 39 - 10
packages/app/src/stores/ui.tsx

@@ -59,19 +59,43 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   return useStaticSWR(key, null, configuration);
   return useStaticSWR(key, null, configuration);
 };
 };
 
 
-// drawer mode keys
-const IS_DRAWER_MODE: Key = 'isDrawerMode';
 
 
-export const mutateDrawerMode: Middleware = (useSWRNext) => {
+const postChangeEditorModeMiddleware: Middleware = (useSWRNext) => {
   return (...args) => {
   return (...args) => {
-    const { mutate } = useSWRConfig();
+    // -- TODO: https://redmine.weseek.co.jp/issues/81817
     const swrNext = useSWRNext(...args);
     const swrNext = useSWRNext(...args);
     return {
     return {
       ...swrNext,
       ...swrNext,
       mutate: (data, shouldRevalidate) => {
       mutate: (data, shouldRevalidate) => {
         return swrNext.mutate(data, shouldRevalidate)
         return swrNext.mutate(data, shouldRevalidate)
           .then((value) => {
           .then((value) => {
-            mutate(IS_DRAWER_MODE); // mutate isDrawerMode
+            const newEditorMode = value as unknown as EditorMode;
+            switch (newEditorMode) {
+              case EditorMode.View:
+                $('body').removeClass('on-edit');
+                $('body').removeClass('builtin-editor');
+                $('body').removeClass('hackmd');
+                $('body').removeClass('pathname-sidebar');
+                window.history.replaceState(null, '', window.location.pathname);
+                break;
+              case EditorMode.Editor:
+                $('body').addClass('on-edit');
+                $('body').addClass('builtin-editor');
+                $('body').removeClass('hackmd');
+                // editing /Sidebar
+                if (window.location.pathname === '/Sidebar') {
+                  $('body').addClass('pathname-sidebar');
+                }
+                window.location.hash = '#edit';
+                break;
+              case EditorMode.HackMD:
+                $('body').addClass('on-edit');
+                $('body').addClass('hackmd');
+                $('body').removeClass('builtin-editor');
+                $('body').removeClass('pathname-sidebar');
+                window.location.hash = '#hackmd';
+                break;
+            }
             return value;
             return value;
           });
           });
       },
       },
@@ -83,7 +107,7 @@ export const useEditorMode = (editorMode?: EditorMode): SWRResponse<EditorMode,
   const key: Key = 'editorMode';
   const key: Key = 'editorMode';
   const initialData = EditorMode.View;
   const initialData = EditorMode.View;
 
 
-  return useStaticSWR(key, editorMode || null, { fallbackData: initialData, use: [mutateDrawerMode] });
+  return useStaticSWR(key, editorMode || null, { fallbackData: initialData, use: [postChangeEditorModeMiddleware] });
 };
 };
 
 
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> => {
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> => {
@@ -107,7 +131,7 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
     }
     }
   }
   }
 
 
-  return useStaticSWR(key, null, { use: [mutateDrawerMode] });
+  return useStaticSWR(key);
 };
 };
 
 
 export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
 export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
@@ -115,7 +139,7 @@ export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boo
   const key: Key = data === undefined ? null : 'preferDrawerModeByUser';
   const key: Key = data === undefined ? null : 'preferDrawerModeByUser';
   const initialData = data?.preferDrawerModeByUser;
   const initialData = data?.preferDrawerModeByUser;
 
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [mutateDrawerMode, sessionStorageMiddleware] });
+  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
 };
 };
 
 
 export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
 export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
@@ -123,7 +147,7 @@ export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRRespon
   const key: Key = data === undefined ? null : 'preferDrawerModeOnEditByUser';
   const key: Key = data === undefined ? null : 'preferDrawerModeOnEditByUser';
   const initialData = data?.preferDrawerModeOnEditByUser;
   const initialData = data?.preferDrawerModeOnEditByUser;
 
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [mutateDrawerMode, sessionStorageMiddleware] });
+  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
 };
 };
 
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
@@ -145,7 +169,7 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
   };
   };
 
 
   return useSWR(
   return useSWR(
-    condition ? [IS_DRAWER_MODE, editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
+    condition ? [editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
     calcDrawerMode,
     calcDrawerMode,
     {
     {
       fallback: calcDrawerMode,
       fallback: calcDrawerMode,
@@ -209,3 +233,8 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
   const initialData = false;
   const initialData = false;
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });
 };
 };
+
+export const usePageCreateModalOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
+  const initialData = false;
+  return useStaticSWR('isPageCreateModalOpened', isOpened || null, { fallbackData: initialData });
+};

+ 35 - 16
packages/app/src/styles/_search.scss

@@ -1,6 +1,17 @@
-.search-listpage-icon {
-  font-size: 16px;
-  color: $gray-400;
+.search-page-nav {
+  background-color: #f7f7f7;
+}
+
+.search-group-submit-button {
+  position: absolute;
+  top: 0;
+  right: 0;
+  z-index: 3;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
 }
 }
 
 
 .search-listpage-clear {
 .search-listpage-clear {
@@ -102,17 +113,19 @@
   }
   }
 
 
   .btn-group-submit-search {
   .btn-group-submit-search {
-    position: absolute;
-    top: 0;
-    right: 0;
+    @extend .search-group-submit-button;
+  }
+}
 
 
-    z-index: 3;
+.grw-search-form-in-search-result-page {
+  .btn-group-submit-search {
+    @extend .search-group-submit-button;
+  }
 
 
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    width: 32px;
-    height: 32px;
+  button {
+    &:focus {
+      box-shadow: none !important;
+    }
   }
   }
 }
 }
 
 
@@ -158,14 +171,15 @@
 .search-result {
 .search-result {
   .search-result-list {
   .search-result-list {
     position: sticky;
     position: sticky;
-    top: 64px;
+    top: 0px;
     height: 100vh;
     height: 100vh;
     overflow-y: scroll;
     overflow-y: scroll;
 
 
     .nav.nav-pills {
     .nav.nav-pills {
-      > li {
+      > .page-list-li {
         > a {
         > a {
-          padding: 2px 8px;
+          height: 123px;
+          padding: 2px 4px;
           word-break: break-all;
           word-break: break-all;
           border-radius: 0;
           border-radius: 0;
 
 
@@ -175,7 +189,7 @@
           }
           }
           &.active {
           &.active {
             padding-right: 5px;
             padding-right: 5px;
-            border-right: solid 3px transparent;
+            border-left: solid 3px transparent;
           }
           }
           > * {
           > * {
             margin-right: 3px;
             margin-right: 3px;
@@ -222,6 +236,7 @@
   }
   }
 }
 }
 
 
+// 2021/9/22 TODO: Remove after moving to SearchResult
 .search-page-input {
 .search-page-input {
   position: sticky;
   position: sticky;
   top: 15px;
   top: 15px;
@@ -243,6 +258,10 @@
   }
   }
 }
 }
 
 
+.search-page-item {
+  height: 130px;
+}
+
 @include media-breakpoint-down(sm) {
 @include media-breakpoint-down(sm) {
   .grw-search-table {
   .grw-search-table {
     th {
     th {

+ 9 - 1
packages/app/src/styles/_sidebar.scss

@@ -262,7 +262,11 @@
 @mixin drawer() {
 @mixin drawer() {
   z-index: $zindex-fixed + 2;
   z-index: $zindex-fixed + 2;
 
 
-  // override @atlaskit/navigation-next styles
+  .data-layout-container {
+    position: fixed;
+    top: 0;
+    width: 0;
+  }
   div.navigation {
   div.navigation {
     max-width: 80vw;
     max-width: 80vw;
 
 
@@ -286,6 +290,10 @@
     }
     }
   }
   }
 
 
+  .grw-navigation-resize-button {
+    display: none;
+  }
+
   .grw-drawer-toggler {
   .grw-drawer-toggler {
     position: fixed;
     position: fixed;
     right: -15px;
     right: -15px;

+ 8 - 9
packages/app/src/styles/theme/_apply-colors.scss

@@ -17,6 +17,8 @@ $bordercolor-nav-tabs-active: $bordercolor-nav-tabs $bordercolor-nav-tabs $bgcol
 $color-seen-user: #549c79 !default;
 $color-seen-user: #549c79 !default;
 $color-btn-reload-in-sidebar: $gray-500;
 $color-btn-reload-in-sidebar: $gray-500;
 $bgcolor-keyword-highlighted: $grw-marker-yellow !default;
 $bgcolor-keyword-highlighted: $grw-marker-yellow !default;
+$bordercolor-search-item-left-active: $primary;
+$bgcolor-search-item-active: lighten($bordercolor-search-item-left-active, 76%);
 
 
 // override bootstrap variables
 // override bootstrap variables
 $body-bg: $bgcolor-global;
 $body-bg: $bgcolor-global;
@@ -597,17 +599,14 @@ body.pathname-sidebar {
 .search-result {
 .search-result {
   .search-result-list {
   .search-result-list {
     .page-list {
     .page-list {
+      .highlighted-keyword {
+        background-color: $bgcolor-keyword-highlighted;
+      }
       .page-list-ul {
       .page-list-ul {
-        > li.nav-item > a.nav-link {
-          color: inherit;
-        }
-        a {
-          &.hover {
-            background-color: darken($bgcolor-global, 4%);
-          }
+        .page-list-li {
           &.active {
           &.active {
-            background-color: darken($bgcolor-global, 8%);
-            border-color: theme-color('primary');
+            background-color: $bgcolor-search-item-active;
+            border-color: $bordercolor-search-item-left-active;
           }
           }
         }
         }
       }
       }

+ 8 - 0
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -37,6 +37,12 @@ export class PageListMeta extends React.Component {
       locked = <span><i className="icon-lock" /></span>;
       locked = <span><i className="icon-lock" /></span>;
     }
     }
 
 
+    let bookmarkCount;
+    if (this.props.bookmarkCount > 0) {
+      bookmarkCount = <span><i className="icon-star" />{this.props.bookmarkCount}</span>;
+    }
+
+
     return (
     return (
       <span className="page-list-meta">
       <span className="page-list-meta">
         {topLabel}
         {topLabel}
@@ -44,6 +50,7 @@ export class PageListMeta extends React.Component {
         {commentCount}
         {commentCount}
         {likerCount}
         {likerCount}
         {locked}
         {locked}
+        {bookmarkCount}
       </span>
       </span>
     );
     );
   }
   }
@@ -52,6 +59,7 @@ export class PageListMeta extends React.Component {
 
 
 PageListMeta.propTypes = {
 PageListMeta.propTypes = {
   page: PropTypes.object.isRequired,
   page: PropTypes.object.isRequired,
+  bookmarkCount: PropTypes.number,
 };
 };
 
 
 PageListMeta.defaultProps = {
 PageListMeta.defaultProps = {

+ 8 - 0
packages/ui/src/components/PagePath/PagePathLabel.jsx

@@ -14,6 +14,13 @@ export const PagePathLabel = (props) => {
     return <span className={classNames.join(' ')}>{dPagePath.latter}</span>;
     return <span className={classNames.join(' ')}>{dPagePath.latter}</span>;
   }
   }
 
 
+  if (props.isFormerOnly) {
+    const textElem = dPagePath.isFormerRoot
+      ? <>/</>
+      : <>{dPagePath.former}</>;
+    return <span className={classNames.join(' ')}>{textElem}</span>;
+  }
+
   const textElem = dPagePath.isRoot
   const textElem = dPagePath.isRoot
     ? <><strong>/</strong></>
     ? <><strong>/</strong></>
     : <>{dPagePath.former}/<strong>{dPagePath.latter}</strong></>;
     : <>{dPagePath.former}/<strong>{dPagePath.latter}</strong></>;
@@ -24,6 +31,7 @@ export const PagePathLabel = (props) => {
 PagePathLabel.propTypes = {
 PagePathLabel.propTypes = {
   page: PropTypes.object.isRequired,
   page: PropTypes.object.isRequired,
   isLatterOnly: PropTypes.bool,
   isLatterOnly: PropTypes.bool,
+  isFormerOnly: PropTypes.bool,
   additionalClassNames: PropTypes.arrayOf(PropTypes.string),
   additionalClassNames: PropTypes.arrayOf(PropTypes.string),
 };
 };
 
 

+ 26 - 4
yarn.lock

@@ -2790,6 +2790,13 @@
     jest-diff "^26.0.0"
     jest-diff "^26.0.0"
     pretty-format "^26.0.0"
     pretty-format "^26.0.0"
 
 
+"@types/jquery@^3.5.8":
+  version "3.5.8"
+  resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.8.tgz#83bfbcdf4e625c5471590f92703c06aadb052a09"
+  integrity sha512-cXk6NwqjDYg+UI9p2l3x0YmPa4m7RrXqmbK4IpVVpRJiYXU/QTo+UZrn54qfE1+9Gao4qpYqUnxm5ZCy2FTXAw==
+  dependencies:
+    "@types/sizzle" "*"
+
 "@types/json-schema@7.0.6":
 "@types/json-schema@7.0.6":
   version "7.0.6"
   version "7.0.6"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
@@ -2963,6 +2970,11 @@
     "@types/mime" "^1"
     "@types/mime" "^1"
     "@types/node" "*"
     "@types/node" "*"
 
 
+"@types/sizzle@*":
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef"
+  integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==
+
 "@types/stack-utils@^2.0.0":
 "@types/stack-utils@^2.0.0":
   version "2.0.1"
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
@@ -15141,10 +15153,10 @@ passport-twitter@^1.0.4:
     passport-oauth1 "1.x.x"
     passport-oauth1 "1.x.x"
     xtraverse "0.1.x"
     xtraverse "0.1.x"
 
 
-passport@^0.4.0:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.1.tgz#941446a21cb92fc688d97a0861c38ce9f738f270"
-  integrity sha512-IxXgZZs8d7uFSt3eqNjM9NQ3g3uQCW5avD8mRNoXV99Yig50vjuaez6dQK2qC0kVWPRTujxY0dWgGfT09adjYg==
+passport@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/passport/-/passport-0.5.0.tgz#7914aaa55844f9dce8c3aa28f7d6b73647ee0169"
+  integrity sha512-ln+ue5YaNDS+fes6O5PCzXKSseY5u8MYhX9H5Co4s+HfYI5oqvnHKoOORLYDUPh+8tHvrxugF2GFcUA1Q1Gqfg==
   dependencies:
   dependencies:
     passport-strategy "1.x.x"
     passport-strategy "1.x.x"
     pause "0.0.1"
     pause "0.0.1"
@@ -16479,6 +16491,16 @@ react-motion@^0.5.0, react-motion@^0.5.2:
     prop-types "^15.5.8"
     prop-types "^15.5.8"
     raf "^3.1.0"
     raf "^3.1.0"
 
 
+react-multiline-clamp@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/react-multiline-clamp/-/react-multiline-clamp-2.0.0.tgz#913a2092368ef1b52c1c79364d506ba4af27e019"
+  integrity sha512-iPm3HxFD6LO63lE5ZnThiqs+6A3c+LW3WbsEM0oa0iNTa0qN4SKx/LK/6ZToSmXundEcQXBFVNzKDvgmExawTw==
+
+react-node-resolver@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/react-node-resolver/-/react-node-resolver-1.0.1.tgz#1798a729c0e218bf2f0e8ddf79c550d4af61d83a"
+  integrity sha1-F5inKcDiGL8vDo3fecVQ1K9h2Do=
+
 react-overlays@^0.8.1:
 react-overlays@^0.8.1:
   version "0.8.3"
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.8.3.tgz#fad65eea5b24301cca192a169f5dddb0b20d3ac5"
   resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.8.3.tgz#fad65eea5b24301cca192a169f5dddb0b20d3ac5"