Przeglądaj źródła

Merge branch 'dev/7.0.x' into feat/yjs-editor

ryoji-s 2 lat temu
rodzic
commit
ebd1b39635
77 zmienionych plików z 1648 dodań i 1136 usunięć
  1. 0 0
      apps/app/_obsolete/src/components/Sidebar/AppearanceModeDropdown.tsx
  2. 0 0
      apps/app/_obsolete/src/components/Sidebar/NavigationResizeHexagon.tsx
  3. 18 23
      apps/app/_obsolete/src/styles/theme/apply-colors.scss
  4. 1 11
      apps/app/src/client/services/user-ui-settings.ts
  5. 10 12
      apps/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  6. 4 12
      apps/app/src/components/CompleteUserRegistrationForm.tsx
  7. 4 12
      apps/app/src/components/InstallerForm.tsx
  8. 12 20
      apps/app/src/components/InvitedForm.tsx
  9. 0 2
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  10. 4 2
      apps/app/src/components/Layout/BasicLayout.tsx
  11. 157 10
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  12. 3 3
      apps/app/src/components/Layout/NoLoginLayout.tsx
  13. 22 36
      apps/app/src/components/LoginForm.tsx
  14. 1 1
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  15. 1 1
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  16. 13 3
      apps/app/src/components/PageEditor/PageEditor.tsx
  17. 94 0
      apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss
  18. 46 0
      apps/app/src/components/Sidebar/AppTitle/AppTitle.tsx
  19. 112 0
      apps/app/src/components/Sidebar/PageCreateButton.tsx
  20. 37 0
      apps/app/src/components/Sidebar/ResizableArea/ResizableArea.module.scss
  21. 99 0
      apps/app/src/components/Sidebar/ResizableArea/ResizableArea.tsx
  22. 1 0
      apps/app/src/components/Sidebar/ResizableArea/index.ts
  23. 45 189
      apps/app/src/components/Sidebar/Sidebar.module.scss
  24. 136 286
      apps/app/src/components/Sidebar/Sidebar.tsx
  25. 23 20
      apps/app/src/components/Sidebar/SidebarContents.tsx
  26. 26 0
      apps/app/src/components/Sidebar/SidebarHead/SidebarHead.module.scss
  27. 17 0
      apps/app/src/components/Sidebar/SidebarHead/SidebarHead.tsx
  28. 52 0
      apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.module.scss
  29. 41 0
      apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx
  30. 1 0
      apps/app/src/components/Sidebar/SidebarHead/index.ts
  31. 0 134
      apps/app/src/components/Sidebar/SidebarNav.module.scss
  32. 0 136
      apps/app/src/components/Sidebar/SidebarNav.tsx
  33. 71 0
      apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.module.scss
  34. 112 0
      apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx
  35. 45 0
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.module.scss
  36. 49 0
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx
  37. 33 0
      apps/app/src/components/Sidebar/SidebarNav/SidebarNav.module.scss
  38. 31 0
      apps/app/src/components/Sidebar/SidebarNav/SidebarNav.tsx
  39. 1 0
      apps/app/src/components/Sidebar/SidebarNav/_variables.scss
  40. 1 0
      apps/app/src/components/Sidebar/SidebarNav/index.ts
  41. 29 0
      apps/app/src/components/Sidebar/_button-styles.scss
  42. 1 0
      apps/app/src/components/Sidebar/_variables.scss
  43. 0 3
      apps/app/src/components/TreeItem/NewPageCreateButton.tsx
  44. 1 2
      apps/app/src/interfaces/sidebar-config.ts
  45. 8 0
      apps/app/src/interfaces/ui.ts
  46. 1 3
      apps/app/src/interfaces/user-ui-settings.ts
  47. 1 2
      apps/app/src/pages/_private-legacy-pages.page.tsx
  48. 1 2
      apps/app/src/pages/_search.page.tsx
  49. 1 2
      apps/app/src/pages/me/[[...path]].page.tsx
  50. 1 2
      apps/app/src/pages/tags.page.tsx
  51. 1 2
      apps/app/src/pages/trash.page.tsx
  52. 3 6
      apps/app/src/pages/utils/commons.ts
  53. 1 2
      apps/app/src/server/models/config.ts
  54. 1 3
      apps/app/src/server/models/user-ui-settings.ts
  55. 5 9
      apps/app/src/server/routes/apiv3/customize-setting.js
  56. 2 6
      apps/app/src/server/routes/apiv3/user-ui-settings.ts
  57. 2 2
      apps/app/src/server/service/page.ts
  58. 1 1
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  59. 52 0
      apps/app/src/stores/admin/sidebar-config.tsx
  60. 58 119
      apps/app/src/stores/ui.tsx
  61. 0 6
      apps/app/src/styles/_variables.scss
  62. 2 2
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  63. 14 22
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  64. 4 4
      apps/app/test/cypress/support/commands.ts
  65. 1 0
      packages/editor/package.json
  66. 29 0
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.module.scss
  67. 5 3
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  68. 38 0
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx
  69. 8 9
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx
  70. 5 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  71. 5 2
      packages/editor/src/components/CodeMirrorEditorComment.tsx
  72. 8 3
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  73. 3 2
      packages/editor/src/components/playground/Playground.tsx
  74. 6 0
      packages/editor/src/consts/accepted-upload-file-type.ts
  75. 1 0
      packages/editor/src/consts/index.ts
  76. 13 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  77. 13 2
      packages/editor/src/services/file-dropzone/use-file-dropzone.ts

+ 0 - 0
apps/app/src/components/Sidebar/AppearanceModeDropdown.tsx → apps/app/_obsolete/src/components/Sidebar/AppearanceModeDropdown.tsx


+ 0 - 0
apps/app/src/components/Sidebar/NavigationResizeHexagon.tsx → apps/app/_obsolete/src/components/Sidebar/NavigationResizeHexagon.tsx


+ 18 - 23
apps/app/_obsolete/src/styles/theme/apply-colors.scss

@@ -206,29 +206,24 @@ ul.pagination {
   $bgcolor-resize-button: var(--bgcolor-resize-button,white);
   $color-resize-button-hover: var(--color-resize-button-hover,var(--color-reversal));
   $bgcolor-resize-button-hover: var(--bgcolor-resize-button-hover,#{hsl.lighten(var(--bgcolor-resize-button), 5%)});
-  .grw-navigation-resize-button {
-    .hexagon-container svg {
-      .background {
-        fill: var(--bgcolor-resize-button);
-      }
-      .icon {
-        fill: var(--color-resize-button);
-      }
-    }
-    &:hover .hexagon-container svg {
-      .background {
-        fill: var(--bgcolor-resize-button-hover);
-      }
-      .icon {
-        fill: var(--color-resize-button-hover);
-      }
-    }
-  }
-  div.grw-global-navigation {
-    > div {
-      background-color: var(--bgcolor-sidebar);
-    }
-  }
+  // .grw-navigation-resize-button {
+  //   .hexagon-container svg {
+  //     .background {
+  //       fill: var(--bgcolor-resize-button);
+  //     }
+  //     .icon {
+  //       fill: var(--color-resize-button);
+  //     }
+  //   }
+  //   &:hover .hexagon-container svg {
+  //     .background {
+  //       fill: var(--bgcolor-resize-button-hover);
+  //     }
+  //     .icon {
+  //       fill: var(--color-resize-button-hover);
+  //     }
+  //   }
+  // }
   div.grw-contextual-navigation {
     > div {
       color: var(--color-sidebar-context);

+ 1 - 11
apps/app/src/client/services/user-ui-settings.ts

@@ -18,7 +18,7 @@ const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> =>
 const _putUserUISettingsInBulkDebounced = debounce(1500, _putUserUISettingsInBulk);
 
 type ScheduleToPutFunction = (settings: Partial<IUserUISettings>) => Promise<AxiosResponse<IUserUISettings>>;
-const scheduleToPut: ScheduleToPutFunction = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+export const scheduleToPut: ScheduleToPutFunction = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
   settingsForBulk = {
     ...settingsForBulk,
     ...settings,
@@ -26,13 +26,3 @@ const scheduleToPut: ScheduleToPutFunction = (settings: Partial<IUserUISettings>
 
   return _putUserUISettingsInBulkDebounced();
 };
-
-type UserUISettingsUtil = {
-  scheduleToPut: ScheduleToPutFunction | (() => void),
-}
-export const useUserUISettings = (): UserUISettingsUtil => {
-
-  return {
-    scheduleToPut,
-  };
-};

+ 10 - 12
apps/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -4,14 +4,14 @@ import { useTranslation } from 'next-i18next';
 import { Card, CardBody } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxSidebarConfig } from '~/stores/ui';
+import { useSWRxSidebarConfig } from '~/stores/admin/sidebar-config';
 import { useNextThemes } from '~/stores/use-next-themes';
 
 const CustomizeSidebarsetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
 
   const {
-    update, isSidebarDrawerMode, isSidebarClosedAtDockMode, setIsSidebarDrawerMode, setIsSidebarClosedAtDockMode,
+    update, isSidebarCollapsedMode, setIsSidebarCollapsedMode,
   } = useSWRxSidebarConfig();
 
   const { resolvedTheme } = useNextThemes();
@@ -45,8 +45,8 @@ const CustomizeSidebarsetting = (): JSX.Element => {
             <div id="layoutOptions" className="row row-cols-2">
               <div className="col">
                 <div
-                  className={`card customize-layout-card ${isSidebarDrawerMode ? 'border-active' : ''}`}
-                  onClick={() => setIsSidebarDrawerMode(true)}
+                  className={`card customize-layout-card ${isSidebarCollapsedMode ? 'border-active' : ''}`}
+                  onClick={() => setIsSidebarCollapsedMode(true)}
                   role="button"
                 >
                   <img src={drawerIconFileName} />
@@ -57,8 +57,8 @@ const CustomizeSidebarsetting = (): JSX.Element => {
               </div>
               <div className="col">
                 <div
-                  className={`card customize-layout-card ${!isSidebarDrawerMode ? 'border-active' : ''}`}
-                  onClick={() => setIsSidebarDrawerMode(false)}
+                  className={`card customize-layout-card ${!isSidebarCollapsedMode ? 'border-active' : ''}`}
+                  onClick={() => setIsSidebarCollapsedMode(false)}
                   role="button"
                 >
                   <img src={dockIconFileName} />
@@ -83,9 +83,8 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 id="is-open"
                 className="form-check-input"
                 name="mailVisibility"
-                checked={isSidebarDrawerMode === false && isSidebarClosedAtDockMode === false}
-                disabled={isSidebarDrawerMode}
-                onChange={() => setIsSidebarClosedAtDockMode(false)}
+                checked={isSidebarCollapsedMode === false}
+                onChange={() => setIsSidebarCollapsedMode(false)}
               />
               <label className="form-label form-check-label" htmlFor="is-open">
                 {t('customize_settings.default_sidebar_mode.dock_mode_default_open')}
@@ -97,9 +96,8 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 id="is-closed"
                 className="form-check-input"
                 name="mailVisibility"
-                checked={isSidebarDrawerMode === false && isSidebarClosedAtDockMode === true}
-                disabled={isSidebarDrawerMode}
-                onChange={() => setIsSidebarClosedAtDockMode(true)}
+                checked={isSidebarCollapsedMode === true}
+                onChange={() => setIsSidebarCollapsedMode(true)}
               />
               <label className="form-label form-check-label" htmlFor="is-closed">
                 {t('customize_settings.default_sidebar_mode.dock_mode_default_close')}

+ 4 - 12
apps/app/src/components/CompleteUserRegistrationForm.tsx

@@ -111,16 +111,12 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
               <input type="hidden" name="token" value={token} />
 
               <div className="input-group">
-                <div>
-                  <span className="input-group-text"><i className="icon-envelope"></i></span>
-                </div>
+                <span className="input-group-text"><i className="icon-envelope"></i></span>
                 <input type="text" className="form-control" placeholder={t('Email')} disabled value={email} />
               </div>
 
               <div className="input-group" id="input-group-username">
-                <div>
-                  <span className="input-group-text"><i className="icon-user"></i></span>
-                </div>
+                <span className="input-group-text"><i className="icon-user"></i></span>
                 <input
                   type="text"
                   className="form-control"
@@ -138,9 +134,7 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
               )}
 
               <div className="input-group">
-                <div>
-                  <span className="input-group-text"><i className="icon-tag"></i></span>
-                </div>
+                <span className="input-group-text"><i className="icon-tag"></i></span>
                 <input
                   type="text"
                   className="form-control"
@@ -154,9 +148,7 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
               </div>
 
               <div className="input-group">
-                <div>
-                  <span className="input-group-text"><i className="icon-lock"></i></span>
-                </div>
+                <span className="input-group-text"><i className="icon-lock"></i></span>
                 <input
                   type="password"
                   className="form-control"

+ 4 - 12
apps/app/src/components/InstallerForm.tsx

@@ -147,9 +147,7 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className={`input-group mb-3${hasErrorClass}`}>
-            <div>
-              <span className="input-group-text"><i className="icon-user" /></span>
-            </div>
+            <span className="input-group-text"><i className="icon-user" /></span>
             <input
               data-testid="tiUsername"
               type="text"
@@ -163,9 +161,7 @@ const InstallerForm = memo((): JSX.Element => {
           <p className="form-text">{ unavailableUserId }</p>
 
           <div className="input-group mb-3">
-            <div>
-              <span className="input-group-text"><i className="icon-tag" /></span>
-            </div>
+            <span className="input-group-text"><i className="icon-tag" /></span>
             <input
               data-testid="tiName"
               type="text"
@@ -177,9 +173,7 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <div>
-              <span className="input-group-text"><i className="icon-envelope" /></span>
-            </div>
+            <span className="input-group-text"><i className="icon-envelope" /></span>
             <input
               data-testid="tiEmail"
               type="email"
@@ -191,9 +185,7 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <div>
-              <span className="input-group-text"><i className="icon-lock" /></span>
-            </div>
+            <span className="input-group-text"><i className="icon-lock" /></span>
             <input
               data-testid="tiPassword"
               type="password"

+ 12 - 20
apps/app/src/components/InvitedForm.tsx

@@ -82,11 +82,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
       <form role="form" onSubmit={submitHandler} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
-          <div>
-            <span className="input-group-text">
-              <i className="icon-envelope"></i>
-            </span>
-          </div>
+          <span className="input-group-text">
+            <i className="icon-envelope"></i>
+          </span>
           <input
             type="text"
             className="form-control"
@@ -99,11 +97,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* UserID Form */}
         <div className="input-group" id="input-group-username">
-          <div>
-            <span className="input-group-text">
-              <i className="icon-user"></i>
-            </span>
-          </div>
+          <span className="input-group-text">
+            <i className="icon-user"></i>
+          </span>
           <input
             type="text"
             className="form-control"
@@ -115,11 +111,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* Name Form */}
         <div className="input-group">
-          <div>
-            <span className="input-group-text">
-              <i className="icon-tag"></i>
-            </span>
-          </div>
+          <span className="input-group-text">
+            <i className="icon-tag"></i>
+          </span>
           <input
             type="text"
             className="form-control"
@@ -131,11 +125,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* Password Form */}
         <div className="input-group">
-          <div>
-            <span className="input-group-text">
-              <i className="icon-lock"></i>
-            </span>
-          </div>
+          <span className="input-group-text">
+            <i className="icon-lock"></i>
+          </span>
           <input
             type="password"
             className="form-control"

+ 0 - 2
apps/app/src/components/ItemsTree/ItemsTree.module.scss

@@ -17,8 +17,6 @@ $grw-pagetree-item-container-height: 40px;
   }
 
   :global {
-    min-height: calc(100vh - ($grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
-
     .btn-page-item-control {
       .icon-plus::before {
         font-size: 18px;

+ 4 - 2
apps/app/src/components/Layout/BasicLayout.tsx

@@ -37,9 +37,11 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <DndProvider backend={HTML5Backend}>
 
         <div className="page-wrapper flex-row">
-          <Sidebar />
+          <div className="z-2">
+            <Sidebar />
+          </div>
 
-          <div className="d-flex flex-grow-1 flex-column">{/* neccessary for nested {children} make expanded */}
+          <div className="d-flex flex-grow-1 flex-column z-1">{/* neccessary for nested {children} make expanded */}
             <AlertSiteUrlUndefined />
             {children}
           </div>

+ 157 - 10
apps/app/src/components/Layout/NoLoginLayout.module.scss

@@ -1,4 +1,5 @@
-@use '@growi/core/scss/bootstrap/init' as *;
+@use '@growi/core/scss/bootstrap/init' as bs;
+@use '@growi/core/scss/growi-official-colors' as var;
 
 
 .nologin :global {
@@ -16,11 +17,11 @@
 
       .nologin-header {
         display: flex;
-        flex-direction: column;
         align-items: center;
         padding-top: 30px;
         padding-bottom: 10px;
       }
+
     }
 
   }
@@ -55,32 +56,32 @@
 
   $btn-fill-colors: (
     'login': (
-      rgba($danger, 0.4),
+      rgba(bs.$danger, 0.4),
       rgba(#7e4153, 0.7),
     ),
     'register': (
-      rgba($success, 0.4),
+      rgba(bs.$success, 0.4),
       rgba(#3f7263, 0.7),
     ),
     'google': (
       rgba(#24292e, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'github': (
       rgba(lighten(black, 20%), 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'facebook': (
       rgba(#29487d, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'oidc': (
       rgba(#24292e, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'saml': (
       rgba(#55a79a, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
   );
 
@@ -114,7 +115,7 @@
 }
 
 .link-switch {
-  color: $gray-200;
+  color: bs.$gray-200;
 
   &:hover {
     color: white;
@@ -126,3 +127,149 @@
     line-height: 1em;
   }
 }
+
+// Light mode color
+@include bs.color-mode(light) {
+  .nologin :global {
+    // background color
+    $color-gradient: #3c465c;
+    background: linear-gradient(45deg, darken($color-gradient, 30%) 0%, hsla(340, 100%, 55%, 0) 70%),
+      linear-gradient(135deg, var.$growi-green 10%, hsla(225, 95%, 50%, 0) 70%), linear-gradient(225deg, var.$growi-blue 10%, hsla(140, 90%, 50%, 0) 80%),
+      linear-gradient(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
+
+    .nologin-header {
+      background-color: rgba(white, 0.5);
+
+      svg {
+        color: var(--bs-body-color);
+      }
+
+      .logo {
+        color: rgba(black, 0.5);
+        background-color: rgba(black, 0);
+      }
+
+      h1 {
+        color: rgba(black, 0.5);
+      }
+    }
+
+    .nologin-dialog {
+      background-color: rgba(white, 0.5);
+      .link-switch {
+        color: #1939b8;
+        &:hover {
+          color: lighten(#1939b8,20%);
+        }
+      }
+    }
+
+    .input-group {
+      .input-group-text {
+        color: darken(white, 30%);
+        background-color: rgba(bs.$gray-700, 0.7);
+      }
+
+      .form-control {
+        color: white;
+        background-color: rgba(bs.$gray-600, 0.7);
+        box-shadow: unset;
+
+        &::placeholder {
+          color: darken(white, 30%);
+        }
+      }
+    }
+
+    .link-growi-org {
+      color: rgba(black, 0.4);
+
+      &:hover,
+      &.focus {
+        color: black;
+
+        .growi {
+          color: darken(var.$growi-green, 20%);
+        }
+
+        .org {
+          color: darken(var.$growi-blue, 15%);
+        }
+      }
+    }
+  }
+}
+
+// Dark mode color
+@include bs.color-mode(dark) {
+  .nologin :global {
+    // background color
+    $color-gradient: #3c465c;
+    background: linear-gradient(45deg, darken($color-gradient, 30%) 0%, hsla(340, 100%, 55%, 0) 70%),
+      linear-gradient(135deg, darken(var.$growi-green, 30%) 10%, hsla(225, 95%, 50%, 0) 70%),
+      linear-gradient(225deg, darken(var.$growi-blue, 20%) 10%, hsla(140, 90%, 50%, 0) 80%),
+      linear-gradient(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
+
+    .nologin-header {
+      background-color: rgba(black, 0.5);
+
+      svg {
+        color: var(--bs-body-color);
+      }
+
+      .logo {
+        color: rgba(white, 0.5);
+        background-color: rgba(white, 0);
+      }
+
+      h1 {
+        color: rgba(white, 0.5);
+      }
+    }
+
+    .nologin-dialog {
+      background-color: rgba(black, 0.5);
+      .link-switch {
+        color: #7b9bd5;
+        &:hover {
+          color: lighten(#7b9bd5,10%);
+        }
+      }
+    }
+
+    .input-group {
+      .input-group-text {
+        color: darken(white, 30%);
+        background-color: rgba(bs.$gray-700, 0.7);
+      }
+
+      .form-control {
+        color: white;
+        background-color: rgba(#505050, 0.7);
+        box-shadow: unset;
+
+        &::placeholder {
+          color: darken(white, 30%);
+        }
+      }
+    }
+
+    .link-growi-org {
+      color: rgba(white, 0.4);
+
+      &:hover,
+      &.focus {
+        color: rgba(white, 0.7);
+
+        .growi {
+          color: darken(var.$growi-green, 5%);
+        }
+
+        .org {
+          color: darken(var.$growi-blue, 5%);
+        }
+      }
+    }
+
+  }
+}

+ 3 - 3
apps/app/src/components/Layout/NoLoginLayout.tsx

@@ -26,13 +26,13 @@ export const NoLoginLayout = ({
 
   return (
     <RawLayout className={`nologin ${commonStyles.nologin} ${classNames}`}>
-      <div className="page-wrapper">
+      <div className="page-wrapper flex-row">
         <div className="main container-fluid">
 
           <div className="row">
 
-            <div className="col-md-12">
-              <div className="nologin-header mx-auto">
+            <div className="col-md-12 position-relative">
+              <div className="nologin-header mx-auto flex-column">
                 <GrowiLogo />
                 <h1 className="my-3">{ appTitle ?? 'GROWI' }</h1>
                 <div className="noLogin-form-errors px-3"></div>

+ 22 - 36
apps/app/src/components/LoginForm.tsx

@@ -195,11 +195,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
-            <div>
-              <span className="input-group-text">
-                <i className="icon-user"></i>
-              </span>
-            </div>
+            <span className="input-group-text">
+              <i className="icon-user"></i>
+            </span>
             <input
               type="text"
               className="form-control rounded-0"
@@ -209,20 +207,16 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
               name="usernameForLogin"
             />
             {isLdapStrategySetup && (
-              <div>
-                <small className="input-group-text text-success">
-                  <i className="icon-fw icon-check"></i> LDAP
-                </small>
-              </div>
+              <small className="input-group-text text-success">
+                <i className="icon-fw icon-check"></i> LDAP
+              </small>
             )}
           </div>
 
           <div className="input-group">
-            <div>
-              <span className="input-group-text">
-                <i className="icon-lock"></i>
-              </span>
-            </div>
+            <span className="input-group-text">
+              <i className="icon-lock"></i>
+            </span>
             <input
               type="password"
               className="form-control rounded-0"
@@ -311,7 +305,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             type="button"
             className="btn btn-secondary btn-external-auth-tab btn-sm rounded-0 mb-3"
             data-bs-toggle={isExternalAuthCollapsible ? 'collapse' : ''}
-            data-target="#external-auth"
+            data-bs-target="#external-auth"
             aria-expanded="false"
             aria-controls="external-auth"
           >
@@ -421,11 +415,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           {!isEmailAuthenticationEnabled && (
             <div>
               <div className="input-group" id="input-group-username">
-                <div>
-                  <span className="input-group-text">
-                    <i className="icon-user"></i>
-                  </span>
-                </div>
+                <span className="input-group-text">
+                  <i className="icon-user"></i>
+                </span>
                 {/* username */}
                 <input
                   type="text"
@@ -441,11 +433,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <span id="help-block-username"></span>
               </p>
               <div className="input-group">
-                <div>
-                  <span className="input-group-text">
-                    <i className="icon-tag"></i>
-                  </span>
-                </div>
+                <span className="input-group-text">
+                  <i className="icon-tag"></i>
+                </span>
                 {/* name */}
                 <input
                   type="text"
@@ -461,11 +451,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           )}
 
           <div className="input-group">
-            <div>
-              <span className="input-group-text">
-                <i className="icon-envelope"></i>
-              </span>
-            </div>
+            <span className="input-group-text">
+              <i className="icon-envelope"></i>
+            </span>
             {/* email */}
             <input
               type="email"
@@ -497,11 +485,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           {!isEmailAuthenticationEnabled && (
             <div>
               <div className="input-group">
-                <div>
-                  <span className="input-group-text">
-                    <i className="icon-lock"></i>
-                  </span>
-                </div>
+                <span className="input-group-text">
+                  <i className="icon-lock"></i>
+                </span>
                 {/* Password */}
                 <input
                   type="password"

+ 1 - 1
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -52,7 +52,7 @@ export const GrowiNavbarBottom = (): JSX.Element => {
                 <a
                   role="button"
                   className="nav-link btn-lg"
-                  data-target="#grw-global-search-collapse"
+                  data-bs-target="#grw-global-search-collapse"
                   data-bs-toggle="collapse"
                 >
                   <i className="icon-magnifier"></i>

+ 1 - 1
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -459,7 +459,7 @@ export const HandsontableModal = (): JSX.Element => {
             type="button"
             className="me-4 data-import-button btn btn-secondary"
             data-bs-toggle="collapse"
-            data-target="#collapseDataImport"
+            data-bs-target="#collapseDataImport"
             aria-expanded={isDataImportAreaExpanded}
             onClick={toggleDataImportArea}
           >

+ 13 - 3
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -7,7 +7,9 @@ import nodePath from 'path';
 
 import type { IPageHasId } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
-import { CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated } from '@growi/editor';
+import {
+  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, AcceptedUploadFileType,
+} from '@growi/editor';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
@@ -359,6 +361,15 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   }, [codeMirrorEditor, currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
 
+  const acceptedFileType = useMemo(() => {
+    if (!isUploadableFile) {
+      return AcceptedUploadFileType.NONE;
+    }
+    if (isUploadableImage) {
+      return AcceptedUploadFileType.IMAGE;
+    }
+    return AcceptedUploadFileType.ALL;
+  }, [isUploadableFile, isUploadableImage]);
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
     if (previewRef.current == null) {
@@ -547,8 +558,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     return <></>;
   }
 
-  const isUploadable = isUploadableImage || isUploadableFile;
-
   return (
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
       <div className="page-editor-editor-container flex-expand-vert">
@@ -574,6 +583,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
           socket={socket}
           initialValue={initialValue}
           setMarkdownToPreview={setMarkdownToPreview}
+          acceptedFileType={acceptedFileType}
         />
       </div>
       <div className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">

+ 94 - 0
apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -0,0 +1,94 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '@growi/core/scss/growi-official-colors';
+
+@use '../button-styles';
+@use '../variables' as var;
+
+// GROWI Logo
+.grw-app-title :global {
+  .grw-logo {
+    $width: var.$grw-sidebar-nav-width;
+    $height: var.$grw-sidebar-nav-width; // declare $height with the same value as the sidebar nav width
+    $logomark-width: 27.7px;
+    $logomark-height: 24px;
+
+    width: $width;
+
+    svg {
+      width: $width;
+      height: $height;
+      padding: (($height - $logomark-height) / 2) (($width - $logomark-width) / 2);
+    }
+  }
+}
+
+
+// == Location
+.on-subnavigation {
+  $grw-contextual-sub-navigation-width: 500px;
+
+  left: var.$grw-sidebar-nav-width;
+  // set width for truncation
+  width: calc(100vw - $grw-contextual-sub-navigation-width);
+}
+
+.on-sidebar-head {
+  $toggle-collapse-button-width: 50px;
+
+  // set width for truncation
+  width: calc(100% - $toggle-collapse-button-width);
+}
+
+
+// == Interaction
+@keyframes bounce-to-right {
+  10% { transform:translateX(3px); }
+  20% { transform:translateX(0%); }
+  25% { transform:translateX(2px); }
+  27% { transform:translateX(0%); }
+}
+
+.on-subnavigation {
+  animation: bounce-to-right 1s ease;
+}
+
+@keyframes bounce-to-left {
+  0% { transform:translateX(48px); }
+  100% { transform:translateX(0%); }
+}
+
+.on-sidebar-head {
+  animation: bounce-to-left 0.2s ease;
+}
+
+
+
+// == Colors
+.grw-app-title :global {
+  .grw-logo {
+    // set transition for fill
+    svg, svg * {
+      transition: fill 0.8s ease-out;
+    }
+
+    fill: var(--grw-app-title-color, var(--bs-tertiary-color));
+
+    &:hover {
+      svg {
+        .group1 {
+          fill: growi-official-colors.$growi-green;
+        }
+
+        .group2 {
+          fill: growi-official-colors.$growi-blue;
+        }
+      }
+    }
+  }
+
+  .grw-site-name {
+    --bs-link-color-rgb: var(--grw-app-title-color-rgb, var(--bs-tertiary-color-rgb));
+    --bs-link-opacity: 0.5;
+  }
+}

+ 46 - 0
apps/app/src/components/Sidebar/AppTitle/AppTitle.tsx

@@ -0,0 +1,46 @@
+import React, { memo } from 'react';
+
+import Link from 'next/link';
+
+import { useAppTitle, useIsDefaultLogo } from '~/stores/context';
+
+import { SidebarBrandLogo } from '../SidebarBrandLogo';
+
+import styles from './AppTitle.module.scss';
+
+
+type Props = {
+  className?: string,
+}
+
+const AppTitleSubstance = memo((props: Props): JSX.Element => {
+
+  const { className } = props;
+
+  const { data: isDefaultLogo } = useIsDefaultLogo();
+  const { data: appTitle } = useAppTitle();
+
+  return (
+    <div className={`${styles['grw-app-title']} ${className} d-flex d-edit-none`}>
+      {/* Brand Logo  */}
+      <Link href="/" className="grw-logo d-block">
+        <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
+      </Link>
+      <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
+        <div className="grw-site-name text-truncate">
+          <Link href="/" className="fs-4">
+            {appTitle}
+          </Link>
+        </div>
+      </div>
+    </div>
+  );
+});
+
+export const AppTitleOnSubnavigation = memo((): JSX.Element => {
+  return <AppTitleSubstance className={`position-absolute ${styles['on-subnavigation']}`} />;
+});
+
+export const AppTitleOnSidebarHead = memo((): JSX.Element => {
+  return <AppTitleSubstance className={`position-absolute z-1 ${styles['on-sidebar-head']}`} />;
+});

+ 112 - 0
apps/app/src/components/Sidebar/PageCreateButton.tsx

@@ -0,0 +1,112 @@
+import React, { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+export const PageCreateButton = React.memo((): JSX.Element => {
+  const router = useRouter();
+
+  const [isHovered, setIsHovered] = useState(false);
+
+  const onMouseEnterHandler = () => {
+    setIsHovered(true);
+  };
+
+  const onMouseLeaveHandler = () => {
+    setIsHovered(false);
+  };
+
+  const iconName = 'create';
+  const isSelected = true;
+  // TODO: create page directly
+  // TODO: https://redmine.weseek.co.jp/issues/132680s
+  const onCreateNewPageButtonHandler = useCallback(() => {
+    // router.push(`${router.pathname}#edit`);
+  }, [router]);
+  const onCreateTodaysButtonHandler = useCallback(() => {
+    // router.push(`${router.pathname}#edit`);
+  }, [router]);
+  const onTemplateForChildrenButtonHandler = useCallback(() => {
+    // router.push(`${router.pathname}/_template#edit`);
+  }, [router]);
+  const onTemplateForDescendantsButtonHandler = useCallback(() => {
+    // router.push(`${router.pathname}/__template#edit`);
+  }, [router]);
+
+  // TODO: update button design
+  // https://redmine.weseek.co.jp/issues/132683
+  // TODO: i18n
+  // https://redmine.weseek.co.jp/issues/132681
+  return (
+    <div
+      className="d-flex flex-row"
+      onMouseEnter={onMouseEnterHandler}
+      onMouseLeave={onMouseLeaveHandler}
+    >
+      <div className="btn-group">
+        <button
+          className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
+          onClick={onCreateNewPageButtonHandler}
+          type="button"
+          data-testid="grw-sidebar-nav-page-create-button"
+        >
+          <i className="material-icons">{iconName}</i>
+        </button>
+      </div>
+      {isHovered && (
+        <div className="btn-group dropend">
+          <button
+            className="btn btn-secondary dropdown-toggle dropdown-toggle-split position-absolute"
+            type="button"
+            data-bs-toggle="dropdown"
+            aria-expanded="false"
+          />
+          <ul className="dropdown-menu">
+            <li>
+              <button
+                className="dropdown-item"
+                onClick={onCreateNewPageButtonHandler}
+                type="button"
+              >
+                Create New Page
+              </button>
+            </li>
+            <li><hr className="dropdown-divider" /></li>
+            <li><span className="text-muted px-3">Create today&apos;s ...</span></li>
+            {/* TODO: show correct create today's page path */}
+            {/* https://redmine.weseek.co.jp/issues/132682 */}
+            <li>
+              <button
+                className="dropdown-item"
+                onClick={onCreateTodaysButtonHandler}
+                type="button"
+              >
+                Create today&apos;s
+              </button>
+            </li>
+            <li><hr className="dropdown-divider" /></li>
+            <li><span className="text-muted px-3">Child page template</span></li>
+            <li>
+              <button
+                className="dropdown-item"
+                onClick={onTemplateForChildrenButtonHandler}
+                type="button"
+              >
+                Template for children
+              </button>
+            </li>
+            <li>
+              <button
+                className="dropdown-item"
+                onClick={onTemplateForDescendantsButtonHandler}
+                type="button"
+              >
+                Template for descendants
+              </button>
+            </li>
+          </ul>
+        </div>
+      )}
+    </div>
+  );
+});
+PageCreateButton.displayName = 'PageCreateButton';

+ 37 - 0
apps/app/src/components/Sidebar/ResizableArea/ResizableArea.module.scss

@@ -0,0 +1,37 @@
+.grw-resizable-area :global {
+  will-change: width;
+}
+
+.grw-resizable-area:not(:global .dragging) {
+  transition: width 100ms cubic-bezier(0.2, 0, 0, 1) 0s;
+}
+
+
+.grw-navigation-draggable :global {
+  position: absolute;
+  top: 0px;
+  bottom: 0px;
+  left: 100%;
+
+  .grw-navigation-draggable-hitarea {
+    position: absolute;
+    left: -4px;
+    width: 24px;
+    height: 100%;
+    cursor: ew-resize;
+  }
+  .grw-navigation-draggable-line {
+    position: absolute;
+    left: -1px;
+    display: none;
+    width: 3px;
+    height: 100%;
+    background-color: rgb(76, 154, 255);
+  }
+}
+
+.grw-navigation-draggable:hover :global {
+  .grw-navigation-draggable-line {
+    display: block;
+  }
+}

+ 99 - 0
apps/app/src/components/Sidebar/ResizableArea/ResizableArea.tsx

@@ -0,0 +1,99 @@
+import React, { memo, useCallback, useRef } from 'react';
+
+
+import styles from './ResizableArea.module.scss';
+
+
+type Props = {
+  className?: string,
+  width?: number,
+  minWidth?: number,
+  disabled?: boolean,
+  children?: React.ReactNode,
+  onResize?: (newWidth: number) => void,
+  onResizeDone?: (newWidth: number) => void,
+  onCollapsed?: () => void,
+}
+
+export const ResizableArea = memo((props: Props): JSX.Element => {
+  const {
+    className,
+    width, minWidth = 0,
+    disabled, children,
+    onResize, onResizeDone, onCollapsed,
+  } = props;
+
+  const resizableContainer = useRef<HTMLDivElement>(null);
+
+  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
+    event.preventDefault();
+
+    const widthByMousePos = event.pageX;
+
+    const newWidth = Math.max(widthByMousePos, minWidth);
+    onResize?.(newWidth);
+    resizableContainer.current?.classList.add('dragging');
+  }, [minWidth, onResize]);
+
+  const dragableAreaMouseUpHandler = useCallback((event: MouseEvent) => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+
+    const widthByMousePos = event.pageX;
+
+    if (widthByMousePos < minWidth / 2) {
+      // force collapsed
+      onCollapsed?.();
+    }
+    else {
+      const newWidth = resizableContainer.current.clientWidth;
+      onResizeDone?.(newWidth);
+    }
+
+    resizableContainer.current.classList.remove('dragging');
+
+  }, [minWidth, onCollapsed, onResizeDone]);
+
+  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
+    if (disabled) {
+      return;
+    }
+
+    event.preventDefault();
+
+    const removeEventListeners = () => {
+      document.removeEventListener('mousemove', draggableAreaMoveHandler);
+      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
+      document.removeEventListener('mouseup', removeEventListeners);
+    };
+
+    document.addEventListener('mousemove', draggableAreaMoveHandler);
+    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
+    document.addEventListener('mouseup', removeEventListeners);
+
+  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, disabled]);
+
+  return (
+    <>
+      <div
+        ref={resizableContainer}
+        className={`${styles['grw-resizable-area']} ${className}`}
+        style={{ width }}
+      >
+        {children}
+      </div>
+      <div className={styles['grw-navigation-draggable']}>
+        { !disabled && (
+          <>
+            <div
+              className="grw-navigation-draggable-hitarea"
+              onMouseDown={dragableAreaMouseDownHandler}
+            />
+            <div className="grw-navigation-draggable-line"></div>
+          </>
+        ) }
+      </div>
+    </>
+  );
+});

+ 1 - 0
apps/app/src/components/Sidebar/ResizableArea/index.ts

@@ -0,0 +1 @@
+export * from './ResizableArea';

+ 45 - 189
apps/app/src/components/Sidebar/Sidebar.module.scss

@@ -1,139 +1,20 @@
-@use '~/styles/mixins';
 @use '@growi/core/scss/bootstrap/init' as bs;
 
-.grw-sidebar :global {
-  // sticky
-  position: sticky;
-  top: 0;
+@use '~/styles/mixins';
 
-  // set the max value that should be taken when sticky
-  height: 100vh;
+@use './variables' as var;
 
-  border-right : 1px solid var(--bs-border-color);
+.grw-sidebar :global {
+  top: 0;
 
-  .data-layout-container {
-    display: flex;
-    flex-direction: row;
-    height: 100vh;
-    margin-top: 0px;
+  .sidebar-contents-container {
+    backdrop-filter: blur(20px);
   }
-  .navigation {
-    .grw-navigation-wrap {
-      display: flex;
-      flex-direction: row;
-      height: 100%;
-      overflow: hidden;
-
-      .grw-contextual-navigation {
-        position: relative;
-        width: 240px;
-        height: 100%;
-        &:not(.dragging) {
-          transition: width 200ms cubic-bezier(0.2, 0, 0, 1) 0s;
-        }
-        will-change: width;
-
-        .grw-contextual-navigation-child {
-          height: 100%;
-          overflow-x: hidden;
-        }
-
-        .grw-drawer-toggler {
-          display: none; // invisible in default
-        }
-
-      }
+}
 
-      .simplebar-mask {
-        z-index: 110; // greater than the value of .grw-navigation-draggable to fix https://redmine.weseek.co.jp/issues/86678
-      }
-    }
-    .grw-navigation-draggable {
-      position: absolute;
-      top: 0px;
-      bottom: 0px;
-      left: 100%;
-      z-index: 10; // greater than the value of SimpleBar
-      width: 0;
-      .grw-navigation-draggable-hitarea {
-        position: relative;
-        left: -4px;
-        width: 24px;
-        height: 100%;
-        cursor: ew-resize;
-        .grw-navigation-draggable-hitarea-child {
-          position: absolute;
-          left: 3px;
-          display: none;
-          width: 2px;
-          height: 100%;
-          background-color: rgb(76, 154, 255);
-        }
-      }
-      .grw-navigation-resize-button {
-        position: fixed;
-
-        $width: 27.691px;
-        $height: 23.999px;
-
-        @mixin hitarea($size-hitarea) {
-          top: ($width - $size-hitarea) / 2;
-          left: ($height - $size-hitarea) / 2;
-          width: $size-hitarea;
-          height: $size-hitarea;
-        }
-
-        // locate to the center of screen
-        top: calc(50vh - $height/2);
-
-        display: none;
-        padding: 0px;
-        background-color: transparent;
-        border: 0;
-        transform: translateX(-50%);
-
-        .hexagon-container {
-          // set transform
-          svg * {
-            transition: fill 100ms linear;
-          }
-          svg {
-            width: $width + 2px; // add 1px for drop-shadow
-            height: $height + 2px; // add 1px for drop-shadow
-            .background {
-              filter: drop-shadow(0px 1px 0px rgba(#999, 60%));
-            }
-          }
-        }
-        .hitarea {
-          position: absolute;
-          border-radius: bs.$border-radius-pill;
-
-          @include hitarea(30px);
-        }
-
-        // reverse and center icon at the time of collapsed
-        &.collapsed {
-          display: block;
-          .hexagon-container svg {
-            transform: rotate(180deg);
-          }
-          .hitarea {
-            @include hitarea(80px);
-          }
-        }
-      }
-      &:hover {
-        .grw-navigation-draggable-hitarea-child {
-          display: block;
-        }
-        .grw-navigation-resize-button {
-          display: block;
-        }
-      }
-    }
-  }
 
+// TODO: commonize reload button style
+.grw-sidebar :global {
   .grw-sidebar-content-header {
     .grw-btn-reload {
       font-size: 18px;
@@ -141,91 +22,66 @@
   }
 }
 
-
 // Dock Mode
-@mixin dock() {
-}
-
-// Drawer Mode
-@mixin drawer() {
-  z-index: bs.$zindex-fixed + 2;
-
-  .data-layout-container {
-    position: fixed;
-    top: 0;
-    width: 0;
-  }
-  div.navigation.transition-enabled {
-    max-width: 80vw;
-
-    // apply transition
-    transition-property: transform;
-    @include mixins.apply-navigation-transition();
-  }
-
-  &:not(.open) {
-    div.navigation {
-      transform: translateX(-100%);
+.grw-sidebar {
+  &:global {
+    &.grw-sidebar-dock {
+      position: sticky;
     }
   }
-  &.open {
-    div.navigation {
-      transform: translateX(0);
-    }
+}
 
-    .grw-contextual-navigation-child {
-      .grw-drawer-toggler {
-        display: block;
-      }
-    }
-  }
+// Collapsed Mode
+.grw-sidebar {
+  &:global {
+    &.grw-sidebar-collapsed {
+      position: sticky;
 
-  .grw-navigation-resize-button {
-    display: none !important;
-  }
+      .sidebar-contents-container {
+        border-color: var(--bs-border-color);
+        border-style: solid;
+        border-width : 1px 1px 1px 0;
+      }
 
-  .grw-contextual-navigation-child {
-    .grw-drawer-toggler {
-      @include bs.media-breakpoint-down(sm) {
-        position: fixed;
-        right: -15px;
-        bottom: 15px;
-        width: 42px;
-        height: 42px;
-        font-size: 18px;
-        transform: translateX(100%);
+      // open
+      .sidebar-contents-container.open {
+        position: absolute;
+        left: var.$grw-sidebar-nav-width;
+        min-height: 50vh;
+        max-height: calc(100vh - var.$grw-sidebar-nav-width * 2);
       }
     }
   }
 }
 
+// Drawer Mode
 .grw-sidebar {
   &:global {
     &.grw-sidebar-drawer {
-      @include drawer();
-    }
-    &.grw-sidebar-dock {
-      @include bs.media-breakpoint-down(sm) {
-        @include drawer();
+      position: fixed;
+      z-index: bs.$zindex-fixed + 2;
+      width: 348px;
+
+      // apply transition
+      transition-property: transform;
+      @include mixins.apply-navigation-transition();
+
+      &:not(.open) {
+        transform: translateX(-100%);
       }
-      @include bs.media-breakpoint-up(md) {
-        @include dock();
+      &.open {
+        transform: translateX(0);
       }
     }
   }
 }
 
 
-.grw-sidebar :global {
-  .grw-contextual-navigation {
-    backdrop-filter: blur(20px);
-  }
-}
 @include bs.color-mode(light) {
   .grw-sidebar :global {
     --bs-border-color: var(--grw-highlight-200);
 
-    .grw-contextual-navigation {
+    .sidebar-contents-container {
       background-color: rgba(var(--grw-highlight-100-rgb), .5);
     }
   }
@@ -236,7 +92,7 @@
     --bs-color: var(--bs-gray-400);
     --bs-border-color: var(--grw-highlight-800);
 
-    .grw-contextual-navigation {
+    .sidebar-contents-container {
       background-color: rgba(var(--grw-highlight-800-rgb), .5);
     }
   }

+ 136 - 286
apps/app/src/components/Sidebar/Sidebar.tsx

@@ -1,24 +1,24 @@
 import React, {
-  memo, useCallback, useEffect, useRef, useState,
+  type FC,
+  memo, useCallback, useEffect, useState,
 } from 'react';
 
 import dynamic from 'next/dynamic';
 
-import { useUserUISettings } from '~/client/services/user-ui-settings';
+import { scheduleToPut } from '~/client/services/user-ui-settings';
+import { SidebarMode } from '~/interfaces/ui';
 import {
-  useDrawerMode, useDrawerOpened,
-  useSidebarCollapsed,
-  useCurrentSidebarContents,
+  useDrawerOpened,
+  useCollapsedContentsOpened,
   useCurrentProductNavWidth,
-  useSidebarResizeDisabled,
-  useSidebarScrollerRef,
+  usePreferCollapsedMode,
+  useSidebarMode,
 } from '~/stores/ui';
 
-import DrawerToggler from '../Navbar/DrawerToggler';
-import { StickyStretchableScrollerProps } from '../StickyStretchableScroller';
-
-import { NavigationResizeHexagon } from './NavigationResizeHexagon';
-import { SidebarNav } from './SidebarNav';
+import { AppTitleOnSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
+import { ResizableArea } from './ResizableArea/ResizableArea';
+import { SidebarHead } from './SidebarHead';
+import { SidebarNav, type SidebarNavProps } from './SidebarNav';
 
 import styles from './Sidebar.module.scss';
 
@@ -26,328 +26,178 @@ import styles from './Sidebar.module.scss';
 const SidebarContents = dynamic(() => import('./SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
 
 
-const StickyStretchableScroller = dynamic<StickyStretchableScrollerProps>(() => import('../StickyStretchableScroller')
-  .then(mod => mod.StickyStretchableScroller), { ssr: false });
-
-const sidebarMinWidth = 240;
-const sidebarMinimizeWidth = 20;
-const sidebarFixedWidthInDrawerMode = 320;
-
-const GlobalNavigation = memo(() => {
-  const { data: isDrawerMode } = useDrawerMode();
-  const { data: currentContents } = useCurrentSidebarContents();
-  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
-
-  const { scheduleToPut } = useUserUISettings();
-
-  const itemSelectedHandler = useCallback((selectedContents) => {
-    if (isDrawerMode) {
-      return;
-    }
-
-    let newValue = false;
-
-    // already selected
-    if (currentContents === selectedContents) {
-      // toggle collapsed
-      newValue = !isCollapsed;
-    }
-
-    mutateSidebarCollapsed(newValue, false);
-    scheduleToPut({ isSidebarCollapsed: newValue });
-
-  }, [currentContents, isCollapsed, isDrawerMode, mutateSidebarCollapsed, scheduleToPut]);
-
-  return <SidebarNav onItemSelected={itemSelectedHandler} />;
-
-});
-GlobalNavigation.displayName = 'GlobalNavigation';
-
-const SidebarContentsWrapper = memo(() => {
-  const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
+const resizableAreaMinWidth = 348;
+const sidebarNavCollapsedWidth = 48;
 
-  const calcViewHeight = useCallback(() => {
-    const elem = document.querySelector('#grw-sidebar-contents-wrapper');
-    return elem != null
-      ? window.innerHeight - elem?.getBoundingClientRect().top
-      : window.innerHeight;
-  }, []);
 
-  return (
-    <>
-      <div id="grw-sidebar-contents-wrapper" style={{ minHeight: '100%' }}>
-        <StickyStretchableScroller
-          simplebarRef={mutateSidebarScroller}
-          stickyElemSelector=".grw-sidebar"
-          calcViewHeight={calcViewHeight}
-        >
-          <SidebarContents />
-        </StickyStretchableScroller>
-      </div>
-
-      <DrawerToggler iconClass="icon-arrow-left" />
-    </>
-  );
-});
-SidebarContentsWrapper.displayName = 'SidebarContentsWrapper';
+type ResizableContainerProps = {
+  children?: React.ReactNode,
+}
 
+const ResizableContainer = memo((props: ResizableContainerProps): JSX.Element => {
 
-export const Sidebar = memo((): JSX.Element => {
+  const { children } = props;
 
-  const { data: isDrawerMode } = useDrawerMode();
-  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { isDrawerMode, isCollapsedMode, isDockMode } = useSidebarMode();
+  const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
-  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
-  const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
+  const { mutate: mutatePreferCollapsedMode } = usePreferCollapsedMode();
+  const { mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
 
-  const { scheduleToPut } = useUserUISettings();
+  const [resizableAreaWidth, setResizableAreaWidth] = useState<number|undefined>(undefined);
 
-  const [isHover, setHover] = useState(false);
-  const [isHoverOnResizableContainer, setHoverOnResizableContainer] = useState(false);
-  const [isDragging, setDrag] = useState(false);
+  const resizeHandler = useCallback((newWidth: number) => {
+    setResizableAreaWidth(newWidth);
+  }, []);
 
-  const resizableContainer = useRef<HTMLDivElement>(null);
+  const resizeDoneHandler = useCallback((newWidth: number) => {
+    mutateProductNavWidth(newWidth, false);
+    scheduleToPut({ preferCollapsedModeByUser: false, currentProductNavWidth: newWidth });
+  }, [mutateProductNavWidth]);
 
-  const timeoutIdRef = useRef<NodeJS.Timeout>();
+  const collapsedByResizableAreaHandler = useCallback(() => {
+    mutatePreferCollapsedMode(true);
+    mutateCollapsedContentsOpened(false);
+    scheduleToPut({ preferCollapsedModeByUser: true });
+  }, [mutateCollapsedContentsOpened, mutatePreferCollapsedMode]);
 
-  const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
 
-  const toggleDrawerMode = useCallback((bool) => {
-    const isStateModified = isResizeDisabled !== bool;
-    if (!isStateModified) {
-      return;
+  // open/close resizable container when drawer mode
+  useEffect(() => {
+    if (isDrawerMode()) {
+      setResizableAreaWidth(undefined);
     }
-
-    // Drawer <-- Dock
-    if (bool) {
-      // disable resize
-      mutateSidebarResizeDisabled(true, false);
+    else if (isCollapsedMode()) {
+      setResizableAreaWidth(sidebarNavCollapsedWidth);
     }
-    // Drawer --> Dock
     else {
-      // enable resize
-      mutateSidebarResizeDisabled(false, false);
-    }
-  }, [isResizeDisabled, mutateSidebarResizeDisabled]);
-
-  const setContentWidth = useCallback((newWidth: number) => {
-    if (resizableContainer.current == null) {
-      return;
-    }
-    resizableContainer.current.style.width = `${newWidth}px`;
-  }, []);
-
-  const hoverOnHandler = useCallback(() => {
-    if (!isCollapsed || isDrawerMode || isDragging) {
-      return;
-    }
-
-    setHover(true);
-  }, [isCollapsed, isDragging, isDrawerMode]);
-
-  const hoverOutHandler = useCallback(() => {
-    if (!isCollapsed || isDrawerMode || isDragging) {
-      return;
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      setResizableAreaWidth(currentProductNavWidth!);
     }
 
-    setHover(false);
-  }, [isCollapsed, isDragging, isDrawerMode]);
+    mutateDrawerOpened(false);
+  }, [currentProductNavWidth, isCollapsedMode, isDrawerMode, mutateDrawerOpened]);
 
-  const hoverOnResizableContainerHandler = useCallback(() => {
-    if (!isCollapsed || isDrawerMode || isDragging) {
-      return;
-    }
+  return (
+    <ResizableArea
+      className="flex-expand-vert"
+      width={resizableAreaWidth}
+      minWidth={resizableAreaMinWidth}
+      disabled={!isDockMode()}
+      onResize={resizeHandler}
+      onResizeDone={resizeDoneHandler}
+      onCollapsed={collapsedByResizableAreaHandler}
+    >
+      {children}
+    </ResizableArea>
+  );
 
-    setHoverOnResizableContainer(true);
-  }, [isCollapsed, isDrawerMode, isDragging]);
+});
 
-  const hoverOutResizableContainerHandler = useCallback(() => {
-    if (!isCollapsed || isDrawerMode || isDragging) {
-      return;
-    }
 
-    setHoverOnResizableContainer(false);
-  }, [isCollapsed, isDrawerMode, isDragging]);
+type CollapsibleContainerProps = {
+  Nav: FC<SidebarNavProps>,
+  className?: string,
+  children?: React.ReactNode,
+}
 
-  const toggleNavigationBtnClickHandler = useCallback(() => {
-    const newValue = !isCollapsed;
-    mutateSidebarCollapsed(newValue, false);
-    scheduleToPut({ isSidebarCollapsed: newValue });
-  }, [isCollapsed, mutateSidebarCollapsed, scheduleToPut]);
+const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Element => {
 
-  useEffect(() => {
-    if (isCollapsed) {
-      setContentWidth(sidebarMinimizeWidth);
-    }
-    else {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      setContentWidth(currentProductNavWidth!);
-    }
-  }, [currentProductNavWidth, isCollapsed, setContentWidth]);
+  const { Nav, className, children } = props;
 
-  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
-    event.preventDefault();
+  const { isCollapsedMode } = useSidebarMode();
+  const { data: currentProductNavWidth } = useCurrentProductNavWidth();
+  const { data: isCollapsedContentsOpened, mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
 
-    const newWidth = event.pageX - 60;
-    if (resizableContainer.current != null) {
-      setContentWidth(newWidth);
-      resizableContainer.current.classList.add('dragging');
-    }
-  }, [setContentWidth]);
 
-  const dragableAreaMouseUpHandler = useCallback(() => {
-    if (resizableContainer.current == null) {
+  // open menu when collapsed mode
+  const primaryItemHoverHandler = useCallback(() => {
+    // reject other than collapsed mode
+    if (!isCollapsedMode()) {
       return;
     }
 
-    setDrag(false);
-
-    if (resizableContainer.current.clientWidth < sidebarMinWidth) {
-      // force collapsed
-      mutateSidebarCollapsed(true);
-      mutateProductNavWidth(sidebarMinWidth, false);
-      scheduleToPut({ isSidebarCollapsed: true, currentProductNavWidth: sidebarMinWidth });
-    }
-    else {
-      const newWidth = resizableContainer.current.clientWidth;
-      mutateSidebarCollapsed(false);
-      mutateProductNavWidth(newWidth, false);
-      scheduleToPut({ isSidebarCollapsed: false, currentProductNavWidth: newWidth });
-    }
-
-    resizableContainer.current.classList.remove('dragging');
-
-  }, [mutateProductNavWidth, mutateSidebarCollapsed, scheduleToPut]);
+    mutateCollapsedContentsOpened(true);
+  }, [isCollapsedMode, mutateCollapsedContentsOpened]);
 
-  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
-    if (!isResizableByDrag) {
+  // close menu when collapsed mode
+  const mouseLeaveHandler = useCallback(() => {
+    // reject other than collapsed mode
+    if (!isCollapsedMode()) {
       return;
     }
 
-    event.preventDefault();
+    mutateCollapsedContentsOpened(false);
+  }, [isCollapsedMode, mutateCollapsedContentsOpened]);
 
-    setDrag(true);
+  const openClass = `${isCollapsedContentsOpened ? 'open' : ''}`;
+  const collapsibleContentsWidth = isCollapsedMode() ? currentProductNavWidth : undefined;
 
-    const removeEventListeners = () => {
-      document.removeEventListener('mousemove', draggableAreaMoveHandler);
-      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
-      document.removeEventListener('mouseup', removeEventListeners);
-    };
+  return (
+    <div className={`flex-expand-horiz ${className}`} onMouseLeave={mouseLeaveHandler}>
+      <Nav onPrimaryItemHover={primaryItemHoverHandler} />
+      <div className={`sidebar-contents-container flex-grow-1 overflow-y-auto ${openClass}`} style={{ width: collapsibleContentsWidth }}>
+        {children}
+      </div>
+    </div>
+  );
 
-    document.addEventListener('mousemove', draggableAreaMoveHandler);
-    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
-    document.addEventListener('mouseup', removeEventListeners);
+});
 
-  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
 
-  useEffect(() => {
-    toggleDrawerMode(isDrawerMode);
-  }, [isDrawerMode, toggleDrawerMode]);
+type DrawableContainerProps = {
+  className?: string,
+  children?: React.ReactNode,
+}
 
-  // open/close resizable container
-  useEffect(() => {
-    if (!isCollapsed) {
-      return;
-    }
+const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
 
-    if (isHoverOnResizableContainer) {
-      // schedule to open
-      timeoutIdRef.current = setTimeout(() => {
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        setContentWidth(currentProductNavWidth!);
-      }, 70);
-    }
-    else if (timeoutIdRef.current != null) {
-      // cancel schedule to open
-      clearTimeout(timeoutIdRef.current);
-      timeoutIdRef.current = undefined;
-    }
+  const { className, children } = props;
 
-    // close
-    if (!isHover) {
-      setContentWidth(sidebarMinimizeWidth);
-      timeoutIdRef.current = undefined;
-    }
-  }, [isCollapsed, isHover, isHoverOnResizableContainer, currentProductNavWidth, setContentWidth]);
+  const { data: isDrawerOpened } = useDrawerOpened();
 
-  // open/close resizable container when drawer mode
-  useEffect(() => {
-    if (isDrawerMode) {
-      setContentWidth(sidebarFixedWidthInDrawerMode);
-    }
-    else if (isCollapsed) {
-      setContentWidth(sidebarMinimizeWidth);
-    }
-    else {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      setContentWidth(currentProductNavWidth!);
-    }
-  }, [currentProductNavWidth, isCollapsed, isDrawerMode, setContentWidth]);
+  const openClass = `${isDrawerOpened ? 'open' : ''}`;
+
+  return (
+    <div className={`${className} ${openClass}`}>
+      {children}
+    </div>
+  );
+});
 
 
-  const showContents = isDrawerMode || isHover || !isCollapsed;
+export const Sidebar = (): JSX.Element => {
 
+  const { data: sidebarMode, isCollapsedMode } = useSidebarMode();
 
   // css styles
-  const grwSidebarClass = `grw-sidebar ${styles['grw-sidebar']}`;
-  const sidebarModeClass = `${isDrawerMode ? 'grw-sidebar-drawer' : 'grw-sidebar-dock'}`;
-  const isOpenClass = `${isDrawerOpened ? 'open' : ''}`;
+  const grwSidebarClass = styles['grw-sidebar'];
+  // eslint-disable-next-line no-nested-ternary
+  let modeClass;
+  switch (sidebarMode) {
+    case SidebarMode.DRAWER:
+      modeClass = 'grw-sidebar-drawer';
+      break;
+    case SidebarMode.COLLAPSED:
+      modeClass = 'grw-sidebar-collapsed';
+      break;
+    case SidebarMode.DOCK:
+      modeClass = 'grw-sidebar-dock';
+      break;
+  }
+
   return (
     <>
-      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`} data-testid="grw-sidebar">
-        <div className="data-layout-container">
-          <div
-            className="navigation transition-enabled"
-            onMouseEnter={hoverOnHandler}
-            onMouseLeave={hoverOutHandler}
-          >
-            <div className="grw-navigation-wrap">
-              <div className="grw-global-navigation">
-                <GlobalNavigation></GlobalNavigation>
-              </div>
-              <div
-                ref={resizableContainer}
-                className="grw-contextual-navigation"
-                onMouseEnter={hoverOnResizableContainerHandler}
-                onMouseLeave={hoverOutResizableContainerHandler}
-                style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
-              >
-                <div className={`grw-contextual-navigation-child ${showContents ? '' : 'd-none'}`} data-testid="grw-contextual-navigation-child">
-                  <SidebarContents />
-                  <DrawerToggler iconClass="icon-arrow-left" />
-                </div>
-              </div>
-            </div>
-            <div className="grw-navigation-draggable">
-              { isResizableByDrag && (
-                <div
-                  className="grw-navigation-draggable-hitarea"
-                  onMouseDown={dragableAreaMouseDownHandler}
-                >
-                  <div className="grw-navigation-draggable-hitarea-child"></div>
-                </div>
-              ) }
-              <button
-                data-testid="grw-navigation-resize-button"
-                className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
-                type="button"
-                aria-expanded="true"
-                aria-label="Toggle navigation"
-                disabled={isDrawerMode}
-                onClick={toggleNavigationBtnClickHandler}
-              >
-                <span className="hexagon-container" role="presentation">
-                  <NavigationResizeHexagon />
-                </span>
-                <span className="hitarea" role="presentation"></span>
-              </button>
-            </div>
-          </div>
-        </div>
-      </div>
-
+      { sidebarMode != null && isCollapsedMode() && <AppTitleOnSubnavigation /> }
+      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end vh-100`} data-testid="grw-sidebar">
+        <ResizableContainer>
+          { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
+          <SidebarHead />
+          <CollapsibleContainer Nav={SidebarNav} className="border-top">
+            <SidebarContents />
+          </CollapsibleContainer>
+        </ResizableContainer>
+      </DrawableContainer>
     </>
   );
-
-});
-Sidebar.displayName = 'Sidebar';
+};

+ 23 - 20
apps/app/src/components/Sidebar/SidebarContents.tsx

@@ -1,7 +1,7 @@
-import React, { memo } from 'react';
+import React, { memo, useMemo } from 'react';
 
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentSidebarContents } from '~/stores/ui';
+import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode } from '~/stores/ui';
 
 
 import { Bookmarks } from './Bookmarks';
@@ -14,28 +14,31 @@ import styles from './SidebarContents.module.scss';
 
 
 export const SidebarContents = memo(() => {
+  const { isCollapsedMode } = useSidebarMode();
+  const { data: isCollapsedContentsOpened } = useCollapsedContentsOpened();
+
   const { data: currentSidebarContents } = useCurrentSidebarContents();
 
-  let Contents;
-  switch (currentSidebarContents) {
-    case SidebarContentsType.RECENT:
-      Contents = RecentChanges;
-      break;
-    case SidebarContentsType.CUSTOM:
-      Contents = CustomSidebar;
-      break;
-    case SidebarContentsType.TAG:
-      Contents = Tag;
-      break;
-    case SidebarContentsType.BOOKMARKS:
-      Contents = Bookmarks;
-      break;
-    default:
-      Contents = PageTree;
-  }
+  const Contents = useMemo(() => {
+    switch (currentSidebarContents) {
+      case SidebarContentsType.RECENT:
+        return RecentChanges;
+      case SidebarContentsType.CUSTOM:
+        return CustomSidebar;
+      case SidebarContentsType.TAG:
+        return Tag;
+      case SidebarContentsType.BOOKMARKS:
+        return Bookmarks;
+      default:
+        return PageTree;
+    }
+  }, [currentSidebarContents]);
+
+  const isHidden = isCollapsedMode() && !isCollapsedContentsOpened;
+  const classToHide = isHidden ? 'd-none' : '';
 
   return (
-    <div className={`grw-sidebar-contents ${styles['grw-sidebar-contents']}`}>
+    <div className={`grw-sidebar-contents ${styles['grw-sidebar-contents']} ${classToHide}`} data-testid="grw-sidebar-contents">
       <Contents />
     </div>
   );

+ 26 - 0
apps/app/src/components/Sidebar/SidebarHead/SidebarHead.module.scss

@@ -0,0 +1,26 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .grw-sidebar-head :global {
+    background-color: var(
+      --grw-sidebar-head-bg,
+      var(
+        --grw-sidebar-nav-bg,
+        var(--grw-highlight-100)
+      )
+    );
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-sidebar-head :global {
+    background-color: var(
+      --grw-sidebar-head-bg,
+      var(
+        --grw-sidebar-nav-bg,
+        var(--grw-highlight-800)
+      )
+    );
+  }
+}

+ 17 - 0
apps/app/src/components/Sidebar/SidebarHead/SidebarHead.tsx

@@ -0,0 +1,17 @@
+import React, {
+  type FC, memo,
+} from 'react';
+
+import { ToggleCollapseButton } from './ToggleCollapseButton';
+
+import styles from './SidebarHead.module.scss';
+
+
+export const SidebarHead: FC = memo(() => {
+  return (
+    <div className={`${styles['grw-sidebar-head']} d-flex justify-content-end w-100`}>
+      <ToggleCollapseButton />
+    </div>
+  );
+
+});

+ 52 - 0
apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.module.scss

@@ -0,0 +1,52 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '../button-styles';
+@use '../variables' as var;
+
+.btn-toggle-collapse :global {
+  @extend %btn-primary-basis;
+
+  $height: var.$grw-sidebar-nav-width; // declare $height with the same value as the sidebar nav width
+  height: $height;
+}
+
+// icon
+.btn-toggle-collapse :global {
+  .material-icons {
+    transition: transform 0.25s;
+
+    // rotation
+    &.rotate180 {
+      transform: rotate(180deg);
+    }
+  }
+}
+
+// == Colors
+.btn-toggle-collapse {
+  &:global {
+    &.btn.btn-primary {
+      @extend %btn-primary-color-vars;
+    }
+  }
+}
+@include bs.color-mode(light) {
+  .btn-toggle-collapse {
+    &:global {
+      &.btn.btn-primary {
+        --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--bs-gray-500));
+        --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-300));
+      }
+    }
+  }
+}
+@include bs.color-mode(dark) {
+  .btn-toggle-collapse {
+    &:global {
+      &.btn.btn-primary {
+        --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--bs-gray-600));
+        --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-700));
+      }
+    }
+  }
+}

+ 41 - 0
apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx

@@ -0,0 +1,41 @@
+import { memo, useCallback } from 'react';
+
+import {
+  useCollapsedContentsOpened, usePreferCollapsedMode, useDrawerOpened, useSidebarMode,
+} from '~/stores/ui';
+
+
+import styles from './ToggleCollapseButton.module.scss';
+
+
+export const ToggleCollapseButton = memo((): JSX.Element => {
+
+  const { isDrawerMode, isCollapsedMode, isDockMode } = useSidebarMode();
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { mutate: mutatePreferCollapsedMode } = usePreferCollapsedMode();
+  const { mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
+
+  const toggleDrawer = useCallback(() => {
+    mutateDrawerOpened(!isDrawerOpened);
+  }, [isDrawerOpened, mutateDrawerOpened]);
+
+  const toggleCollapsed = useCallback(() => {
+    mutatePreferCollapsedMode(!isCollapsedMode());
+    mutateCollapsedContentsOpened(false);
+  }, [isCollapsedMode, mutateCollapsedContentsOpened, mutatePreferCollapsedMode]);
+
+  const rotationClass = isCollapsedMode() ? 'rotate180' : '';
+  const icon = isDrawerMode() || isDockMode()
+    ? 'first_page'
+    : 'keyboard_double_arrow_left';
+
+  return (
+    <button
+      type="button"
+      className={`btn btn-primary ${styles['btn-toggle-collapse']} p-2`}
+      onClick={isDrawerMode() ? toggleDrawer : toggleCollapsed}
+    >
+      <span className={`material-icons fs-2 ${rotationClass}`}>{icon}</span>
+    </button>
+  );
+});

+ 1 - 0
apps/app/src/components/Sidebar/SidebarHead/index.ts

@@ -0,0 +1 @@
+export * from './SidebarHead';

+ 0 - 134
apps/app/src/components/Sidebar/SidebarNav.module.scss

@@ -1,134 +0,0 @@
-@use '@growi/core/scss/bootstrap/init' as bs;
-
-@use '~/styles/variables' as var;
-
-.grw-sidebar-nav :global {
-  // set position and z-index to prevent dropdowns covered by other element
-  position: relative;
-  z-index: bs.$zindex-fixed;
-
-  height: 100vh;
-
-  border-right : 1px solid var(--bs-border-color);
-
-  .grw-logo {
-    svg {
-      width: var.$grw-logo-width;
-      height: var.$grw-logo-width;
-      padding: (var.$grw-logo-width - var.$grw-logomark-width) / 2;
-    }
-  }
-
-  .grw-apperance-mode-dropdown,
-  .grw-personal-dropdown {
-    .dropdown-menu {
-      min-width: 15rem;
-
-      .grw-icon-container svg {
-        width: 18px;
-        height: 18px;
-      }
-    }
-  }
-
-  .btn {
-    width: var.$grw-sidebar-nav-width;
-    height: var.$grw-sidebar-nav-height;
-    padding-top: .75rem;
-    padding-bottom: .75rem;
-    line-height: 1em;
-    border: 0;
-    border-radius: 0;
-    box-shadow: none !important;
-
-    // icon opacity
-    &:not(.active) {
-      i {
-        opacity: 0.7;
-      }
-      &:hover,
-      &:focus {
-        i {
-          opacity: 0.8;
-        }
-      }
-    }
-  }
-
-  .grw-sidebar-nav-primary-container {
-    $btn-active-indicator-height: 34px;
-
-    .btn {
-      &.active {
-        position: relative;
-
-        // indicator
-        &:after {
-          position: absolute;
-          top: 0;
-          left: 0;
-          display: block;
-          width: 3px;
-          height: $btn-active-indicator-height;
-          content: '';
-          background-color: var(--bs-primary);
-          transform: translateY(#{(var.$grw-sidebar-nav-height - $btn-active-indicator-height) / 2});
-        }
-      }
-    }
-  }
-
-  .grw-sidebar-nav-secondary-container {
-    position: fixed;
-    bottom: 1.5rem;
-
-    .btn {
-      i {
-        opacity: 0.4;
-      }
-    }
-  }
-}
-
-
-// == Colors
-.grw-sidebar-nav :global {
-  .btn.btn-primary {
-    --bs-btn-bg: transparent;
-    --bs-btn-active-bg: transparent;
-    --bs-btn-hover-color: var(
-      --grw-sidebar-nav-btn-hover-color,
-      var(
-        --grw-sidebar-nav-btn-color,
-        var(--bs-btn-color)
-      ),
-    );
-    --bs-btn-active-color: var(
-      --grw-sidebar-nav-btn-active-color,
-      var(
-        --grw-sidebar-nav-btn-color,
-        var(--bs-btn-color)
-      ),
-    );
-  }
-}
-@include bs.color-mode(light) {
-  .grw-sidebar-nav :global {
-    background-color: var(--grw-sidebar-nav-bg, var(--grw-highlight-100));
-
-    .btn-primary {
-      --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--grw-primary-500));
-      --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-300));
-    }
-  }
-}
-@include bs.color-mode(dark) {
-  .grw-sidebar-nav :global {
-    background-color: var(--grw-sidebar-nav-bg, var(--grw-highlight-800));
-
-    .btn-primary {
-      --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--grw-primary-400));
-      --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-700));
-    }
-  }
-}

+ 0 - 136
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -1,136 +0,0 @@
-import React, {
-  FC, memo, useCallback,
-} from 'react';
-
-import dynamic from 'next/dynamic';
-import Link from 'next/link';
-
-import { useUserUISettings } from '~/client/services/user-ui-settings';
-import { SidebarContentsType } from '~/interfaces/ui';
-import {
-  useIsAdmin, useGrowiCloudUri, useIsDefaultLogo, useIsGuestUser,
-} from '~/stores/context';
-import { useCurrentSidebarContents } from '~/stores/ui';
-
-import DrawerToggler from '../Navbar/DrawerToggler';
-
-import { SidebarBrandLogo } from './SidebarBrandLogo';
-
-import styles from './SidebarNav.module.scss';
-
-
-const PersonalDropdown = dynamic(() => import('./PersonalDropdown').then(mod => mod.PersonalDropdown), { ssr: false });
-const InAppNotificationDropdown = dynamic(() => import('../InAppNotification/InAppNotificationDropdown')
-  .then(mod => mod.InAppNotificationDropdown), { ssr: false });
-const AppearanceModeDropdown = dynamic(() => import('./AppearanceModeDropdown').then(mod => mod.AppearanceModeDropdown), { ssr: false });
-
-
-type PrimaryItemProps = {
-  contents: SidebarContentsType,
-  label: string,
-  iconName: string,
-  onItemSelected: (contents: SidebarContentsType) => void,
-}
-
-const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
-  const {
-    contents, label, iconName, onItemSelected,
-  } = props;
-
-  const { data: currentContents, mutate } = useCurrentSidebarContents();
-  const { scheduleToPut } = useUserUISettings();
-
-  const isSelected = contents === currentContents;
-
-  const itemSelectedHandler = useCallback(() => {
-    if (onItemSelected != null) {
-      onItemSelected(contents);
-    }
-
-    mutate(contents, false);
-
-    scheduleToPut({ currentSidebarContents: contents });
-  }, [contents, mutate, onItemSelected, scheduleToPut]);
-
-  const labelForTestId = label.toLowerCase().replace(' ', '-');
-
-  return (
-    <button
-      type="button"
-      data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
-      className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
-      onClick={itemSelectedHandler}
-    >
-      <i className="material-icons">{iconName}</i>
-    </button>
-  );
-};
-
-type SecondaryItemProps = {
-  label: string,
-  href: string,
-  iconName: string,
-  isBlank?: boolean,
-}
-
-const SecondaryItem: FC<SecondaryItemProps> = memo((props: SecondaryItemProps) => {
-  const { iconName, href, isBlank } = props;
-
-  return (
-    <Link
-      href={href}
-      className="d-block btn btn-primary"
-      target={`${isBlank ? '_blank' : ''}`}
-      prefetch={false}
-    >
-      <i className="material-icons">{iconName}</i>
-    </Link>
-  );
-});
-SecondaryItem.displayName = 'SecondaryItem';
-
-
-type Props = {
-  onItemSelected: (contents: SidebarContentsType) => void,
-}
-
-export const SidebarNav: FC<Props> = (props: Props) => {
-  const { data: isAdmin } = useIsAdmin();
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: growiCloudUri } = useGrowiCloudUri();
-  const { data: isDefaultLogo } = useIsDefaultLogo();
-
-  const { onItemSelected } = props;
-
-  const isAuthenticated = isGuestUser === false;
-
-  return (
-    <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`}>
-      {/* Brand Logo  */}
-      <div className="navbar-brand">
-        <Link href="/" className="grw-logo d-block">
-          <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
-        </Link>
-        <DrawerToggler />
-      </div>
-
-      <div className="grw-sidebar-nav-primary-container" data-vrt-blackout-sidebar-nav>
-        <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />
-        <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />
-        <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />
-        <PrimaryItem contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmark" onItemSelected={onItemSelected} />
-        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onItemSelected={onItemSelected} />
-        <InAppNotificationDropdown />
-      </div>
-      <div className="grw-sidebar-nav-secondary-container">
-        {/* TODO: This setting will be consolidated in "Settings" on My Page, so delete it from here. */}
-        {/* <AppearanceModeDropdown isAuthenticated={isAuthenticated} /> */}
-        <PersonalDropdown />
-        <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
-        {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-        <SecondaryItem label="Trash" href="/trash" iconName="delete" />
-      </div>
-    </div>
-  );
-
-};

+ 71 - 0
apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.module.scss

@@ -0,0 +1,71 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '../button-styles';
+@use '../variables' as var;
+
+@use './variables' as sidebarNavVar;
+
+.grw-primary-items :global {
+  .btn {
+    @extend %btn-primary-basis;
+
+    height: sidebarNavVar.$grw-sidebar-primary-button-height;
+
+    i {
+      opacity: 0.7;
+
+      &:hover,
+      &:focus {
+        opacity: 0.8;
+      }
+    }
+  }
+}
+
+// Add indicator
+.grw-primary-items :global {
+  $btn-height: sidebarNavVar.$grw-sidebar-primary-button-height;
+  $btn-active-indicator-height: 34px;
+
+  .btn {
+    &.active {
+      position: relative;
+
+      // indicator
+      &:after {
+        position: absolute;
+        top: 0;
+        left: 0;
+        display: block;
+        width: 3px;
+        height: $btn-active-indicator-height;
+        content: '';
+        background-color: var(--bs-primary);
+        transform: translateY(#{($btn-height - $btn-active-indicator-height) / 2});
+      }
+    }
+  }
+}
+
+// == Colors
+.grw-primary-items :global {
+  .btn.btn-primary {
+    @extend %btn-primary-color-vars;
+  }
+}
+@include bs.color-mode(light) {
+  .grw-primary-items :global {
+    .btn-primary {
+      --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--grw-primary-600));
+      --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-300));
+    }
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-primary-items :global {
+    .btn-primary {
+      --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--grw-primary-300));
+      --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-700));
+    }
+  }
+}

+ 112 - 0
apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx

@@ -0,0 +1,112 @@
+import { FC, memo, useCallback } from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { scheduleToPut } from '~/client/services/user-ui-settings';
+import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
+import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode } from '~/stores/ui';
+
+import styles from './PrimaryItems.module.scss';
+
+
+const InAppNotificationDropdown = dynamic(() => import('../../InAppNotification/InAppNotificationDropdown')
+  .then(mod => mod.InAppNotificationDropdown), { ssr: false });
+
+
+/**
+ * @returns String for className to switch the indicator is active or not
+ */
+const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
+  const { data: isCollapsedContentsOpened } = useCollapsedContentsOpened();
+
+  if (sidebarMode === SidebarMode.COLLAPSED && !isCollapsedContentsOpened) {
+    return '';
+  }
+
+  return isSelected ? 'active' : '';
+};
+
+
+type PrimaryItemProps = {
+  contents: SidebarContentsType,
+  label: string,
+  iconName: string,
+  sidebarMode: SidebarMode,
+  onHover?: (contents: SidebarContentsType) => void,
+}
+
+const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
+  const {
+    contents, label, iconName, sidebarMode,
+    onHover,
+  } = props;
+
+  const { data: currentContents, mutate: mutateContents } = useCurrentSidebarContents();
+
+  const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
+
+  const selectThisItem = useCallback(() => {
+    mutateContents(contents, false);
+    scheduleToPut({ currentSidebarContents: contents });
+  }, [contents, mutateContents]);
+
+  const itemClickedHandler = useCallback(() => {
+    // do nothing ONLY WHEN the collapse mode
+    if (sidebarMode === SidebarMode.COLLAPSED) {
+      return;
+    }
+
+    selectThisItem();
+  }, [selectThisItem, sidebarMode]);
+
+  const mouseEnteredHandler = useCallback(() => {
+    // ignore other than collapsed mode
+    if (sidebarMode !== SidebarMode.COLLAPSED) {
+      return;
+    }
+
+    selectThisItem();
+    onHover?.(contents);
+  }, [contents, onHover, selectThisItem, sidebarMode]);
+
+
+  const labelForTestId = label.toLowerCase().replace(' ', '-');
+
+  return (
+    <button
+      type="button"
+      data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
+      className={`d-block btn btn-primary ${indicatorClass}`}
+      onClick={itemClickedHandler}
+      onMouseEnter={mouseEnteredHandler}
+    >
+      <i className="material-icons">{iconName}</i>
+    </button>
+  );
+};
+
+
+type Props = {
+  onItemHover?: (contents: SidebarContentsType) => void,
+}
+
+export const PrimaryItems = memo((props: Props) => {
+  const { onItemHover } = props;
+
+  const { data: sidebarMode } = useSidebarMode();
+
+  if (sidebarMode == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={styles['grw-primary-items']}>
+      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onHover={onItemHover} />
+      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onHover={onItemHover} />
+      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onHover={onItemHover} />
+      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmark" onHover={onItemHover} />
+      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
+      <InAppNotificationDropdown />
+    </div>
+  );
+});

+ 45 - 0
apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.module.scss

@@ -0,0 +1,45 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '../button-styles';
+
+@use './variables' as sidebarNavVar;
+
+.grw-secondary-items :global {
+  .btn {
+    @extend %btn-primary-basis;
+
+    height: sidebarNavVar.$grw-sidebar-primary-button-height;
+
+    i {
+      opacity: 0.6;
+
+      &:hover,
+      &:focus {
+        opacity: 0.8;
+      }
+    }
+  }
+}
+
+// == Colors
+.grw-secondary-items :global {
+  .btn.btn-primary {
+    @extend %btn-primary-color-vars;
+  }
+}
+@include bs.color-mode(light) {
+  .grw-secondary-items :global {
+    .btn-primary {
+      --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--grw-primary-600));
+      --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-700));
+    }
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-secondary-items :global {
+    .btn-primary {
+      --bs-btn-color: var(--grw-sidebar-nav-btn-color, var(--grw-primary-500));
+      --bs-btn-hover-bg: var(--grw-sidebar-nav-btn-hover-bg, var(--grw-highlight-700));
+    }
+  }
+}

+ 49 - 0
apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -0,0 +1,49 @@
+import { FC, memo } from 'react';
+
+import dynamic from 'next/dynamic';
+import Link from 'next/link';
+
+import { useGrowiCloudUri, useIsAdmin } from '~/stores/context';
+
+import styles from './SecondaryItems.module.scss';
+
+
+const PersonalDropdown = dynamic(() => import('../PersonalDropdown').then(mod => mod.PersonalDropdown), { ssr: false });
+
+
+type SecondaryItemProps = {
+  label: string,
+  href: string,
+  iconName: string,
+  isBlank?: boolean,
+}
+
+const SecondaryItem: FC<SecondaryItemProps> = (props: SecondaryItemProps) => {
+  const { iconName, href, isBlank } = props;
+
+  return (
+    <Link
+      href={href}
+      className="d-block btn btn-primary"
+      target={`${isBlank ? '_blank' : ''}`}
+      prefetch={false}
+    >
+      <i className="material-icons">{iconName}</i>
+    </Link>
+  );
+};
+
+export const SecondaryItems: FC = memo(() => {
+
+  const { data: isAdmin } = useIsAdmin();
+  const { data: growiCloudUri } = useGrowiCloudUri();
+
+  return (
+    <div className={styles['grw-secondary-items']}>
+      <PersonalDropdown />
+      <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
+      {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
+      <SecondaryItem label="Trash" href="/trash" iconName="delete" />
+    </div>
+  );
+});

+ 33 - 0
apps/app/src/components/Sidebar/SidebarNav/SidebarNav.module.scss

@@ -0,0 +1,33 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '../variables' as var;
+
+.grw-sidebar-nav :global {
+  // set position and z-index to prevent dropdowns covered by other element
+  position: relative;
+  z-index: bs.$zindex-fixed;
+
+  width: var.$grw-sidebar-nav-width;
+
+  border-right : 1px solid var(--bs-border-color);
+
+  .grw-sidebar-nav-secondary-container {
+    position: fixed;
+    bottom: 1.5rem;
+  }
+}
+
+
+// == Colors
+.grw-sidebar-nav :global {
+}
+@include bs.color-mode(light) {
+  .grw-sidebar-nav :global {
+    background-color: var(--grw-sidebar-nav-bg, var(--grw-highlight-100));
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-sidebar-nav :global {
+    background-color: var(--grw-sidebar-nav-bg, var(--grw-highlight-800));
+  }
+}

+ 31 - 0
apps/app/src/components/Sidebar/SidebarNav/SidebarNav.tsx

@@ -0,0 +1,31 @@
+import React, { memo } from 'react';
+
+import { SidebarContentsType } from '~/interfaces/ui';
+
+import { PageCreateButton } from '../PageCreateButton';
+
+import { PrimaryItems } from './PrimaryItems';
+import { SecondaryItems } from './SecondaryItems';
+
+import styles from './SidebarNav.module.scss';
+
+export type SidebarNavProps = {
+  onPrimaryItemHover?: (contents: SidebarContentsType) => void,
+}
+
+export const SidebarNav = memo((props: SidebarNavProps) => {
+  const { onPrimaryItemHover } = props;
+
+  return (
+    <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`}>
+      <PageCreateButton />
+
+      <div className="grw-sidebar-nav-primary-container" data-vrt-blackout-sidebar-nav>
+        <PrimaryItems onItemHover={onPrimaryItemHover} />
+      </div>
+      <div className="grw-sidebar-nav-secondary-container">
+        <SecondaryItems />
+      </div>
+    </div>
+  );
+});

+ 1 - 0
apps/app/src/components/Sidebar/SidebarNav/_variables.scss

@@ -0,0 +1 @@
+$grw-sidebar-primary-button-height: 50px;

+ 1 - 0
apps/app/src/components/Sidebar/SidebarNav/index.ts

@@ -0,0 +1 @@
+export * from './SidebarNav';

+ 29 - 0
apps/app/src/components/Sidebar/_button-styles.scss

@@ -0,0 +1,29 @@
+@use './variables' as var;
+
+%btn-primary-basis {
+  padding-top: .75rem;
+  padding-bottom: .75rem;
+  line-height: 1em;
+  border: 0;
+  border-radius: 0;
+  box-shadow: none !important;
+}
+
+%btn-primary-color-vars {
+  --bs-btn-bg: transparent;
+  --bs-btn-active-bg: transparent;
+  --bs-btn-hover-color: var(
+    --grw-sidebar-nav-btn-hover-color,
+    var(
+      --grw-sidebar-nav-btn-color,
+      var(--bs-btn-color)
+    ),
+  );
+  --bs-btn-active-color: var(
+    --grw-sidebar-nav-btn-active-color,
+    var(
+      --grw-sidebar-nav-btn-color,
+      var(--bs-btn-color)
+    ),
+  );
+}

+ 1 - 0
apps/app/src/components/Sidebar/_variables.scss

@@ -0,0 +1 @@
+$grw-sidebar-nav-width: 48px;

+ 0 - 3
apps/app/src/components/TreeItem/NewPageCreateButton.tsx

@@ -48,9 +48,6 @@ export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
     }
   }, [hasDescendants, setIsOpen]);
 
-  const test = pagePathUtils;
-  console.dir(test);
-
   return (
     <>
       {!pagePathUtils.isUsersTopPage(page.path ?? '') && (

+ 1 - 2
apps/app/src/interfaces/sidebar-config.ts

@@ -1,5 +1,4 @@
 
 export interface ISidebarConfig {
-  isSidebarDrawerMode: boolean,
-  isSidebarClosedAtDockMode: boolean
+  isSidebarCollapsedMode: boolean,
 }

+ 8 - 0
apps/app/src/interfaces/ui.ts

@@ -1,5 +1,13 @@
 import type { Nullable } from '@growi/core';
 
+
+export const SidebarMode = {
+  DRAWER: 'drawer',
+  COLLAPSED: 'collapsed',
+  DOCK: 'dock',
+} as const;
+export type SidebarMode = typeof SidebarMode[keyof typeof SidebarMode];
+
 export const SidebarContentsType = {
   CUSTOM: 'custom',
   RECENT: 'recent',

+ 1 - 3
apps/app/src/interfaces/user-ui-settings.ts

@@ -1,9 +1,7 @@
 import { SidebarContentsType } from './ui';
 
 export interface IUserUISettings {
-  isSidebarCollapsed: boolean,
   currentSidebarContents: SidebarContentsType,
   currentProductNavWidth: number,
-  preferDrawerModeByUser: boolean,
-  preferDrawerModeOnEditByUser: boolean,
+  preferCollapsedModeByUser: boolean,
 }

+ 1 - 2
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -89,8 +89,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isEnabledMarp = configManager.getConfig('crowi', 'customize:isEnabledMarp');
 
   props.sidebarConfig = {
-    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
   };
 
   props.rendererConfig = {

+ 1 - 2
apps/app/src/pages/_search.page.tsx

@@ -113,8 +113,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
 
   props.sidebarConfig = {
-    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
   };
 
   props.rendererConfig = {

+ 1 - 2
apps/app/src/pages/me/[[...path]].page.tsx

@@ -158,8 +158,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.showPageLimitationXL = crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL');
 
   props.sidebarConfig = {
-    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
   };
 
   props.rendererConfig = {

+ 1 - 2
apps/app/src/pages/tags.page.tsx

@@ -134,8 +134,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
   props.sidebarConfig = {
-    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
   };
 
 }

+ 1 - 2
apps/app/src/pages/trash.page.tsx

@@ -114,8 +114,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.showPageLimitationXL = crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL');
 
   props.sidebarConfig = {
-    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+    isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
   };
 
 }

+ 3 - 6
apps/app/src/pages/utils/commons.ts

@@ -14,7 +14,7 @@ import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { PageDocument } from '~/server/models/page';
 import type { UserUISettingsDocument } from '~/server/models/user-ui-settings';
 import {
-  useCurrentProductNavWidth, useCurrentSidebarContents, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
+  useCurrentProductNavWidth, useCurrentSidebarContents, usePreferCollapsedMode,
 } from '~/stores/ui';
 
 export type CommonProps = {
@@ -100,8 +100,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     forcedColorScheme,
     growiCloudUri: configManager.getConfig('crowi', 'app:growiCloudUri'),
     sidebarConfig: {
-      isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
-      isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+      isSidebarCollapsedMode: configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
     },
     userUISettings: userUISettings?.toObject?.() ?? userUISettings,
   };
@@ -166,9 +165,7 @@ export const generateCustomTitleForPage = (props: CommonProps, pagePath: string)
 
 export const useInitSidebarConfig = (sidebarConfig: ISidebarConfig, userUISettings?: IUserUISettings): void => {
   // UserUISettings
-  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? sidebarConfig.isSidebarDrawerMode);
-  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
-  useSidebarCollapsed(userUISettings?.isSidebarCollapsed ?? sidebarConfig.isSidebarClosedAtDockMode);
+  usePreferCollapsedMode(userUISettings?.preferCollapsedModeByUser ?? sidebarConfig.isSidebarCollapsedMode);
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 };

+ 1 - 2
apps/app/src/server/models/config.ts

@@ -133,8 +133,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:isAllReplyShown': false,
   'customize:isSearchScopeChildrenAsDefault': false,
   'customize:isEnabledMarp': false,
-  'customize:isSidebarDrawerMode': false,
-  'customize:isSidebarClosedAtDockMode': false,
+  'customize:isSidebarCollapsedMode': false,
 
   'notification:owner-page:isEnabled': false,
   'notification:group-page:isEnabled': false,

+ 1 - 3
apps/app/src/server/models/user-ui-settings.ts

@@ -17,15 +17,13 @@ export type UserUISettingsModel = Model<UserUISettingsDocument>
 
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
   user: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
-  isSidebarCollapsed: { type: Boolean, default: false },
   currentSidebarContents: {
     type: String,
     enum: SidebarContentsType,
     default: SidebarContentsType.RECENT,
   },
   currentProductNavWidth: { type: Number },
-  preferDrawerModeByUser: { type: Boolean, default: false },
-  preferDrawerModeOnEditByUser: { type: Boolean, default: true },
+  preferCollapsedModeByUser: { type: Boolean, default: false },
 });
 
 

+ 5 - 9
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -112,8 +112,7 @@ module.exports = (crowi) => {
       body('theme').isString(),
     ],
     sidebar: [
-      body('isSidebarDrawerMode').isBoolean(),
-      body('isSidebarClosedAtDockMode').isBoolean(),
+      body('isSidebarCollapsedMode').isBoolean(),
     ],
     function: [
       body('isEnabledTimeline').isBoolean(),
@@ -342,9 +341,8 @@ module.exports = (crowi) => {
   router.get('/sidebar', loginRequiredStrictly, adminRequired, async(req, res) => {
 
     try {
-      const isSidebarDrawerMode = await crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode');
-      const isSidebarClosedAtDockMode = await crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode');
-      return res.apiv3({ isSidebarDrawerMode, isSidebarClosedAtDockMode });
+      const isSidebarCollapsedMode = await crowi.configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode');
+      return res.apiv3({ isSidebarCollapsedMode });
     }
     catch (err) {
       const msg = 'Error occurred in getting sidebar';
@@ -355,15 +353,13 @@ module.exports = (crowi) => {
 
   router.put('/sidebar', loginRequiredStrictly, adminRequired, validator.sidebar, apiV3FormValidator, addActivity, async(req, res) => {
     const requestParams = {
-      'customize:isSidebarDrawerMode': req.body.isSidebarDrawerMode,
-      'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
+      'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
     };
 
     try {
       await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
       const customizedParams = {
-        isSidebarDrawerMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
-        isSidebarClosedAtDockMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+        isSidebarCollapsedMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarCollapsedMode'),
       };
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE });

+ 2 - 6
apps/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -16,11 +16,9 @@ module.exports = (crowi) => {
 
   const validatorForPut = [
     body('settings').exists().withMessage('The body param \'settings\' is required'),
-    body('settings.isSidebarCollapsed').optional().isBoolean(),
     body('settings.currentSidebarContents').optional().isIn(AllSidebarContentsType),
     body('settings.currentProductNavWidth').optional().isNumeric(),
-    body('settings.preferDrawerModeByUser').optional().isBoolean(),
-    body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
+    body('settings.preferCollapsedModeByUser').optional().isBoolean(),
   ];
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -30,11 +28,9 @@ module.exports = (crowi) => {
 
     // extract only necessary params
     const updateData = {
-      isSidebarCollapsed: settings.isSidebarCollapsed,
       currentSidebarContents: settings.currentSidebarContents,
       currentProductNavWidth: settings.currentProductNavWidth,
-      preferDrawerModeByUser: settings.preferDrawerModeByUser,
-      preferDrawerModeOnEditByUser: settings.preferDrawerModeOnEditByUser,
+      preferCollapsedModeByUser: settings.preferCollapsedModeByUser,
     };
 
     if (user == null) {

+ 2 - 2
apps/app/src/server/service/page.ts

@@ -3637,7 +3637,7 @@ class PageService {
     const shouldValidateGrant = !isGrantRestricted;
     const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user, options);
     if (!canProcessCreate) {
-      throw Error('Cannnot process create');
+      throw Error('Cannot process create');
     }
 
     // Prepare a page document
@@ -3828,7 +3828,7 @@ class PageService {
     }
     const canProcessForceCreateBySystem = await this.canProcessForceCreateBySystem(path, grantData);
     if (!canProcessForceCreateBySystem) {
-      throw Error('Cannnot process forceCreateBySystem');
+      throw Error('Cannot process forceCreateBySystem');
     }
 
     // Prepare a page document

+ 1 - 1
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -939,7 +939,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const { queryString, terms } = data;
 
     if (terms == null) {
-      throw Error('Cannnot process search since terms is undefined.');
+      throw Error('Cannot process search since terms is undefined.');
     }
 
     const from = option?.offset ?? null;

+ 52 - 0
apps/app/src/stores/admin/sidebar-config.tsx

@@ -0,0 +1,52 @@
+import type { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import type { ISidebarConfig } from '~/interfaces/sidebar-config';
+
+type SidebarConfigOption = {
+  update: () => Promise<void>,
+  isSidebarCollapsedMode: boolean|undefined,
+  setIsSidebarCollapsedMode: (isSidebarCollapsedMode: boolean) => void,
+}
+
+export const useSWRxSidebarConfig = (): SWRResponse<ISidebarConfig, Error> & SidebarConfigOption => {
+  const swrResponse = useSWRImmutable<ISidebarConfig>(
+    '/customize-setting/sidebar',
+    endpoint => apiv3Get<ISidebarConfig>(endpoint).then(result => result.data),
+  );
+  return {
+    ...swrResponse,
+    update: async() => {
+      const { data } = swrResponse;
+
+      if (data == null) {
+        return;
+      }
+
+      const { isSidebarCollapsedMode } = data;
+
+      const updateData = {
+        isSidebarCollapsedMode,
+      };
+
+      // invoke API
+      await apiv3Put('/customize-setting/sidebar', updateData);
+    },
+    isSidebarCollapsedMode: swrResponse.data?.isSidebarCollapsedMode,
+    setIsSidebarCollapsedMode: (isSidebarCollapsedMode) => {
+      const { data, mutate } = swrResponse;
+
+      if (data == null) {
+        return;
+      }
+
+      const updateData = {
+        isSidebarCollapsedMode,
+      };
+
+      // update isSidebarCollapsedMode in cache, not revalidate
+      mutate({ ...data, ...updateData }, false);
+    },
+  };
+};

+ 58 - 119
apps/app/src/stores/ui.tsx

@@ -1,7 +1,9 @@
-import { type RefObject, useCallback, useEffect } from 'react';
+import {
+  type RefObject, useCallback, useEffect,
+} from 'react';
 
 import { PageGrant, type Nullable } from '@growi/core';
-import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
+import { type SWRResponseWithUtils, useSWRStatic } from '@growi/core/dist/swr';
 import { pagePathUtils, isClient, isServer } from '@growi/core/dist/utils';
 import { Breakpoint } from '@growi/ui/dist/interfaces';
 import { addBreakpointListener, cleanupBreakpointListener } from '@growi/ui/dist/utils';
@@ -13,11 +15,8 @@ import {
 import useSWRImmutable from 'swr/immutable';
 
 import type { IFocusable } from '~/client/interfaces/focusable';
-import { useUserUISettings } from '~/client/services/user-ui-settings';
-import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import type { IPageGrantData } from '~/interfaces/page';
-import type { ISidebarConfig } from '~/interfaces/sidebar-config';
-import { SidebarContentsType } from '~/interfaces/ui';
+import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
 import type { UpdateDescCountData } from '~/interfaces/websocket';
 import {
   useIsNotFound, useCurrentPagePath, useIsTrashPage, useCurrentPageId,
@@ -132,15 +131,17 @@ type EditorModeUtils = {
 
 export const useEditorMode = (): SWRResponseWithUtils<EditorModeUtils, EditorMode> => {
   const { data: _isEditable } = useIsEditable();
+  const { data: isNotFound } = useIsNotFound();
 
   const editorModeByHash = determineEditorModeByHash();
 
   const isLoading = _isEditable === undefined;
   const isEditable = !isLoading && _isEditable;
-  const initialData = isEditable ? editorModeByHash : EditorMode.View;
+  const preventModeEditor = !isEditable || isNotFound === undefined || isNotFound === true;
+  const initialData = preventModeEditor ? EditorMode.View : editorModeByHash;
 
   const swrResponse = useSWRImmutable(
-    isLoading ? null : ['editorMode', isEditable],
+    isLoading ? null : ['editorMode', isEditable, preventModeEditor],
     null,
     { fallbackData: initialData },
   );
@@ -148,12 +149,12 @@ export const useEditorMode = (): SWRResponseWithUtils<EditorModeUtils, EditorMod
   // construct overriding mutate method
   const mutateOriginal = swrResponse.mutate;
   const mutate = useCallback((editorMode: EditorMode, shouldRevalidate?: boolean) => {
-    if (!isEditable) {
+    if (preventModeEditor) {
       return Promise.resolve(EditorMode.View); // fixed if not editable
     }
     updateHashByEditorMode(editorMode);
     return mutateOriginal(editorMode, shouldRevalidate);
-  }, [isEditable, mutateOriginal]);
+  }, [preventModeEditor, mutateOriginal]);
 
   const getClassNames = useCallback(() => {
     return getClassNamesByEditorMode(swrResponse.data);
@@ -221,155 +222,93 @@ export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
   return useStaticSWR(key);
 };
 
-type PreferDrawerModeByUserUtils = {
-  update: (preferDrawerMode: boolean) => void
-}
-
-export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponseWithUtils<PreferDrawerModeByUserUtils, boolean> => {
-  const { scheduleToPut } = useUserUISettings();
-
-  const swrResponse: SWRResponse<boolean, Error> = useStaticSWR('preferDrawerModeByUser', initialData);
-
-  const utils: PreferDrawerModeByUserUtils = {
-    update: (preferDrawerMode: boolean) => {
-      swrResponse.mutate(preferDrawerMode);
-      scheduleToPut({ preferDrawerModeByUser: preferDrawerMode });
-    },
-  };
-
-  return withUtils<PreferDrawerModeByUserUtils>(swrResponse, utils);
-
-};
-
-export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('preferDrawerModeOnEditByUser', initialData, { fallbackData: true });
-};
-
-export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isSidebarCollapsed', initialData, { fallbackData: false });
-};
 
 export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
-  return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
+  return useSWRStatic('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
 };
 
 export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
-  return useStaticSWR('productNavWidth', initialData, { fallbackData: 320 });
+  return useSWRStatic('productNavWidth', initialData, { fallbackData: 320 });
 };
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
-  const { data: preferDrawerModeByUser } = usePreferDrawerModeByUser();
-  const { data: preferDrawerModeOnEditByUser } = usePreferDrawerModeOnEditByUser();
   const { data: editorMode } = useEditorMode();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
 
-  const condition = editorMode != null && preferDrawerModeByUser != null && preferDrawerModeOnEditByUser != null && isDeviceSmallerThanMd != null;
+  const condition = editorMode != null && isDeviceSmallerThanMd != null;
 
   const calcDrawerMode = (
-      endpoint: string,
+      _keyString: string,
       editorMode: EditorMode,
-      preferDrawerModeByUser: boolean,
-      preferDrawerModeOnEditByUser: boolean,
       isDeviceSmallerThanMd: boolean,
   ): boolean => {
-    // get preference on view or edit
-    const preferDrawerMode = editorMode !== EditorMode.View ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    return isDeviceSmallerThanMd ?? preferDrawerMode ?? false;
+    return isDeviceSmallerThanMd
+      ? true
+      : editorMode === EditorMode.Editor;
   };
 
-  const isViewModeWithPreferDrawerMode = editorMode === EditorMode.View && preferDrawerModeByUser;
-  const isEditModeWithPreferDrawerMode = editorMode !== EditorMode.View && preferDrawerModeOnEditByUser;
-  const isDrawerModeFixed = isViewModeWithPreferDrawerMode || isEditModeWithPreferDrawerMode;
-
   return useSWRImmutable(
-    condition ? ['isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
+    condition ? ['isDrawerMode', editorMode, isDeviceSmallerThanMd] : null,
     // calcDrawerMode,
     key => calcDrawerMode(...key),
     condition
       ? {
-        fallbackData: isDrawerModeFixed
-          ? true
-          : calcDrawerMode('isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd),
+        fallbackData: calcDrawerMode('isDrawerMode', editorMode, isDeviceSmallerThanMd),
       }
       : undefined,
   );
 };
 
-type SidebarConfigOption = {
-  update: () => Promise<void>,
-  isSidebarDrawerMode: boolean|undefined,
-  isSidebarClosedAtDockMode: boolean|undefined,
-  setIsSidebarDrawerMode: (isSidebarDrawerMode: boolean) => void,
-  setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode: boolean) => void
-}
-
-export const useSWRxSidebarConfig = (): SWRResponse<ISidebarConfig, Error> & SidebarConfigOption => {
-  const swrResponse = useSWRImmutable(
-    '/customize-setting/sidebar',
-    endpoint => apiv3Get(endpoint).then(result => result.data),
-  );
-  return {
-    ...swrResponse,
-    update: async() => {
-      const { data } = swrResponse;
-
-      if (data == null) {
-        return;
-      }
-
-      const { isSidebarDrawerMode, isSidebarClosedAtDockMode } = data;
+export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
+  return useSWRStatic('isDrawerOpened', isOpened, { fallbackData: false });
+};
 
-      const updateData = {
-        isSidebarDrawerMode,
-        isSidebarClosedAtDockMode,
-      };
+export const usePreferCollapsedMode = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useSWRStatic('isPreferCollapsedMode', initialData, { fallbackData: false });
+};
 
-      // invoke API
-      await apiv3Put('/customize-setting/sidebar', updateData);
-    },
-    isSidebarDrawerMode: swrResponse.data?.isSidebarDrawerMode,
-    isSidebarClosedAtDockMode: swrResponse.data?.isSidebarClosedAtDockMode,
-    setIsSidebarDrawerMode: (isSidebarDrawerMode) => {
-      const { data, mutate } = swrResponse;
+export const useCollapsedContentsOpened = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useSWRStatic('isCollapsedContentsOpened', initialData, { fallbackData: false });
+};
 
-      if (data == null) {
-        return;
-      }
+type DetectSidebarModeUtils = {
+  isDrawerMode(): boolean
+  isCollapsedMode(): boolean
+  isDockMode(): boolean
+}
 
-      const updateData = {
-        isSidebarDrawerMode,
-      };
+export const useSidebarMode = (): SWRResponseWithUtils<DetectSidebarModeUtils, SidebarMode> => {
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: isCollapsedModeUnderDockMode } = usePreferCollapsedMode();
 
-      // update isSidebarDrawerMode in cache, not revalidate
-      mutate({ ...data, ...updateData }, false);
+  const condition = isDrawerMode != null && isCollapsedModeUnderDockMode != null;
 
-    },
-    setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode) => {
-      const { data, mutate } = swrResponse;
+  const fetcher = useCallback(([, isDrawerMode, isCollapsedModeUnderDockMode]: [Key, boolean|undefined, boolean|undefined]) => {
+    if (isDrawerMode) {
+      return SidebarMode.DRAWER;
+    }
+    return isCollapsedModeUnderDockMode ? SidebarMode.COLLAPSED : SidebarMode.DOCK;
+  }, []);
 
-      if (data == null) {
-        return;
-      }
+  const swrResponse = useSWRImmutable(
+    condition ? ['sidebarMode', isDrawerMode, isCollapsedModeUnderDockMode] : null,
+    // calcDrawerMode,
+    fetcher,
+    { fallbackData: fetcher(['sidebarMode', isDrawerMode, isCollapsedModeUnderDockMode]) },
+  );
 
-      const updateData = {
-        isSidebarClosedAtDockMode,
-      };
+  const _isDrawerMode = useCallback(() => swrResponse.data === SidebarMode.DRAWER, [swrResponse.data]);
+  const _isCollapsedMode = useCallback(() => swrResponse.data === SidebarMode.COLLAPSED, [swrResponse.data]);
+  const _isDockMode = useCallback(() => swrResponse.data === SidebarMode.DOCK, [swrResponse.data]);
 
-      // update isSidebarClosedAtDockMode in cache, not revalidate
-      mutate({ ...data, ...updateData }, false);
-    },
+  return {
+    ...swrResponse,
+    isDrawerMode: _isDrawerMode,
+    isCollapsedMode: _isCollapsedMode,
+    isDockMode: _isDockMode,
   };
 };
 
-export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isDrawerOpened', isOpened, { fallbackData: false });
-};
-
-export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
-};
-
 export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
   return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
 };

+ 0 - 6
apps/app/src/styles/_variables.scss

@@ -9,10 +9,4 @@ $grw-marker-green: #6f6;
 $grw-navbar-bottom-height: 48px;
 $grw-editor-navbar-bottom-height: 48px;
 
-$grw-sidebar-nav-width: 48px;
-$grw-sidebar-nav-height: 50px;
-
-$grw-logo-width: $grw-sidebar-nav-width;
-$grw-logomark-width: 24px;
-
 $grw-scroll-margin-top-in-view: 130px;

+ 2 - 2
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -209,7 +209,7 @@ context('Access to Template Editing Mode', () => {
 
     // Open sidebar
     cy.collapseSidebar(false);
-    cy.getByTestid('grw-contextual-navigation-child').should('be.visible');
+    cy.getByTestid('grw-sidebar-contents').should('be.visible');
     cy.waitUntilSkeletonDisappear();
 
     // If PageTree is not active when the sidebar is opened, make it active
@@ -221,7 +221,7 @@ context('Access to Template Editing Mode', () => {
       });
 
     // Create page (/{parentPath}}/{newPagePath}) from PageTree
-    cy.getByTestid('grw-contextual-navigation-child').within(() => {
+    cy.getByTestid('grw-sidebar-contents').within(() => {
       cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
         cy.get('#page-create-button-in-page-tree').first().click({force: true})
       });

+ 14 - 22
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -27,7 +27,7 @@ describe('Access to sidebar', () => {
 
       describe('Test show/collapse button', () => {
         it('Successfully show sidebar', () => {
-          cy.getByTestid('grw-contextual-navigation-child').should('be.visible');
+          cy.getByTestid('grw-sidebar-contents').should('be.visible');
 
           cy.waitUntilSkeletonDisappear();
           cy.screenshot(`${ssPrefix}1-sidebar-shown`, {
@@ -36,10 +36,11 @@ describe('Access to sidebar', () => {
           });
         });
 
+        // TODO: rewrite test case with grw-switch-collapse-button
         it('Successfully collapse sidebar', () => {
-          cy.getByTestid('grw-navigation-resize-button').click({force: true});
+          cy.getByTestid('grw-switch-collapse-button').click({force: true});
 
-          cy.getByTestid('grw-contextual-navigation-child').should('not.be.visible');
+          cy.getByTestid('grw-sidebar-contents').should('not.be.visible');
 
           cy.waitUntilSkeletonDisappear();
           cy.screenshot(`${ssPrefix}2-sidebar-collapsed`, {
@@ -61,7 +62,7 @@ describe('Access to sidebar', () => {
         });
 
         it('Successfully access to page tree', () => {
-          cy.getByTestid('grw-contextual-navigation-child').within(() => {
+          cy.getByTestid('grw-sidebar-contents').within(() => {
             cy.getByTestid('grw-pagetree-item-container').should('be.visible');
 
             cy.waitUntilSkeletonDisappear();
@@ -70,7 +71,7 @@ describe('Access to sidebar', () => {
         });
 
         it('Successfully hide page tree items', () => {
-          cy.getByTestid('grw-contextual-navigation-child').within(() => {
+          cy.getByTestid('grw-sidebar-contents').within(() => {
             cy.get('.grw-pagetree-open').should('be.visible');
 
             // hide page tree tiems
@@ -83,7 +84,7 @@ describe('Access to sidebar', () => {
         it('Successfully click Add to Bookmarks button', () => {
           cy.waitUntil(() => {
             // do
-            cy.getByTestid('grw-contextual-navigation-child').within(() => {
+            cy.getByTestid('grw-sidebar-contents').within(() => {
               cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
                 cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
               });
@@ -101,7 +102,7 @@ describe('Access to sidebar', () => {
           // show dropdown again
           cy.waitUntil(() => {
             // do
-            cy.getByTestid('grw-contextual-navigation-child').within(() => {
+            cy.getByTestid('grw-sidebar-contents').within(() => {
               cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
                 cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
               });
@@ -116,7 +117,7 @@ describe('Access to sidebar', () => {
         it('Successfully show duplicate page modal', () => {
           cy.waitUntil(() => {
             // do
-            cy.getByTestid('grw-contextual-navigation-child').within(() => {
+            cy.getByTestid('grw-sidebar-contents').within(() => {
               cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
                 cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
               });
@@ -141,7 +142,7 @@ describe('Access to sidebar', () => {
         it('Successfully rename page', () => {
           cy.waitUntil(() => {
             // do
-            cy.getByTestid('grw-contextual-navigation-child').within(() => {
+            cy.getByTestid('grw-sidebar-contents').within(() => {
               cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
                 cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
               });
@@ -164,7 +165,7 @@ describe('Access to sidebar', () => {
         it('Successfully show delete page modal', () => {
           cy.waitUntil(() => {
             // do
-            cy.getByTestid('grw-contextual-navigation-child').within(() => {
+            cy.getByTestid('grw-sidebar-contents').within(() => {
               cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
                 cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
               });
@@ -196,7 +197,7 @@ describe('Access to sidebar', () => {
         });
 
         it('Successfully access to custom sidebar', () => {
-          cy.getByTestid('grw-contextual-navigation-child').within(() => {
+          cy.getByTestid('grw-sidebar-contents').within(() => {
             cy.get('.grw-sidebar-content-header > h3').find('a');
 
             cy.waitUntilSkeletonDisappear();
@@ -243,18 +244,9 @@ describe('Access to sidebar', () => {
           cy.get('.list-group-item').should('be.visible');
 
           // The scope of the screenshot is not narrowed because the blackout is shifted
-          cy.screenshot(`${ssPrefix}recent-changes-1-access-to-recent-changes`, { blackout: blackoutOverride });
+          cy.screenshot(`${ssPrefix}recent-changes-access-to-recent-changes`, { blackout: blackoutOverride });
         });
 
-        it('Successfully switch content size', () => {
-          cy.get('#grw-sidebar-contents-wrapper').within(() => {
-            cy.get('#recentChangesResize').click({force: true});
-            cy.get('.list-group-item').should('be.visible');
-          });
-
-          // The scope of the screenshot is not narrowed because the blackout is shifted
-          cy.screenshot(`${ssPrefix}recent-changes-2-switch-content-size`, { blackout: blackoutOverride });
-        });
       });
 
       describe('Test tags tab', () => {
@@ -269,7 +261,7 @@ describe('Access to sidebar', () => {
         });
 
         it('Successfully access to tags', () => {
-          cy.getByTestid('grw-contextual-navigation-child').within(() => {
+          cy.getByTestid('grw-sidebar-contents').within(() => {
             cy.getByTestid('grw-tags-list').should('be.visible');
 
             cy.screenshot(`${ssPrefix}tags-1-access-to-tags`, { blackout: blackoutOverride });

+ 4 - 4
apps/app/test/cypress/support/commands.ts

@@ -81,14 +81,14 @@ Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean, waitUntilSaving =
 
   cy.getByTestid('grw-sidebar').should('be.visible').within(() => {
 
-    const isSidebarContextualNavigationHidden = isHiddenByTestId('grw-contextual-navigation-child');
-    if (isSidebarContextualNavigationHidden === isCollapsed) {
+    const isSidebarContentsHidden = isHiddenByTestId('grw-sidebar-contents');
+    if (isSidebarContentsHidden === isCollapsed) {
       return;
     }
 
     cy.waitUntil(() => {
       // do
-      cy.getByTestid("grw-navigation-resize-button").click({force: true});
+      cy.getByTestid("grw-switch-collapse-button").click({force: true});
       // wait until saving UserUISettings
       if (waitUntilSaving) {
         // eslint-disable-next-line cypress/no-unnecessary-waiting
@@ -96,7 +96,7 @@ Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean, waitUntilSaving =
       }
 
       // wait until
-      return cy.getByTestid('grw-contextual-navigation-child').then($contents => isHidden($contents) === isCollapsed);
+      return cy.getByTestid('grw-sidebar-contents').then($contents => isHidden($contents) === isCollapsed);
     });
   });
 

+ 1 - 0
packages/editor/package.json

@@ -23,6 +23,7 @@
   "devDependencies": {
     "@codemirror/lang-markdown": "^6.2.0",
     "@codemirror/language-data": "^6.3.1",
+    "@codemirror/language": "^6.8.0",
     "@codemirror/state": "^6.2.1",
     "@codemirror/view": "^6.15.3",
     "@popperjs/core": "^2.11.8",

+ 29 - 0
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.module.scss

@@ -3,6 +3,35 @@
   .cm-editor {
     width: 100%;
     height: 100%;
+
+    // Header highlight style
+    .cm-header {
+      text-decoration: none;
+      &:first-of-type {
+        font-style: italic;
+        opacity: 0.5;
+      }
+    }
+
+    .cm-header-1 {
+      font-size: 1.9em;
+    }
+    .cm-header-2 {
+      font-size: 1.6em;
+    }
+    .cm-header-3 {
+      font-size: 1.4em;
+    }
+    .cm-header-4 {
+      font-size: 1.35em;
+    }
+    .cm-header-5 {
+      font-size: 1.25em;
+    }
+    .cm-header-6 {
+      font-size: 1.2em;
+    }
+
   }
 
 }

+ 5 - 3
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -6,7 +6,7 @@ import { indentUnit } from '@codemirror/language';
 import { EditorView } from '@codemirror/view';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
-import { GlobalCodeMirrorEditorKey } from '../../consts';
+import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
 import { useFileDropzone } from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
@@ -22,6 +22,7 @@ const CodeMirrorEditorContainer = forwardRef<HTMLDivElement>((props, ref) => {
 
 type Props = {
   editorKey: string | GlobalCodeMirrorEditorKey,
+  acceptedFileType: AcceptedUploadFileType,
   onChange?: (value: string) => void,
   onUpload?: (files: File[]) => void,
   indentSize?: number,
@@ -30,6 +31,7 @@ type Props = {
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
   const {
     editorKey,
+    acceptedFileType,
     onChange,
     onUpload,
     indentSize,
@@ -98,12 +100,12 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [codeMirrorEditor]);
 
-  const { getRootProps, open } = useFileDropzone({ onUpload });
+  const { getRootProps, open } = useFileDropzone({ onUpload, acceptedFileType });
 
   return (
     <div {...getRootProps()} className="flex-expand-vert">
       <CodeMirrorEditorContainer ref={containerRef} />
-      <Toolbar onFileOpen={open} />
+      <Toolbar onFileOpen={open} acceptedFileType={acceptedFileType} />
     </div>
   );
 };

+ 38 - 0
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx

@@ -0,0 +1,38 @@
+import {
+  DropdownItem,
+} from 'reactstrap';
+
+import { AcceptedUploadFileType } from '../../../consts/accepted-upload-file-type';
+
+type Props = {
+  onFileOpen: () => void,
+  acceptedFileType: AcceptedUploadFileType,
+}
+
+export const AttachmentsButton = (props: Props): JSX.Element => {
+
+  const { onFileOpen, acceptedFileType } = props;
+
+  if (acceptedFileType === AcceptedUploadFileType.ALL) {
+    return (
+      <>
+        <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
+          <span className="material-icons-outlined fs-5">attach_file</span>
+          Files
+        </DropdownItem>
+      </>
+    );
+  }
+  if (acceptedFileType === AcceptedUploadFileType.IMAGE) {
+    return (
+      <>
+        <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
+          <span className="material-icons-outlined fs-5">image</span>
+          Images
+        </DropdownItem>
+      </>
+    );
+  }
+
+  return <></>;
+};

+ 8 - 9
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

@@ -5,13 +5,19 @@ import {
   DropdownItem,
 } from 'reactstrap';
 
+import { AcceptedUploadFileType } from '../../../consts/accepted-upload-file-type';
+
+import { AttachmentsButton } from './AttachmentsButton';
+
+
 type Props = {
   onFileOpen: () => void,
+  acceptedFileType: AcceptedUploadFileType,
 }
 
 export const AttachmentsDropup = (props: Props): JSX.Element => {
 
-  const { onFileOpen } = props;
+  const { onFileOpen, acceptedFileType } = props;
   return (
     <>
       <UncontrolledDropdown direction="up" className="lh-1">
@@ -24,14 +30,7 @@ export const AttachmentsDropup = (props: Props): JSX.Element => {
             Attachments
           </DropdownItem>
           <DropdownItem divider />
-          <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
-            <span className="material-icons-outlined fs-5">attach_file</span>
-            Files
-          </DropdownItem>
-          <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
-            <span className="material-icons-outlined fs-5">image</span>
-            Images
-          </DropdownItem>
+          <AttachmentsButton onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
           <DropdownItem className="d-flex gap-1 align-items-center">
             <span className="material-icons-outlined fs-5">link</span>
             Link

+ 5 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -7,18 +7,21 @@ import { TableButton } from './TableButton';
 import { TemplateButton } from './TemplateButton';
 import { TextFormatTools } from './TextFormatTools';
 
+import { AcceptedUploadFileType } from 'src/consts';
+
 import styles from './Toolbar.module.scss';
 
 type Props = {
   onFileOpen: () => void,
+  acceptedFileType: AcceptedUploadFileType
 }
 
 export const Toolbar = memo((props: Props): JSX.Element => {
 
-  const { onFileOpen } = props;
+  const { onFileOpen, acceptedFileType } = props;
   return (
     <div className={`d-flex gap-2 p-2 codemirror-editor-toolbar ${styles['codemirror-editor-toolbar']}`}>
-      <AttachmentsDropup onFileOpen={onFileOpen} />
+      <AttachmentsDropup onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
       <TextFormatTools />
       <EmojiButton />
       <TableButton />

+ 5 - 2
packages/editor/src/components/CodeMirrorEditorComment.tsx

@@ -3,7 +3,7 @@ import { useEffect } from 'react';
 import type { Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
 
-import { GlobalCodeMirrorEditorKey } from '../consts';
+import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../consts';
 import { useCodeMirrorEditorIsolated } from '../stores';
 
 import { CodeMirrorEditor } from '.';
@@ -17,14 +17,16 @@ const additionalExtensions: Extension[] = [
 type Props = {
   onChange?: (value: string) => void,
   onComment?: () => void,
+  acceptedFileType?: AcceptedUploadFileType,
 }
 
 export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
   const {
-    onComment, onChange,
+    onComment, onChange, acceptedFileType,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
+  const acceptedFileTypeNoOpt = acceptedFileType ?? AcceptedUploadFileType.NONE;
 
   // setup additional extensions
   useEffect(() => {
@@ -60,6 +62,7 @@ export const CodeMirrorEditorComment = (props: Props): JSX.Element => {
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.COMMENT}
       onChange={onChange}
+      acceptedFileType={acceptedFileTypeNoOpt}
     />
   );
 };

+ 8 - 3
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
 
 import type { Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
+import type { Nullable } from '@growi/core';
 // TODO: import socket.io-client types wihtout lint error
 // import type { Socket, DefaultEventsMap } from 'socket.io-client';
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -10,7 +11,7 @@ import { yCollab } from 'y-codemirror.next';
 import { SocketIOProvider } from 'y-socket.io';
 import * as Y from 'yjs';
 
-import { GlobalCodeMirrorEditorKey, userColor } from '../consts';
+import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType, userColor } from '../consts';
 import { useCodeMirrorEditorIsolated } from '../stores';
 
 import { CodeMirrorEditor } from '.';
@@ -29,8 +30,9 @@ type Props = {
   onChange?: (value: string) => void,
   onSave?: () => void,
   onUpload?: (files: File[]) => void,
+  acceptedFileType?: AcceptedUploadFileType,
   indentSize?: number,
-  pageId?: string | null,
+  pageId: Nullable<string>,
   userName?: string,
   socket?: any, // Socket<DefaultEventsMap, DefaultEventsMap>,
   initialValue: string,
@@ -39,7 +41,7 @@ type Props = {
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, onUpload, indentSize, pageId, userName, initialValue, socket, setMarkdownToPreview,
+    onSave, onChange, onUpload, acceptedFileType, indentSize, pageId, userName, initialValue, socket, setMarkdownToPreview,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -47,6 +49,8 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const [provider, setProvider] = useState<SocketIOProvider | null>(null);
   const [cPageId, setCPageId] = useState(pageId);
 
+  const acceptedFileTypeNoOpt = acceptedFileType ?? AcceptedUploadFileType.NONE;
+
   // cleanup ydoc and socketIOProvider
   useEffect(() => {
     if (cPageId === pageId) {
@@ -179,6 +183,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       onChange={onChange}
       onUpload={onUpload}
+      acceptedFileType={acceptedFileTypeNoOpt}
       indentSize={indentSize}
     />
   );

+ 3 - 2
packages/editor/src/components/playground/Playground.tsx

@@ -4,7 +4,7 @@ import {
 
 import { toast } from 'react-toastify';
 
-import { GlobalCodeMirrorEditorKey } from '../../consts';
+import { AcceptedUploadFileType, GlobalCodeMirrorEditorKey } from '../../consts';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 import { CodeMirrorEditorMain } from '../CodeMirrorEditorMain';
 
@@ -48,7 +48,6 @@ export const Playground = (): JSX.Element => {
 
   }, [codeMirrorEditor]);
 
-
   return (
     <>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
@@ -63,6 +62,8 @@ export const Playground = (): JSX.Element => {
             indentSize={4}
             setMarkdownToPreview={setMarkdownToPreview}
             initialValue={initialValue}
+            acceptedFileType={AcceptedUploadFileType.ALL}
+            pageId={undefined}
           />
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">

+ 6 - 0
packages/editor/src/consts/accepted-upload-file-type.ts

@@ -0,0 +1,6 @@
+export const AcceptedUploadFileType = {
+  ALL: '*',
+  IMAGE: 'image/*',
+  NONE: '',
+} as const;
+export type AcceptedUploadFileType = typeof AcceptedUploadFileType[keyof typeof AcceptedUploadFileType];

+ 1 - 0
packages/editor/src/consts/index.ts

@@ -1,2 +1,3 @@
 export * from './global-code-mirror-editor-key';
 export * from './ydoc-awareness-user-color';
+export * from './accepted-upload-file-type';

+ 13 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -2,9 +2,11 @@ import { useMemo } from 'react';
 
 import { indentWithTab, defaultKeymap } from '@codemirror/commands';
 import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
+import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language';
 import { languages } from '@codemirror/language-data';
 import { EditorState, Prec, type Extension } from '@codemirror/state';
 import { keymap, EditorView } from '@codemirror/view';
+import { tags } from '@lezer/highlight';
 import { useCodeMirror, type UseCodeMirror } from '@uiw/react-codemirror';
 import deepmerge from 'ts-deepmerge';
 
@@ -16,6 +18,15 @@ import { useInsertText, type InsertText } from './utils/insert-text';
 import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
 
+const markdownHighlighting = HighlightStyle.define([
+  { tag: tags.heading1, class: 'cm-header-1 cm-header' },
+  { tag: tags.heading2, class: 'cm-header-2 cm-header' },
+  { tag: tags.heading3, class: 'cm-header-3 cm-header' },
+  { tag: tags.heading4, class: 'cm-header-4 cm-header' },
+  { tag: tags.heading5, class: 'cm-header-5 cm-header' },
+  { tag: tags.heading6, class: 'cm-header-6 cm-header' },
+]);
+
 type UseCodeMirrorEditorUtils = {
   initDoc: InitDoc,
   appendExtensions: AppendExtensions,
@@ -35,6 +46,8 @@ const defaultExtensions: Extension[] = [
   markdown({ base: markdownLanguage, codeLanguages: languages }),
   keymap.of([indentWithTab]),
   Prec.lowest(keymap.of(defaultKeymap)),
+  syntaxHighlighting(markdownHighlighting),
+  Prec.lowest(syntaxHighlighting(defaultHighlightStyle)),
 ];
 
 export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor => {

+ 13 - 2
packages/editor/src/services/file-dropzone/use-file-dropzone.ts

@@ -1,15 +1,18 @@
 import { useCallback } from 'react';
 
-import { useDropzone } from 'react-dropzone';
+import { useDropzone, Accept } from 'react-dropzone';
 import type { DropzoneState } from 'react-dropzone';
 
+import { AcceptedUploadFileType } from '../../consts';
+
 type DropzoneEditor = {
   onUpload?: (files: File[]) => void,
+  acceptedFileType: AcceptedUploadFileType,
 }
 
 export const useFileDropzone = (props: DropzoneEditor): DropzoneState => {
 
-  const { onUpload } = props;
+  const { onUpload, acceptedFileType } = props;
 
   const dropHandler = useCallback((acceptedFiles: File[]) => {
     if (onUpload == null) {
@@ -18,10 +21,18 @@ export const useFileDropzone = (props: DropzoneEditor): DropzoneState => {
     onUpload(acceptedFiles);
   }, [onUpload]);
 
+  const accept: Accept = {
+    acceptedFileType: [],
+  };
+
+  const disabled = acceptedFileType === AcceptedUploadFileType.NONE;
+
   return useDropzone({
     noKeyboard: true,
     noClick: true,
+    disabled,
     onDrop: dropHandler,
+    accept,
   });
 
 };