Explorar el Código

Merge branch 'master' of https://github.com/weseek/growi into feat/auditlog

Shun Miyazawa hace 3 años
padre
commit
d4841aa5b7
Se han modificado 36 ficheros con 620 adiciones y 122 borrados
  1. 32 0
      packages/app/public/images/customize-settings/dock-dark.svg
  2. 32 0
      packages/app/public/images/customize-settings/dock-light.svg
  3. 31 0
      packages/app/public/images/customize-settings/drawer-dark.svg
  4. 31 0
      packages/app/public/images/customize-settings/drawer-light.svg
  5. 7 0
      packages/app/public/static/locales/en_US/admin/admin.json
  6. 7 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  7. 7 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  8. 6 2
      packages/app/src/client/app.jsx
  9. 11 5
      packages/app/src/client/services/ContextExtractor.tsx
  10. 1 0
      packages/app/src/client/services/PageContainer.js
  11. 12 7
      packages/app/src/components/Admin/Customize/Customize.jsx
  12. 118 0
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  13. 28 15
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  14. 29 24
      packages/app/src/components/Navbar/SubNavButtons.tsx
  15. 28 3
      packages/app/src/components/NotFoundPage.tsx
  16. 3 3
      packages/app/src/components/Page/DisplaySwitcher.tsx
  17. 3 3
      packages/app/src/components/PageContentFooter.tsx
  18. 1 1
      packages/app/src/components/PageRenameModal.tsx
  19. 2 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  20. 4 4
      packages/app/src/interfaces/page.ts
  21. 5 0
      packages/app/src/interfaces/sidebar-config.ts
  22. 4 0
      packages/app/src/server/models/config.ts
  23. 1 1
      packages/app/src/server/models/obsolete-page.js
  24. 18 0
      packages/app/src/server/models/page.ts
  25. 40 0
      packages/app/src/server/routes/apiv3/customize-setting.js
  26. 3 2
      packages/app/src/server/routes/apiv3/page-listing.ts
  27. 2 2
      packages/app/src/server/routes/apiv3/page.js
  28. 1 1
      packages/app/src/server/routes/apiv3/pages.js
  29. 41 27
      packages/app/src/server/routes/page.js
  30. 22 17
      packages/app/src/server/service/page.ts
  31. 1 0
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  32. 1 0
      packages/app/src/server/views/layout-growi/not_found.html
  33. 1 0
      packages/app/src/server/views/widget/not_found_content.html
  34. 7 0
      packages/app/src/stores/context.tsx
  35. 73 3
      packages/app/src/stores/ui.tsx
  36. 7 0
      packages/app/src/styles/_admin.scss

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 32 - 0
packages/app/public/images/customize-settings/dock-dark.svg


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 32 - 0
packages/app/public/images/customize-settings/dock-light.svg


+ 31 - 0
packages/app/public/images/customize-settings/drawer-dark.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#2a2d33"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#16171d" opacity="0.586"/>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#343a40"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+  </g>
+</svg>

+ 31 - 0
packages/app/public/images/customize-settings/drawer-light.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#fff"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#abb4bd"/>
+      <rect width="42.646" height="5" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#abb4bd"/>
+      <rect width="42.646" height="5" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
+    </g>
+    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#25272f" opacity="0.271"/>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#f3f7fc"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#abb4bd"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#abb4bd"/>
+    </g>
+  </g>
+</svg>

+ 7 - 0
packages/app/public/static/locales/en_US/admin/admin.json

@@ -149,6 +149,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "Default sidebar mode",
+      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
+      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
+      "dock_mode_default_open": "Open the page as it was opened from the beginning",
+      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+    },
     "layout": "Layout",
     "layout_options": {
       "default": "Default content width",

+ 7 - 0
packages/app/public/static/locales/ja_JP/admin/admin.json

@@ -149,6 +149,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "デフォルトのサイドバーモード",
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
+      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
+      "dock_mode_default_open": "初めから開いた状態でページを開く",
+      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+    },
     "layout": "レイアウト",
     "layout_options": {
       "default": "デフォルトのコンテンツ幅",

+ 7 - 0
packages/app/public/static/locales/zh_CN/admin/admin.json

@@ -148,6 +148,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "默认的侧边栏模式",
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
+      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
+      "dock_mode_default_open": "从头开始翻页",
+      "dock_mode_default_close": "从头开始打开关闭的页面"
+    },
     "layout": "布局",
     "layout_options": {
       "default": "默认内容宽度 ",

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

@@ -96,8 +96,6 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
-  'fix-page-grant-alert': <FixPageGrantAlert />,
-
   'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
@@ -131,6 +129,12 @@ if (pageContainer.state.pageId != null) {
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
 
+  if (!pageContainer.state.isEmpty) {
+    Object.assign(componentMappings, {
+      'fix-page-grant-alert': <FixPageGrantAlert />,
+    });
+  }
+
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
     toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');

+ 11 - 5
packages/app/src/client/services/ContextExtractor.tsx

@@ -18,7 +18,7 @@ import {
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useActivityExpirationSeconds, useGrowiVersion,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion, useActivityExpirationSeconds,
 } from '../../stores/context';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -29,7 +29,7 @@ const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
   const notFoundContentForPt = document.getElementById('growi-pagetree-not-found-context');
-  const notFoundContent = document.getElementById('growi-not-found-context');
+  const notFoundContext = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
 
   // get csrf token from body element
@@ -57,7 +57,10 @@ const ContextExtractorOnce: FC = () => {
    */
   const revisionId = mainContent?.getAttribute('data-page-revision-id');
   const path = decodeURI(mainContent?.getAttribute('data-path') || '');
+  // assign `null` to avoid returning empty string
   const pageId = mainContent?.getAttribute('data-page-id') || null;
+  const emptyPageId = notFoundContext?.getAttribute('data-page-id') || null;
+
   const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
 
   // createdAt
@@ -89,8 +92,9 @@ const ContextExtractorOnce: FC = () => {
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
+  const isNotFoundPermalink = JSON.parse(notFoundContext?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
+  const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
@@ -105,9 +109,9 @@ const ContextExtractorOnce: FC = () => {
   useCurrentUser(currentUser);
 
   // UserUISettings
-  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? configByContextHydrate.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
-  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed ?? configByContextHydrate.isSidebarClosedAtDockMode);
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
@@ -136,6 +140,7 @@ const ContextExtractorOnce: FC = () => {
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useCurrentPageId(pageId);
+  useEmptyPageId(emptyPageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   useCurrentPagePath(path);
@@ -152,6 +157,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
+  useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);
 
   // Navigation

+ 1 - 0
packages/app/src/client/services/PageContainer.js

@@ -52,6 +52,7 @@ export default class PageContainer extends Container {
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
+      isEmpty: mainContent.getAttribute('data-page-is-empty'),
 
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead

+ 12 - 7
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -1,23 +1,25 @@
 
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 
-import loggerFactory from '~/utils/logger';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toArrayIfNot } from '~/utils/array-utils';
+import loggerFactory from '~/utils/logger';
+
 import { withLoadingSppiner } from '../../SuspenseUtils';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import CustomizeLayoutSetting from './CustomizeLayoutSetting';
-import CustomizeThemeSetting from './CustomizeThemeSetting';
+import CustomizeCssSetting from './CustomizeCssSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
+import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
-import CustomizeCssSetting from './CustomizeCssSetting';
+import CustomizeLayoutSetting from './CustomizeLayoutSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
-import CustomizeHeaderSetting from './CustomizeHeaderSetting';
+import CustomizeSidebarSetting from './CustomizeSidebarSetting';
+import CustomizeThemeSetting from './CustomizeThemeSetting';
 import CustomizeTitle from './CustomizeTitle';
 
 const logger = loggerFactory('growi:services:AdminCustomizePage');
@@ -53,6 +55,9 @@ function Customize(props) {
       <div className="mb-5">
         <CustomizeThemeSetting />
       </div>
+      <div className="mb-5">
+        <CustomizeSidebarSetting />
+      </div>
       <div className="mb-5">
         <CustomizeFunctionSetting />
       </div>

+ 118 - 0
packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -0,0 +1,118 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
+import { useSWRxSidebarConfig } from '~/stores/ui';
+
+const CustomizeSidebarsetting = (): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    update, isSidebarDrawerMode, isSidebarClosedAtDockMode, setIsSidebarDrawerMode, setIsSidebarClosedAtDockMode,
+  } = useSWRxSidebarConfig();
+
+  const isDarkMode = isDarkModeByUtil();
+  const colorText = isDarkMode ? 'dark' : 'light';
+  const drawerIconFileName = `/images/customize-settings/drawer-${colorText}.svg`;
+  const dockIconFileName = `/images/customize-settings/dock-${colorText}.svg`;
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await update();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.default_sidebar_mode.title') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, update]);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+
+          <h2 className="admin-setting-header">{t('admin:customize_setting.default_sidebar_mode.title')}</h2>
+
+          <Card className="card well my-3">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.default_sidebar_mode.desc')}
+            </CardBody>
+          </Card>
+
+          <div className="d-flex justify-content-around mt-5">
+            <div id="layoutOptions" className="card-deck">
+              <div
+                className={`card customize-layout-card ${isSidebarDrawerMode ? 'border-active' : ''}`}
+                onClick={() => setIsSidebarDrawerMode(true)}
+                role="button"
+              >
+                <img src={drawerIconFileName} />
+                <div className="card-body text-center">
+                  Drawer Mode
+                </div>
+              </div>
+              <div
+                className={`card customize-layout-card ${!isSidebarDrawerMode ? 'border-active' : ''}`}
+                onClick={() => setIsSidebarDrawerMode(false)}
+                role="button"
+              >
+                <img src={dockIconFileName} />
+                <div className="card-body  text-center">
+                  Dock Mode
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <Card className="card well my-5">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_desc')}
+            </CardBody>
+          </Card>
+
+          <div className="px-3">
+            <div className="custom-control custom-radio my-3">
+              <input
+                type="radio"
+                id="is-open"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={!isSidebarDrawerMode && !isSidebarClosedAtDockMode}
+                disabled={isSidebarDrawerMode}
+                onChange={() => setIsSidebarClosedAtDockMode(false)}
+              />
+              <label className="custom-control-label" htmlFor="is-open">
+                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_open')}
+              </label>
+            </div>
+            <div className="custom-control custom-radio my-3">
+              <input
+                type="radio"
+                id="is-closed"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={!isSidebarDrawerMode && isSidebarClosedAtDockMode}
+                disabled={isSidebarDrawerMode}
+                onChange={() => setIsSidebarClosedAtDockMode(true)}
+              />
+              <label className="custom-control-label" htmlFor="is-closed">
+                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_close')}
+              </label>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="mx-auto">
+              <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>
+            </div>
+          </div>
+
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+export default CustomizeSidebarsetting;

+ 28 - 15
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -10,12 +10,14 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getIdForRef } from '~/interfaces/common';
-import { IPageHasId, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
+import {
+  IPageHasId, IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
+} from '~/interfaces/page';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
+  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useEmptyPageId,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
@@ -155,6 +157,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: createdAt } = useCurrentCreatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
   const { data: pageId } = useCurrentPageId();
+  const { data: emptyPageId } = useEmptyPageId();
   const { data: revisionId } = useRevisionId();
   const { data: path } = useCurrentPagePath();
   const { data: creator } = useCreator();
@@ -222,8 +225,12 @@ const GrowiContextualSubNavigation = (props) => {
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta) => {
+  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
+      if (page.data._id !== null) {
+        window.location.href = `/${page.data._id}`;
+        return;
+      }
       window.location.reload();
     };
     openRenameModal(page, { onRenamed: renamedHandler });
@@ -255,32 +262,38 @@ const GrowiContextualSubNavigation = (props) => {
 
 
   const ControlComponents = useCallback(() => {
+    const pageIdForSubNavButtons = pageId ?? emptyPageId; // for SubNavButtons
+
     function onPageEditorModeButtonClicked(viewType) {
       mutateEditorMode(viewType);
     }
 
+    let additionalMenuItemsRenderer;
+    if (revisionId != null) {
+      additionalMenuItemsRenderer = props => (
+        <AdditionalMenuItems
+          {...props}
+          pageId={pageId}
+          revisionId={revisionId}
+          isLinkSharingDisabled={isLinkSharingDisabled}
+          onClickTemplateMenuItem={templateMenuItemClickHandler}
+        />
+      );
+    }
     return (
       <>
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-          { pageId != null && isViewMode && (
+          { pageIdForSubNavButtons != null && isViewMode && (
             <div className="h-50">
               <SubNavButtons
                 isCompactMode={isCompactMode}
-                pageId={pageId}
+                pageId={pageIdForSubNavButtons}
                 shareLinkId={shareLinkId}
                 revisionId={revisionId}
                 path={path}
                 disableSeenUserInfoPopover={isSharedUser}
                 showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={props => (
-                  <AdditionalMenuItems
-                    {...props}
-                    pageId={pageId}
-                    revisionId={revisionId}
-                    isLinkSharingDisabled={isLinkSharingDisabled}
-                    onClickTemplateMenuItem={templateMenuItemClickHandler}
-                  />
-                )}
+                additionalMenuItemRenderer={additionalMenuItemsRenderer}
                 onClickDuplicateMenuItem={duplicateItemClickedHandler}
                 onClickRenameMenuItem={renameItemClickedHandler}
                 onClickDeleteMenuItem={deleteItemClickedHandler}
@@ -306,7 +319,7 @@ const GrowiContextualSubNavigation = (props) => {
       </>
     );
   }, [
-    pageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
+    pageId, emptyPageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
     isLinkSharingDisabled, isDeviceSmallerThanMd, isGuestUser, isSharedUser, currentUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
     duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,

+ 29 - 24
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -33,7 +33,7 @@ type CommonProps = {
 type SubNavButtonsSubstanceProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
-  revisionId: string,
+  revisionId: string | null,
   path?: string | null,
   pageInfo: IPageInfoAll,
 }
@@ -154,27 +154,33 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
-      <span>
-        <SubscribeButton
-          status={pageInfo.subscriptionStatus}
-          onClick={subscribeClickhandler}
+      {revisionId != null && (
+        <span>
+          <SubscribeButton
+            status={pageInfo.subscriptionStatus}
+            onClick={subscribeClickhandler}
+          />
+        </span>
+      )}
+      {revisionId != null && (
+        <LikeButtons
+          hideTotalNumber={isCompactMode}
+          onLikeClicked={likeClickhandler}
+          sumOfLikers={sumOfLikers}
+          isLiked={isLiked}
+          likers={likers}
+        />
+      )}
+      {revisionId != null && (
+        <BookmarkButtons
+          hideTotalNumber={isCompactMode}
+          bookmarkCount={bookmarkCount}
+          isBookmarked={isBookmarked}
+          bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
+          onBookMarkClicked={bookmarkClickHandler}
         />
-      </span>
-      <LikeButtons
-        hideTotalNumber={isCompactMode}
-        onLikeClicked={likeClickhandler}
-        sumOfLikers={sumOfLikers}
-        isLiked={isLiked}
-        likers={likers}
-      />
-      <BookmarkButtons
-        hideTotalNumber={isCompactMode}
-        bookmarkCount={bookmarkCount}
-        isBookmarked={isBookmarked}
-        bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
-        onBookMarkClicked={bookmarkClickHandler}
-      />
-      { !isCompactMode && (
+      )}
+      {revisionId != null && !isCompactMode && (
         <SeenUserInfo
           seenUsers={seenUsers}
           sumOfSeenUsers={sumOfSeenUsers}
@@ -212,7 +218,7 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
-  if (revisionId == null || error != null) {
+  if (error != null) {
     return <></>;
   }
 
@@ -220,13 +226,12 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
     return <></>;
   }
 
-
   return (
     <SubNavButtonsSubstance
       {...props}
       pageInfo={pageInfo}
       pageId={pageId}
-      revisionId={revisionId}
+      revisionId={revisionId ?? null}
       path={path}
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}

+ 28 - 3
packages/app/src/components/NotFoundPage.tsx

@@ -1,15 +1,40 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useEffect } from 'react';
+
 import { useTranslation } from 'react-i18next';
+import urljoin from 'url-join';
+
+import { useCurrentPagePath, useIsEmptyPage, useNotFoundTargetPathOrId } from '~/stores/context';
 
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
 import PageTimeline from './PageTimeline';
 
+/**
+ * Replace url in address bar with new path and query parameters
+ */
+const replaceURLHistory = (path: string) => {
+  const queryParameters = window.location.search;
+  window.history.replaceState(null, '', urljoin(path, queryParameters));
+};
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
+  const { data: isEmptyPage } = useIsEmptyPage();
+  const { data: path } = useCurrentPagePath();
+  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
+
+  // replace url in address bar with path when accessing empty page by permalink
+  useEffect(() => {
+    if (path == null) {
+      return;
+    }
+    const isPermalink = !notFoundTargetPathOrId?.includes('/');
+    if (isEmptyPage && isPermalink) {
+      replaceURLHistory(path);
+    }
+  }, [path, isEmptyPage, notFoundTargetPathOrId]);
 
   const navTabMapping = useMemo(() => {
     return {

+ 3 - 3
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -7,7 +7,7 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId, useIsEmptyPage,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -36,7 +36,7 @@ const DisplaySwitcher = (): JSX.Element => {
   // get element for smoothScroll
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
-
+  const { data: isEmptyPage } = useIsEmptyPage();
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
@@ -60,7 +60,7 @@ const DisplaySwitcher = (): JSX.Element => {
         <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
-            { isPageExist && (
+            { isPageExist && !isEmptyPage && (
               <div className="grw-side-contents-container">
                 <div className="grw-side-contents-sticky-container">
 

+ 3 - 3
packages/app/src/components/PageContentFooter.tsx

@@ -1,14 +1,14 @@
 import React, { FC, memo } from 'react';
 
-import AuthorInfo from './Navbar/AuthorInfo';
-
 import { Ref } from '../interfaces/common';
 import { IUser } from '../interfaces/user';
 
+import AuthorInfo from './Navbar/AuthorInfo';
+
 type Props = {
   createdAt: Date,
   updatedAt: Date,
-  creator: Ref<IUser>,
+  creator: any,
   revisionAuthor: Ref<IUser>,
 }
 

+ 1 - 1
packages/app/src/components/PageRenameModal.tsx

@@ -94,7 +94,7 @@ const PageRenameModal = (): JSX.Element => {
     try {
       const response = await apiv3Put('/pages/rename', {
         pageId: _id,
-        revisionId: revision,
+        revisionId: revision ?? null,
         isRecursively: !_isV5Compatible ? isRenameRecursively : undefined,
         isRenameRedirect,
         updateMetadata: !isRemainMetadata,

+ 2 - 2
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -292,8 +292,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       return;
     }
 
-    if (page._id == null || page.revision == null || page.path == null) {
-      throw Error('Any of _id, revision, and path must not be null.');
+    if (page._id == null || page.path == null) {
+      throw Error('_id and path must not be null.');
     }
 
     const pageToDelete: IPageToDeleteWithMeta = {

+ 4 - 4
packages/app/src/interfaces/page.ts

@@ -12,7 +12,7 @@ export interface IPage {
   status: string,
   revision: Ref<IRevision>,
   tags: Ref<ITag>[],
-  creator: Ref<IUser>,
+  creator: any,
   createdAt: Date,
   updatedAt: Date,
   seenUsers: Ref<IUser>[],
@@ -75,7 +75,7 @@ export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperatio
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
-  return pageInfo != null && ('isEmpty' in pageInfo) && pageInfo.isEmpty === false;
+  return pageInfo != null;
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -115,8 +115,8 @@ export type IDataWithMeta<D = unknown, M = unknown> = {
 
 export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 
-export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
-export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
+export type IPageToDeleteWithMeta<T = IPageInfoForEntity | unknown> = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string | null}), T>;
+export type IPageToRenameWithMeta<T = IPageInfoForEntity | unknown> = IPageToDeleteWithMeta<T>;
 
 export type IPageGrantData = {
   grant: number,

+ 5 - 0
packages/app/src/interfaces/sidebar-config.ts

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

+ 4 - 0
packages/app/src/server/models/config.ts

@@ -135,6 +135,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:isEnabledStaleNotification': false,
   'customize:isAllReplyShown': false,
   'customize:isSearchScopeChildrenAsDefault': false,
+  'customize:isSidebarDrawerMode': false,
+  'customize:isSidebarClosedAtDockMode': false,
 
   'notification:owner-page:isEnabled': false,
   'notification:group-page:isEnabled': false,
@@ -244,6 +246,8 @@ schema.statics.getLocalconfig = function(crowi) {
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
     activityExpirationSeconds: crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds'),
+    isSidebarDrawerMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
 
   return localConfig;

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

@@ -421,7 +421,7 @@ export const getPageSchema = (crowi) => {
   };
 
   /**
-   * find pages that is match with `path` and its descendants whitch user is able to manage
+   * find pages that is match with `path` and its descendants which user is able to manage
    */
   pageSchema.statics.findManageableListWithDescendants = async function(page, user, option = {}, includeEmpty = false) {
     if (user == null) {

+ 18 - 0
packages/app/src/server/models/page.ts

@@ -915,6 +915,24 @@ export function generateGrantCondition(
 
 schema.statics.generateGrantCondition = generateGrantCondition;
 
+// find ancestor page with isEmpty: false. If parameter path is '/', return undefined
+schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | undefined> {
+  if (path === '/') {
+    return;
+  }
+
+  const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
+
+  const ancestors = await builderForAncestors
+    .addConditionToListOnlyAncestors(path) // only ancestor paths
+    .addConditionToSortPagesByDescPath() // sort by path in Desc. Long to Short.
+    .query
+    .exec();
+
+  return ancestors[0];
+};
+
+
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike

+ 40 - 0
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -112,6 +112,10 @@ module.exports = (crowi) => {
     theme: [
       body('themeType').isString(),
     ],
+    sidebar: [
+      body('isSidebarDrawerMode').isBoolean(),
+      body('isSidebarClosedAtDockMode').isBoolean(),
+    ],
     function: [
       body('isEnabledTimeline').isBoolean(),
       body('isSavedStatesOfTabChanges').isBoolean(),
@@ -345,6 +349,42 @@ module.exports = (crowi) => {
     }
   });
 
+  // sidebar
+  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 });
+    }
+    catch (err) {
+      const msg = 'Error occurred in getting sidebar';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-sidebar-failed'));
+    }
+  });
+
+  router.put('/sidebar', loginRequiredStrictly, adminRequired, csrf, validator.sidebar, apiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:isSidebarDrawerMode': req.body.isSidebarDrawerMode,
+      'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
+    };
+
+    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'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating sidebar';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-sidebar-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 3 - 2
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -129,16 +129,17 @@ export default (crowi: Crowi): Router => {
 
       const idToPageInfoMap: Record<string, IPageInfoAll> = {};
 
+      const isGuestUser = req.user == null;
       for (const page of pages) {
         // construct isIPageInfoForListing
-        const basicPageInfo = pageService.constructBasicPageInfo(page);
+        const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            isAbleToDeleteCompletely: pageService.canDeleteCompletely((page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
+            isAbleToDeleteCompletely: pageService.canDeleteCompletely(page.path, (page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;

+ 2 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -649,12 +649,12 @@ module.exports = (crowi) => {
     const { fromPath, toPath } = req.query;
 
     try {
-      const fromPage = await Page.findByPath(fromPath);
+      const fromPage = await Page.findByPath(fromPath, true);
       if (fromPage == null) {
         return res.apiv3Err(new ErrorV3('fromPage is Null'), 400);
       }
 
-      const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
+      const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user, {}, true);
 
       const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {
         return convertToNewAffiliationPath(fromPath, toPath, subordinatedPage.path);

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

@@ -177,7 +177,7 @@ module.exports = (crowi) => {
     ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
-      body('revisionId').optional().isMongoId().withMessage('revisionId is required'), // required when v4
+      body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),

+ 41 - 27
packages/app/src/server/routes/page.js

@@ -282,6 +282,12 @@ module.exports = function(crowi, app) {
     renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
 
+  async function addRenderVarsWhenEmptyPage(renderVars, isEmpty, pageId) {
+    if (!isEmpty) return;
+    renderVars.pageId = pageId;
+    renderVars.isEmpty = isEmpty;
+  }
+
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
       return '';
@@ -336,9 +342,8 @@ module.exports = function(crowi, app) {
     const offset = parseInt(req.query.offset) || 0;
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
-
     await addRenderVarsWhenNotFound(renderVars, pathOrId);
-
+    await addRenderVarsWhenEmptyPage(renderVars, req.isEmpty, req.pageId);
     return res.render(view, renderVars);
   }
 
@@ -426,13 +431,10 @@ module.exports = function(crowi, app) {
 
     // empty page
     if (page.isEmpty) {
-      // redirect to page (path) url
-      const url = new URL('https://dummy.origin');
-      url.pathname = page.path;
-      Object.entries(req.query).forEach(([key, value], i) => {
-        url.searchParams.append(key, value);
-      });
-      return res.safeRedirect(urljoin(url.pathname, url.search));
+      req.pageId = page._id;
+      req.pagePath = page.path;
+      req.isEmpty = page.isEmpty;
+      return _notFound(req, res);
     }
 
     const { path } = page; // this must exist
@@ -605,40 +607,42 @@ module.exports = function(crowi, app) {
   async function redirector(req, res, next, path) {
     const { redirectFrom } = req.query;
 
-    const builder = new PageQueryBuilder(Page.find({ path }));
-    await Page.addConditionToFilteringByViewerForList(builder, req.user, true);
-
-    const pages = await builder.query.lean().clone().exec('find');
+    const includeEmpty = true;
+    const builder = new PageQueryBuilder(Page.find({ path }), includeEmpty);
 
-    if (pages.length >= 2) {
+    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
 
-      // populate to list
-      builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-      const identicalPathPages = await builder.query.lean().exec('find');
+    await Page.addConditionToFilteringByViewerForList(builder, req.user, true);
+    const pages = await builder.query.lean().clone().exec('find');
+    const nonEmptyPages = pages.filter(p => !p.isEmpty);
 
+    if (nonEmptyPages.length >= 2) {
       return res.render('layout-growi/identical-path-page', {
-        identicalPathPages,
+        identicalPathPages: nonEmptyPages,
         redirectFrom,
         path,
       });
     }
 
-    if (pages.length === 1) {
+    if (nonEmptyPages.length === 1) {
+      const nonEmptyPage = nonEmptyPages[0];
       const url = new URL('https://dummy.origin');
-      url.pathname = `/${pages[0]._id}`;
+
+      url.pathname = `/${nonEmptyPage._id}`;
       Object.entries(req.query).forEach(([key, value], i) => {
         url.searchParams.append(key, value);
       });
       return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
-    // Exclude isEmpty page to handle _notFound or forbidden
-    const isForbidden = await Page.exists({ path, isEmpty: false });
-    if (isForbidden) {
-      req.isForbidden = true;
+    // Processing of nonEmptyPage is finished by the time this code is read
+    // If any pages exist then they should be empty
+    const emptyPage = pages[0];
+    if (emptyPage != null) {
+      req.pageId = emptyPage._id;
+      req.isEmpty = emptyPage.isEmpty;
       return _notFound(req, res);
     }
-
     // redirect by PageRedirect
     const pageRedirect = await PageRedirect.findOne({ fromPath: path });
     if (pageRedirect != null) {
@@ -1203,11 +1207,21 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
+    let creator;
+    if (page.isEmpty) {
+      // If empty, the creator is inherited from the closest non-empty ancestor page.
+      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+      creator = notEmptyClosestAncestor.creator;
+    }
+    else {
+      creator = page.creator;
+    }
+
     debug('Delete page', page._id, page.path);
 
     try {
       if (isCompletely) {
-        if (!crowi.pageService.canDeleteCompletely(page.creator, req.user, isRecursively)) {
+        if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
@@ -1223,7 +1237,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
-        if (!crowi.pageService.canDelete(page.creator, req.user, isRecursively)) {
+        if (!crowi.pageService.canDelete(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 

+ 22 - 17
packages/app/src/server/service/page.ts

@@ -38,7 +38,7 @@ const debug = require('debug')('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 const {
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  collectAncestorPaths, isMovablePage, canMoveByPath, hasSlash, generateChildrenRegExp,
+  collectAncestorPaths, isMovablePage, canMoveByPath, isUsersProtectedPages, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -153,7 +153,9 @@ class PageService {
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
   }
 
-  canDeleteCompletely(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
+  canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
+    if (operator == null || isTopPage(path) || isUsersProtectedPages(path)) return false;
+
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
@@ -162,7 +164,9 @@ class PageService {
     return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
-  canDelete(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
+  canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
+    if (operator == null || isUsersProtectedPages(path) || isTopPage(path)) return false;
+
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
 
@@ -204,11 +208,11 @@ class PageService {
   }
 
   filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user, isRecursively));
+    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.path, p.creator, user, isRecursively));
   }
 
   filterPagesByCanDelete(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDelete(p.creator, user, isRecursively));
+    return pages.filter(p => p.isEmpty || this.canDelete(p.path, p.creator, user, isRecursively));
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -262,14 +266,24 @@ class PageService {
 
     const isBookmarked: boolean = (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
     const isLiked: boolean = page.isLiked(user);
-    const isAbleToDeleteCompletely: boolean = this.canDeleteCompletely((page.creator as IUserHasId)?._id, user, false); // use normal delete config
 
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 
+    let creatorId = page.creator;
+    if (page.isEmpty) {
+      // Need non-empty ancestor page to get its creatorId because empty page does NOT have it.
+      // Use creatorId of ancestor page to determine whether the empty page is deletable
+      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+      creatorId = notEmptyClosestAncestor.creator;
+    }
+    const isDeletable = this.canDelete(page.path, creatorId, user, false);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely(page.path, creatorId, user, false); // use normal delete config
+
     return {
       data: page,
       meta: {
         ...metadataForGuest,
+        isDeletable,
         isAbleToDeleteCompletely,
         isBookmarked,
         isLiked,
@@ -1329,14 +1343,14 @@ class PageService {
       await Page.replaceTargetWithPage(page, null, true);
     }
 
-    // Delete target
+    // Delete target (only updating an existing document's properties )
     let deletedPage;
     if (!page.isEmpty) {
       deletedPage = await this.deleteNonEmptyTarget(page, user);
     }
     else { // always recursive
       deletedPage = page;
-      await this.deleteEmptyTarget(page);
+      await Page.deleteOne({ _id: page._id, isEmpty: true });
     }
 
     // 1. Update descendantCount
@@ -1403,15 +1417,6 @@ class PageService {
     return deletedPage;
   }
 
-  private async deleteEmptyTarget(page): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    await Page.deleteOne({ _id: page._id, isEmpty: true });
-
-    // update descendantCount of ancestors' before removeLeafEmptyPages
-    await this.updateDescendantCountOfAncestors(page._id, -page.descendantCount, false);
-  }
-
   async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
     await this.deleteDescendantsWithStream(page, user, false);
 

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

@@ -943,6 +943,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
   appendHighlight(query) {
     query.body.highlight = {
+      max_analyzed_offset: 1000000 - 1, // Set the query parameter [max_analyzed_offset] to a value less than index setting [1000000] and this will tolerate long field values by truncating them.
       fields: {
         '*': {
           fragment_size: 40,

+ 1 - 0
packages/app/src/server/views/layout-growi/not_found.html

@@ -11,6 +11,7 @@
   <div
     id="growi-not-found-context"
     data-is-not-found-permalink="{% if isNotFoundPermalink %}{{isNotFoundPermalink|json}}{% endif %}"
+    data-page-id="{%if pageId %}{{pageId.toString()}}{% endif %}"
   >
   </div>
   <div class="grw-container-convertible">

+ 1 - 0
packages/app/src/server/views/widget/not_found_content.html

@@ -4,6 +4,7 @@
   data-page-grant="{{ grant }}"
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
+  data-page-is-empty="{{ isEmpty }}"
   {% if templateTags %}
     data-template-tags="{{ templateTags }}"
   {% endif %}

+ 7 - 0
packages/app/src/stores/context.tsx

@@ -36,6 +36,10 @@ export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nu
   return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 
+export const useEmptyPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('emptyPageId', initialData);
+};
+
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData);
 };
@@ -156,6 +160,9 @@ export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRRespon
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 
+export const useIsEmptyPage = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isEmptyPage', initialData);
+};
 export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('hasParent', initialData);
 };

+ 73 - 3
packages/app/src/stores/ui.tsx

@@ -10,13 +10,15 @@ import useSWRImmutable from 'swr/immutable';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { Nullable } from '~/interfaces/common';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import {
-  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
+  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
   useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser, useIsDeleted,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
@@ -278,6 +280,72 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
   );
 };
 
+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<ISidebarConfig>(
+    '/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;
+
+      const updateData = {
+        isSidebarDrawerMode,
+        isSidebarClosedAtDockMode,
+      };
+
+      // invoke API
+      await apiv3Put('/customize-setting/sidebar', updateData);
+    },
+    isSidebarDrawerMode: swrResponse.data?.isSidebarDrawerMode,
+    isSidebarClosedAtDockMode: swrResponse.data?.isSidebarClosedAtDockMode,
+    setIsSidebarDrawerMode: (isSidebarDrawerMode) => {
+      const { data, mutate } = swrResponse;
+
+      if (data == null) {
+        return;
+      }
+
+      const updateData = {
+        isSidebarDrawerMode,
+      };
+
+      // update isSidebarDrawerMode in cache, not revalidate
+      mutate({ ...data, ...updateData }, false);
+
+    },
+    setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode) => {
+      const { data, mutate } = swrResponse;
+
+      if (data == null) {
+        return;
+      }
+
+      const updateData = {
+        isSidebarClosedAtDockMode,
+      };
+
+      // update isSidebarClosedAtDockMode in cache, not revalidate
+      mutate({ ...data, ...updateData }, false);
+    },
+  };
+};
+
 export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR('isDrawerOpened', isOpened, { fallbackData: false });
 };
@@ -336,11 +404,13 @@ export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageManagement';
   const { data: currentPageId } = useCurrentPageId();
+  const { data: emptyPageId } = useEmptyPageId();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
 
-  const includesUndefined = [currentPageId, isTrashPage, isSharedUser].some(v => v === undefined);
-  const isPageExist = currentPageId != null;
+  const pageId = currentPageId ?? emptyPageId;
+  const includesUndefined = [pageId, isTrashPage, isSharedUser].some(v => v === undefined);
+  const isPageExist = pageId != null;
 
   return useSWRImmutable(
     includesUndefined ? null : key,

+ 7 - 0
packages/app/src/styles/_admin.scss

@@ -59,6 +59,13 @@ $slack-work-space-name-card-border: #efc1f6;
     }
   }
 
+  .admin-customize-sidebar-icon {
+    svg {
+      width: 20px;
+      height: 20px;
+    }
+  }
+
   .admin-notification {
     table .admin-notif-list {
       td {

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio