Procházet zdrojové kódy

Merge branch 'dev/7.0.x' into imprv/139435-140259-update-side-contents-tag-outline

ryoji-s před 2 roky
rodič
revize
9dce98c124
97 změnil soubory, kde provedl 1930 přidání a 1737 odebrání
  1. 3 2
      apps/app/package.json
  2. binární
      apps/app/public/images/icons/sublime.png
  3. binární
      apps/app/public/images/icons/vscode.png
  4. 2 0
      apps/app/src/client/services/create-page/index.ts
  5. 110 0
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  6. 38 0
      apps/app/src/client/services/create-page/use-create-template-page.ts
  7. 18 78
      apps/app/src/client/services/page-operation.ts
  8. 10 27
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  9. 11 28
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  10. 0 123
      apps/app/src/client/services/use-create-page-and-transit.tsx
  11. 0 55
      apps/app/src/client/services/use-on-template-button-clicked.ts
  12. 1 1
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  13. 3 2
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  14. 36 20
      apps/app/src/components/Common/ClosableTextInput.tsx
  15. 2 1
      apps/app/src/components/Common/CountBadge.tsx
  16. 5 0
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  17. 13 4
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  18. 0 6
      apps/app/src/components/ContentLinkButtons.module.scss
  19. 12 17
      apps/app/src/components/ContentLinkButtons.tsx
  20. 13 8
      apps/app/src/components/CreateTemplateModal.tsx
  21. 5 12
      apps/app/src/components/CustomNavigation/CustomNav.module.scss
  22. 3 3
      apps/app/src/components/CustomNavigation/CustomNav.tsx
  23. 13 13
      apps/app/src/components/Me/BasicInfoSettings.tsx
  24. 6 6
      apps/app/src/components/Me/PersonalSettings.jsx
  25. 19 19
      apps/app/src/components/Me/ProfileImageSettings.tsx
  26. 2 2
      apps/app/src/components/Me/UserSettings.tsx
  27. 24 23
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  28. 6 6
      apps/app/src/components/PageDuplicateModal.tsx
  29. 2 2
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  30. 24 57
      apps/app/src/components/PageEditor/PageEditor.tsx
  31. 2 0
      apps/app/src/components/PageHeader/PageHeader.module.scss
  32. 10 8
      apps/app/src/components/PageHeader/PageHeader.tsx
  33. 19 0
      apps/app/src/components/PageHeader/PagePathHeader.module.scss
  34. 97 83
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  35. 9 0
      apps/app/src/components/PageHeader/PageTitleHeader.module.scss
  36. 90 17
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  37. 0 76
      apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx
  38. 7 5
      apps/app/src/components/PageHeader/page-header-utils.ts
  39. 3 2
      apps/app/src/components/PageHistory/RevisionDiff.tsx
  40. 3 2
      apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx
  41. 1 0
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  42. 13 7
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  43. 28 22
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  44. 27 39
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  45. 0 109
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  46. 2 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/index.ts
  47. 29 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  48. 45 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  49. 51 44
      apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  50. 2 1
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx
  51. 2 1
      apps/app/src/components/Sidebar/SidebarNav/SidebarNav.tsx
  52. 1 1
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  53. 1 1
      apps/app/src/components/TreeItem/SimpleItem.tsx
  54. 1 0
      apps/app/src/interfaces/apiv3/index.ts
  55. 37 0
      apps/app/src/interfaces/apiv3/page.ts
  56. 1 1
      apps/app/src/interfaces/editor-settings.ts
  57. 0 10
      apps/app/src/interfaces/page-operation.ts
  58. 17 2
      apps/app/src/interfaces/page.ts
  59. 2 2
      apps/app/src/models/linked-page-path.js
  60. 6 4
      apps/app/src/pages/_private-legacy-pages.page.tsx
  61. 6 4
      apps/app/src/pages/_search.page.tsx
  62. 10 8
      apps/app/src/pages/me/[[...path]].page.tsx
  63. 9 6
      apps/app/src/pages/tags.page.tsx
  64. 0 43
      apps/app/src/server/models/interfaces/page-operation.ts
  65. 35 6
      apps/app/src/server/models/page-operation.ts
  66. 5 11
      apps/app/src/server/models/page.ts
  67. 8 6
      apps/app/src/server/routes/apiv3/page-listing.ts
  68. 54 0
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  69. 254 0
      apps/app/src/server/routes/apiv3/page/create-page.ts
  70. 0 234
      apps/app/src/server/routes/apiv3/page/cteate-page.ts
  71. 109 0
      apps/app/src/server/routes/apiv3/page/index.js
  72. 172 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  73. 0 58
      apps/app/src/server/routes/apiv3/pages/index.js
  74. 0 2
      apps/app/src/server/routes/index.js
  75. 12 227
      apps/app/src/server/routes/page.js
  76. 40 48
      apps/app/src/server/service/installer.ts
  77. 3 3
      apps/app/src/server/service/page-grant.ts
  78. 77 65
      apps/app/src/server/service/page/index.ts
  79. 15 4
      apps/app/src/server/service/page/page-service.ts
  80. 8 4
      apps/app/test/integration/service/page.test.js
  81. 59 2
      apps/app/test/integration/service/v5.non-public-page.test.ts
  82. 10 3
      packages/core/src/models/devided-page-path.ts
  83. 3 0
      packages/core/src/utils/page-path-utils/index.spec.ts
  84. 4 3
      packages/core/src/utils/page-path-utils/index.ts
  85. 8 0
      packages/custom-icons/svg/recently_created.svg
  86. 3 0
      packages/editor/package.json
  87. 26 1
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  88. 4 1
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  89. 3 1
      packages/editor/src/components/playground/Playground.tsx
  90. 27 28
      packages/editor/src/components/playground/PlaygroundController.tsx
  91. 4 4
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  92. 1 0
      packages/editor/src/services/index.ts
  93. 26 0
      packages/editor/src/services/keymaps/index.ts
  94. 13 0
      packages/editor/src/services/keymaps/vim.ts
  95. 6 6
      packages/preset-themes/src/styles/default.scss
  96. 7 7
      packages/ui/src/components/Attachment.tsx
  97. 22 0
      yarn.lock

+ 3 - 2
apps/app/package.json

@@ -69,8 +69,8 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/custom-icons": "link:../../packages/custom-icons",
     "@growi/core": "link:../../packages/core",
+    "@growi/custom-icons": "link:../../packages/custom-icons",
     "@growi/pluginkit": "link:../../packages/pluginkit",
     "@growi/preset-templates": "link:../../packages/preset-templates",
     "@growi/preset-themes": "link:../../packages/preset-themes",
@@ -235,8 +235,8 @@
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
-    "@types/url-join": "^4.0.2",
     "@types/unzip-stream": "^0.3.4",
+    "@types/url-join": "^4.0.2",
     "@vitejs/plugin-react": "^4.2.1",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
@@ -270,6 +270,7 @@
     "react-copy-to-clipboard": "^5.0.1",
     "react-dropzone": "^11.2.4",
     "react-hotkeys": "^2.0.0",
+    "react-input-autosize": "^3.0.0",
     "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "sass": "^1.53.0",

binární
apps/app/public/images/icons/sublime.png


binární
apps/app/public/images/icons/vscode.png


+ 2 - 0
apps/app/src/client/services/create-page/index.ts

@@ -0,0 +1,2 @@
+export * from './use-create-page-and-transit';
+export * from './use-create-template-page';

+ 110 - 0
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -0,0 +1,110 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useCurrentPagePath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+type CreatePageAndTransitOpts = {
+  shouldCheckPageExists?: boolean,
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePageAndTransit = (
+  params: IApiv3PageCreateParams,
+  opts?: CreatePageAndTransitOpts,
+) => Promise<void>;
+
+type UseCreatePageAndTransit = () => {
+  isCreating: boolean,
+  createAndTransit: CreatePageAndTransit,
+};
+
+export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
+
+  const router = useRouter();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const [isCreating, setCreating] = useState(false);
+
+  const createAndTransit: CreatePageAndTransit = useCallback(async(params, opts = {}) => {
+    const {
+      shouldCheckPageExists,
+      onCreationStart, onCreated, onAborted, onTerminated,
+    } = opts;
+
+    // check the page existence
+    if (shouldCheckPageExists && params.path != null) {
+      const pagePath = params.path;
+
+      try {
+        const { isExist } = await exist(pagePath);
+
+        if (isExist) {
+          // routing
+          if (pagePath !== currentPagePath) {
+            await router.push(`${pagePath}#edit`);
+          }
+          mutateEditorMode(EditorMode.Editor);
+          onAborted?.();
+          return;
+        }
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+      }
+    }
+
+    // create and transit
+    try {
+      setCreating(true);
+      onCreationStart?.();
+
+      const response = await createPage(params);
+
+      await router.push(`/${response.page._id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
+
+      onCreated?.();
+    }
+    catch (err) {
+      throw err;
+    }
+    finally {
+      onTerminated?.();
+      setCreating(false);
+    }
+
+  }, [currentPagePath, mutateEditorMode, router]);
+
+  return {
+    isCreating,
+    createAndTransit,
+  };
+};

+ 38 - 0
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
+
+import type { LabelType } from '~/interfaces/template';
+import { useCurrentPagePath } from '~/stores/page';
+
+import { useCreatePageAndTransit } from './use-create-page-and-transit';
+
+type UseCreateTemplatePage = () => {
+  isCreatable: boolean,
+  isCreating: boolean,
+  createTemplate?: (label: LabelType) => Promise<void>,
+}
+
+export const useCreateTemplatePage: UseCreateTemplatePage = () => {
+
+  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
+
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
+
+  const createTemplate = useCallback(async(label: LabelType) => {
+    if (isLoadingPagePath || !isCreatable) return;
+
+    return createAndTransit(
+      { path: normalizePath(`${currentPagePath}/${label}`) },
+      { shouldCheckPageExists: true },
+    );
+  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+
+  return {
+    isCreatable,
+    isCreating,
+    createTemplate: isCreatable ? createTemplate : undefined,
+  };
+};

+ 18 - 78
apps/app/src/client/services/page-operation.ts

@@ -1,20 +1,23 @@
 import { useCallback } from 'react';
 
-import { SubscriptionStatusType, type Nullable } from '@growi/core';
+import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 
-import { OptionsToSave } from '~/interfaces/page-operation';
-import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import type {
+  IApiv3PageCreateParams, IApiv3PageCreateResponse, IApiv3PageUpdateParams, IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
+import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
 import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { apiGet, apiPost } from '../util/apiv1-client';
-import { apiv3Post, apiv3Put } from '../util/apiv3-client';
+import { apiPost } from '../util/apiv1-client';
+import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
 import { toastError } from '../util/toastr';
 
 const logger = loggerFactory('growi:services:page-operation');
 
+
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
     const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
@@ -87,71 +90,14 @@ export const resumeRenameOperation = async(pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 
-// TODO: define return type
-export const createPage = async(pagePath: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    path: pagePath,
-    body: markdown,
-  });
-
-  const res = await apiv3Post('/pages/', params);
-  const { page, tags, revision } = res.data;
-
-  return { page, tags, revision };
+export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
+  const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
+  return res.data;
 };
 
-// TODO: define return type
-const updatePage = async(pageId: string, revisionId: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    page_id: pageId,
-    revision_id: revisionId,
-    body: markdown,
-  });
-
-  const res: any = await apiPost('/pages.update', params);
-  if (!res.ok) {
-    throw new Error(res.error);
-  }
-  return res;
-};
-
-type PageInfo= {
-  path: string,
-  pageId: Nullable<string>,
-  revisionId: Nullable<string>,
-}
-
-type SaveOrUpdateFunction = (markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => any;
-
-// TODO: define return type
-export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
-  /* eslint-disable react-hooks/rules-of-hooks */
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  /* eslint-enable react-hooks/rules-of-hooks */
-
-  return useCallback(async(markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => {
-    const { path, pageId, revisionId } = pageInfo;
-
-    const options: OptionsToSave = Object.assign({}, optionsToSave);
-
-    let res;
-    if (pageId == null || revisionId == null) {
-      res = await createPage(path, markdown, options);
-    }
-    else {
-      if (revisionId == null) {
-        const msg = '\'revisionId\' is required to update page';
-        throw new Error(msg);
-      }
-      res = await updatePage(pageId, revisionId, markdown, options);
-    }
-
-    mutateIsEnabledUnsavedWarning(false);
-
-    return res;
-  }, [mutateIsEnabledUnsavedWarning]);
+export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
+  return res.data;
 };
 
 export type UpdateStateAfterSaveOption = {
@@ -205,17 +151,11 @@ export const unlink = async(path: string): Promise<void> => {
 };
 
 
-interface PageExistRequest {
-  pagePaths: string;
-}
-
 interface PageExistResponse {
-  pages: Record<string, boolean>;
-  ok: boolean
+  isExist: boolean,
 }
 
-export const exist = async(pagePaths: string): Promise<PageExistResponse> => {
-  const request: PageExistRequest = { pagePaths };
-  const res = await apiGet<PageExistResponse>('/pages.exist', request);
-  return res;
+export const exist = async(path: string): Promise<PageExistResponse> => {
+  const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
+  return res.data;
 };

+ 10 - 27
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -1,17 +1,17 @@
 import { useCallback, useEffect } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useDrawioModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
+import { updatePage } from '../page-operation';
+
 
 const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
 
@@ -33,8 +33,6 @@ export const useDrawioModalLauncherForView = (opts?: {
 
   const { open: openDrawioModal } = useDrawioModal();
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
     if (currentPage == null || shareLinkId != null) {
       return;
@@ -43,28 +41,13 @@ export const useDrawioModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
       opts?.onSaveSuccess?.();
     }
@@ -72,7 +55,7 @@ export const useDrawioModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
   // set handler to open DrawioModal

+ 11 - 28
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -1,16 +1,16 @@
 import { useCallback, useEffect } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
-import MarkdownTable from '~/client/models/MarkdownTable';
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import type MarkdownTable from '~/client/models/MarkdownTable';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useHandsontableModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
+import { updatePage } from '../page-operation';
+
 
 const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
 
@@ -32,8 +32,6 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
     if (currentPage == null || shareLinkId != null) {
       return;
@@ -42,28 +40,13 @@ export const useHandsontableModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
       opts?.onSaveSuccess?.();
     }
@@ -71,7 +54,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
   // set handler to open HandsonTableModal

+ 0 - 123
apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -1,123 +0,0 @@
-import { useCallback } from 'react';
-
-import { useRouter } from 'next/router';
-
-import { createPage } from '~/client/services/page-operation';
-import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
-/**
- * Invoked when creation and transition has finished
- */
-type OnCreated = () => void;
-/**
- * Invoked when either creation or transition has aborted
- */
-type OnAborted = () => void;
-/**
- * Invoked when an error is occured
- */
-type OnError = (err) => void;
-/**
- * Always invoked after processing is terminated
- */
-type OnTerminated = () => void;
-
-type CreatePageAndTransitOpts = {
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onError?: OnError,
-  onTerminated?: OnTerminated,
-}
-
-type CreatePageAndTransit = (
-  pagePath: string | undefined,
-  // grant?: number,
-  // grantUserGroupId?: string,
-  opts?: CreatePageAndTransitOpts,
-) => Promise<void>;
-
-export const useCreatePageAndTransit = (): CreatePageAndTransit => {
-
-  const router = useRouter();
-
-  const { data: isNotFound } = useIsNotFound();
-  const { data: currentPage, isLoading } = useSWRxCurrentPage();
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  // const {
-  //   path: currentPagePath,
-  //   grant: currentPageGrant,
-  //   grantedGroups: currentPageGrantedGroups,
-  // } = currentPage ?? {};
-
-  return useCallback(async(pagePath, opts = {}) => {
-    if (isLoading) {
-      return;
-    }
-
-    const {
-      onCreationStart, onCreated, onAborted, onError, onTerminated,
-    } = opts;
-
-    if (isNotFound == null || !isNotFound || pagePath == null) {
-      mutateEditorMode(EditorMode.Editor);
-
-      onAborted?.();
-      onTerminated?.();
-      return;
-    }
-
-    try {
-      onCreationStart?.();
-
-      /**
-       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
-       * since the new page path is not generated on the client side.
-       * need shouldGeneratePath flag.
-       */
-      // const shouldCreateUnderRoot = currentPagePath == null || currentPageGrant == null;
-      // const parentPath = shouldCreateUnderRoot
-      //   ? '/'
-      //   : currentPagePath;
-
-      // const params = {
-      //   isSlackEnabled: false,
-      //   slackChannels: '',
-      //   grant: shouldCreateUnderRoot ? 1 : currentPageGrant,
-      //   grantUserGroupIds: shouldCreateUnderRoot ? undefined : currentPageGrantedGroups,
-      //   shouldGeneratePath: true,
-      // };
-
-      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
-      // const response = await createPage(parentPath, '', params);
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant,
-        // grantUserGroupId,
-      };
-
-      const response = await createPage(pagePath, '', params);
-
-      await router.push(`${response.page.id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-
-      onCreated?.();
-    }
-    catch (err) {
-      logger.warn(err);
-      onError?.(err);
-    }
-    finally {
-      onTerminated?.();
-    }
-
-  }, [isLoading, isNotFound, mutateEditorMode, router]);
-};

+ 0 - 55
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -1,55 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
-import { useRouter } from 'next/router';
-
-import { createPage, exist } from '~/client/services/page-operation';
-import type { LabelType } from '~/interfaces/template';
-
-export const useOnTemplateButtonClicked = (
-    currentPagePath?: string,
-    isLoading?: boolean,
-): {
-  onClickHandler: (label: LabelType) => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const onClickHandler = useCallback(async(label: LabelType) => {
-    if (isLoading) return;
-
-    try {
-      setIsPageCreating(true);
-
-      const targetPath = currentPagePath == null || currentPagePath === '/'
-        ? `/${label}`
-        : `${currentPagePath}/${label}`;
-
-      const path = isCreatablePage(targetPath) ? targetPath : `/${label}`;
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        const params = {
-          isSlackEnabled: false,
-          slackChannels: '',
-          grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-        };
-
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      throw err;
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPagePath, isLoading, router]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 1 - 1
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -46,7 +46,7 @@ const MenuLink = ({
 }: MenuLinkProps) => {
 
   const pageTransitionClassName = isListGroupItems
-    ? 'list-group-item list-group-item-action border-0 round-corner'
+    ? 'list-group-item list-group-item-action rounded border-0'
     : 'dropdown-item px-3 py-2';
 
   const href = isRoot ? '/admin' : urljoin('/admin', menu);

+ 3 - 2
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -14,7 +14,8 @@ import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
+import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { usePutBackPageModal } from '~/stores/modal';
 import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
@@ -191,7 +192,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           fade={false}
         >
-          {formerPagePath !== null ? `${formerPagePath}/` : '/'}
+          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
       </li>
     </DragAndDropWrapper>

+ 36 - 20
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -1,18 +1,24 @@
+import type { FC } from 'react';
 import React, {
-  FC, memo, useEffect, useRef, useState,
+  memo, useEffect, useRef, useState,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import AutosizeInput from 'react-input-autosize';
 
-import { AlertInfo, AlertType, inputValidator } from '~/client/util/input-validator';
+import type { AlertInfo } from '~/client/util/input-validator';
+import { AlertType, inputValidator } from '~/client/util/input-validator';
 
 type ClosableTextInputProps = {
   value?: string
   placeholder?: string
   validationTarget?: string,
+  useAutosizeInput?: boolean
+  inputClassName?: string,
   onPressEnter?(inputText: string | null): void
+  onPressEscape?: () => void
   onClickOutside?(): void
-  handleInputChange?: (string) => void
+  onChange?(inputText: string): void
 }
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
@@ -40,7 +46,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     setInputText(inputText);
     setIsAbleToShowAlert(true);
 
-    props.handleInputChange?.(inputText);
+    props.onChange?.(inputText);
   };
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -66,6 +72,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         }
         onPressEnter();
         break;
+      case 'Escape':
+        if (isComposing) {
+          return;
+        }
+        props.onPressEscape?.();
+        break;
       default:
         break;
     }
@@ -106,25 +118,29 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     );
   };
 
+  const inputProps = {
+    'data-testid': 'closable-text-input',
+    value: inputText || '',
+    ref: inputRef,
+    type: 'text',
+    placeholder: props.placeholder,
+    name: 'input',
+    onFocus: onFocusHandler,
+    onChange: onChangeHandler,
+    onKeyDown: onKeyDownHandler,
+    onCompositionStart: () => setComposing(true),
+    onCompositionEnd: () => setComposing(false),
+    onBlur: onBlurHandler,
+  };
+
+  const inputClassName = `form-control ${props.inputClassName ?? ''}`;
 
   return (
     <div>
-      <input
-        value={inputText || ''}
-        ref={inputRef}
-        type="text"
-        className="form-control"
-        placeholder={props.placeholder}
-        name="input"
-        data-testid="closable-text-input"
-        onFocus={onFocusHandler}
-        onChange={onChangeHandler}
-        onKeyDown={onKeyDownHandler}
-        onCompositionStart={() => setComposing(true)}
-        onCompositionEnd={() => setComposing(false)}
-        onBlur={onBlurHandler}
-        autoFocus={false}
-      />
+      { props.useAutosizeInput
+        ? <AutosizeInput inputClassName={inputClassName} {...inputProps} />
+        : <input className={inputClassName} {...inputProps} />
+      }
       {isAbleToShowAlert && <AlertInfo />}
     </div>
   );

+ 2 - 1
apps/app/src/components/Common/CountBadge.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 type CountProps = {
   count?: number,

+ 5 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -6,6 +6,11 @@
   margin-right: 0.2em;
 }
 
+.grw-mx-02em {
+  margin-right: 0.2em;
+  margin-left: 0.2em;
+}
+
 .grw-page-path-nav-sticky :global {
   min-height: 75px;
 

+ 13 - 4
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -27,9 +28,12 @@ type Props = {
 
 const CopyDropdown = dynamic(() => import('../CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
 
-const Separator = (): JSX.Element => {
+const RootSlash = (): JSX.Element => {
   return <span className={styles['grw-mr-02em']}>/</span>;
 };
+const Separator = (): JSX.Element => {
+  return <span className={styles['grw-mx-02em']}>/</span>;
+};
 
 export const PagePathNav: FC<Props> = (props: Props) => {
   const {
@@ -66,10 +70,15 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />;
-    latterLink = (
+    formerLink = (
       <>
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />
         <Separator />
+      </>
+    );
+    latterLink = (
+      <>
+        <RootSlash />
         <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />
       </>
     );

+ 0 - 6
apps/app/src/components/ContentLinkButtons.module.scss

@@ -1,6 +0,0 @@
-.grw-icon-container-recently-created :global {
-  svg {
-    width: 14px;
-    height: 14px;
-  }
-}

+ 12 - 17
apps/app/src/components/ContentLinkButtons.tsx

@@ -1,22 +1,19 @@
 import React from 'react';
 
-import type { IUserHasId } from '@growi/core';
+import { USER_STATUS, type IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
 import { Link as ScrollLink } from 'react-scroll';
 
-import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-
-import styles from './ContentLinkButtons.module.scss';
-
 const BookMarkLinkButton = React.memo(() => {
-
+  const { t } = useTranslation();
   return (
     <ScrollLink to="bookmarks-list" offset={-120}>
       <button
         type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
+        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
-        <span className="material-symbols-outlined">bookmark</span>
-        <span>Bookmarks</span>
+        <span className="material-symbols-outlined p-0">bookmark</span>
+        <span>{t('footer.bookmarks')}</span>
       </button>
     </ScrollLink>
   );
@@ -25,15 +22,15 @@ const BookMarkLinkButton = React.memo(() => {
 BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 
 const RecentlyCreatedLinkButton = React.memo(() => {
-
+  const { t } = useTranslation();
   return (
     <ScrollLink to="recently-created-list" offset={-120}>
       <button
         type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
+        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
-        <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created me-2`}><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
+        <span className="growi-custom-icons mx-1">recently_created</span>
+        <span>{t('footer.recently_created')}</span>
       </button>
     </ScrollLink>
   );
@@ -47,18 +44,16 @@ export type ContentLinkButtonsProps = {
 }
 
 export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
-
   const { author } = props;
 
-  if (author == null || author.status === 4) {
+  if (author == null || author.status === USER_STATUS.DELETED) {
     return <></>;
   }
 
   return (
-    <div className="mt-3 d-flex justify-content-between">
+    <div className="d-grid gap-2">
       <BookMarkLinkButton />
       <RecentlyCreatedLinkButton />
     </div>
   );
-
 };

+ 13 - 8
apps/app/src/components/CreateTemplateModal.tsx

@@ -4,9 +4,9 @@ import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
-import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
-import { TargetType, LabelType } from '~/interfaces/template';
+import type { TargetType, LabelType } from '~/interfaces/template';
 
 
 type TemplateCardProps = {
@@ -53,18 +53,19 @@ type CreateTemplateModalProps = {
 export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
   path, isOpen, onClose,
 }) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
 
-  const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
+  const { createTemplate, isCreating, isCreatable } = useCreateTemplatePage();
 
   const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
     try {
-      await onClickTemplateButton(label);
+      await createTemplate?.(label);
+      onClose();
     }
     catch (err) {
-      toastError(err);
+      toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [onClickTemplateButton]);
+  }, [createTemplate, path, t]);
 
   const parentPath = pathUtils.addTrailingSlash(path);
 
@@ -73,12 +74,16 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
       <TemplateCard
         target={target}
         label={label}
-        isPageCreating={isPageCreating}
+        isPageCreating={isCreating}
         onClickHandler={() => onClickTemplateButtonHandler(label)}
       />
     </div>
   );
 
+  if (!isCreatable) {
+    return <></>;
+  }
+
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
       <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">

+ 5 - 12
apps/app/src/components/CustomNavigation/CustomNav.module.scss

@@ -1,15 +1,3 @@
-.grw-custom-nav-tab,
-.grw-custom-nav-dropdown {
-  :global {
-    svg {
-      width: 17px;
-      height: 17px;
-      margin-right: 5px;
-      vertical-align: text-bottom;
-    }
-  }
-}
-
 .grw-custom-nav-tab :global {
   .nav-title {
     flex-wrap: nowrap;
@@ -24,4 +12,9 @@
     border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
   }
+
+  .material-symbols-outlined {
+    margin-right: 6px;
+    font-size: 18px;
+  }
 }

+ 3 - 3
apps/app/src/components/CustomNavigation/CustomNav.tsx

@@ -2,12 +2,12 @@ import React, {
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
 
-import { Breakpoint } from '@growi/ui/dist/interfaces';
+import type { Breakpoint } from '@growi/ui/dist/interfaces';
 import {
   Nav, NavItem, NavLink,
 } from 'reactstrap';
 
-import { ICustomNavTabMappings } from '~/interfaces/ui';
+import type { ICustomNavTabMappings } from '~/interfaces/ui';
 
 import styles from './CustomNav.module.scss';
 
@@ -49,7 +49,7 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
   }, [onNavSelected]);
 
   return (
-    <div className="grw-custom-nav-dropdown btn-group">
+    <div className="btn-group">
       <button
         className="btn btn-outline-primary btn-lg dropdown-toggle text-end"
         type="button"

+ 13 - 13
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -49,7 +49,7 @@ export const BasicInfoSettings = (): JSX.Element => {
   return (
     <>
 
-      <div className="row">
+      <div className="row mt-3 mt-md-4">
         <label htmlFor="userForm[name]" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
         <div className="col-md-6">
           <input
@@ -62,7 +62,7 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
       </div>
 
-      <div className="row">
+      <div className="row mt-3">
         <label htmlFor="userForm[email]" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
         <div className="col-md-6">
           <input
@@ -83,10 +83,10 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
       </div>
 
-      <div className="row">
+      <div className="row mt-3">
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Disclose E-mail')}</label>
-        <div className="col-md-6">
-          <div className="form-check form-check-inline">
+        <div className="col-md-6 my-auto">
+          <div className="form-check form-check-inline me-4">
             <input
               type="radio"
               id="radioEmailShow"
@@ -95,7 +95,7 @@ export const BasicInfoSettings = (): JSX.Element => {
               checked={personalSettingsInfo?.isEmailPublished === true}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
             />
-            <label className="form-label form-check-label" htmlFor="radioEmailShow">{t('Show')}</label>
+            <label className="form-label form-check-label mb-0" htmlFor="radioEmailShow">{t('Show')}</label>
           </div>
           <div className="form-check form-check-inline">
             <input
@@ -106,21 +106,21 @@ export const BasicInfoSettings = (): JSX.Element => {
               checked={personalSettingsInfo?.isEmailPublished === false}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
             />
-            <label className="form-label form-check-label" htmlFor="radioEmailHide">{t('Hide')}</label>
+            <label className="form-label form-check-label mb-0" htmlFor="radioEmailHide">{t('Hide')}</label>
           </div>
         </div>
       </div>
 
-      <div className="row">
+      <div className="row mt-3">
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Language')}</label>
-        <div className="col-md-6">
+        <div className="col-md-6 my-auto">
           {
             i18nConfig.locales.map((locale) => {
               if (i18n == null) { return }
               const fixedT = i18n.getFixedT(locale);
 
               return (
-                <div key={locale} className="form-check form-check-inline">
+                <div key={locale} className="form-check form-check-inline me-4">
                   <input
                     type="radio"
                     id={`radioLang${locale}`}
@@ -129,14 +129,14 @@ export const BasicInfoSettings = (): JSX.Element => {
                     checked={personalSettingsInfo?.lang === locale}
                     onChange={() => changePersonalSettingsHandler({ lang: locale })}
                   />
-                  <label className="form-label form-check-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
+                  <label className="form-label form-check-label mb-0" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
                 </div>
               );
             })
           }
         </div>
       </div>
-      <div className="row">
+      <div className="row mt-3">
         <label htmlFor="userForm[slackMemberId]" className="text-start text-md-end col-md-3 col-form-label">{t('Slack Member ID')}</label>
         <div className="col-md-6">
           <input
@@ -150,7 +150,7 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
       </div>
 
-      <div className="row my-3">
+      <div className="row mt-4">
         <div className="offset-4 col-5">
           <button
             data-testid="grw-besic-info-settings-update-button"

+ 6 - 6
apps/app/src/components/Me/PersonalSettings.jsx

@@ -20,22 +20,22 @@ const PersonalSettings = () => {
   const navTabMapping = useMemo(() => {
     return {
       user_infomation: {
-        Icon: () => <i className="icon-fw icon-user"></i>,
+        Icon: () => <span className="material-symbols-outlined">person</span>,
         Content: UserSettings,
         i18n: t('User Information'),
       },
       external_accounts: {
-        Icon: () => <i className="icon-fw icon-share-alt"></i>,
+        Icon: () => <span className="material-symbols-outlined">ungroup</span>,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
       },
       password_settings: {
-        Icon: () => <i className="icon-fw icon-lock"></i>,
+        Icon: () => <span className="material-symbols-outlined">password</span>,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
       },
       api_settings: {
-        Icon: () => <i className="icon-fw icon-paper-plane"></i>,
+        Icon: () => <span className="material-symbols-outlined">api</span>,
         Content: ApiSettings,
         i18n: t('API Settings'),
       },
@@ -45,12 +45,12 @@ const PersonalSettings = () => {
       //   i18n: t('editor_settings.editor_settings'),
       // },
       in_app_notification_settings: {
-        Icon: () => <i className="icon-fw icon-bell"></i>,
+        Icon: () => <span className="material-symbols-outlined">notifications</span>,
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
       other_settings: {
-        Icon: () => <i className="icon-fw icon-settings"></i>,
+        Icon: () => <span className="material-symbols-outlined">settings</span>,
         Content: OtherSettings,
         i18n: t('Other Settings'),
       },

+ 19 - 19
apps/app/src/components/Me/ProfileImageSettings.tsx

@@ -91,9 +91,9 @@ const ProfileImageSettings = (): JSX.Element => {
 
   return (
     <>
-      <div className="row">
-        <div className="col-md-6 col-12 mb-3 mb-md-0">
-          <h4>
+      <div className="row justify-content-around mt-5 mt-md-4">
+        <div className="col-md-3">
+          <h5>
             <div className="form-check radio-primary">
               <input
                 type="radio"
@@ -105,18 +105,18 @@ const ProfileImageSettings = (): JSX.Element => {
                 onChange={() => setGravatarEnabled(true)}
               />
               <label className="form-label form-check-label" htmlFor="radioGravatar">
-                <img src={GRAVATAR_DEFAULT} data-vrt-blackout-profile /> Gravatar
+                <img src={GRAVATAR_DEFAULT} className="me-1" data-vrt-blackout-profile /> Gravatar
               </label>
-              <a href="https://gravatar.com/">
-                <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
+              <a href="https://gravatar.com/" target="_blank" rel="noopener noreferrer">
+                <small><span className="material-symbols-outlined ms-2 text-secondary" aria-hidden="true">info</span></small>
               </a>
             </div>
-          </h4>
-          <img src={generateGravatarSrc(currentUser.email)} width="64" data-vrt-blackout-profile />
+          </h5>
+          <img src={generateGravatarSrc(currentUser.email)} className="rounded-pill" width="64" data-vrt-blackout-profile />
         </div>
 
-        <div className="col-md-6 col-12">
-          <h4>
+        <div className="col-md-7 mt-5">
+          <h5>
             <div className="form-check radio-primary">
               <input
                 type="radio"
@@ -131,21 +131,21 @@ const ProfileImageSettings = (): JSX.Element => {
                 { t('Upload Image') }
               </label>
             </div>
-          </h4>
-          <div className="row mb-3">
-            <label className="col-sm-4 col-12 col-form-label text-start">
+          </h5>
+          <div className="row mt-3">
+            <label className="col-md-6 col-lg-4 col-form-label text-start">
               { t('Current Image') }
             </label>
-            <div className="col-sm-8 col-12">
-              <p><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
+            <div className="col-md-6 col-lg-8">
+              <p className="mb-0"><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
               {uploadedPictureSrc && <button type="button" className="btn btn-danger" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
             </div>
           </div>
-          <div className="row">
-            <label className="col-sm-4 col-12 col-form-label text-start">
+          <div className="row align-items-center mt-3 mt-md-5">
+            <label className="col-md-6 col-lg-4 col-form-label text-start mt-3 mt-md-0">
               {t('Upload new image')}
             </label>
-            <div className="col-sm-8 col-12">
+            <div className="col-md-6 col-lg-8">
               <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
             </div>
           </div>
@@ -161,7 +161,7 @@ const ProfileImageSettings = (): JSX.Element => {
         showCropOption
       />
 
-      <div className="row my-3">
+      <div className="row mt-4">
         <div className="offset-4 col-5">
           <button type="button" className="btn btn-primary" onClick={submit}>
             {t('Update')}

+ 2 - 2
apps/app/src/components/Me/UserSettings.tsx

@@ -11,11 +11,11 @@ const UserSettings = React.memo((): JSX.Element => {
   return (
     <div data-testid="grw-user-settings">
       <div className="mb-5">
-        <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
+        <h2 className="border-bottom fs-4 mt-4 pb-1">{t('Basic Info')}</h2>
         <BasicInfoSettings />
       </div>
       <div className="mb-5">
-        <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
+        <h2 className="border-bottom fs-4 mt-3 mt-md-5 pb-1">{t('Set Profile Image')}</h2>
         <ProfileImageSettings />
       </div>
     </div>

+ 24 - 23
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,12 +1,12 @@
-import React, { type ReactNode, useCallback, useState } from 'react';
+import React, { type ReactNode, useCallback } from 'react';
 
-import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
+import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
+import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -47,9 +47,6 @@ type Props = {
   editorMode: EditorMode | undefined,
   isBtnDisabled: boolean,
   path?: string,
-  grant?: number,
-  // grantUserGroupId?: string
-  grantUserGroupIds?: IGrantedGroup[]
 }
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
@@ -57,30 +54,34 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     editorMode = EditorMode.View,
     isBtnDisabled,
     path,
-    // grant,
-    // grantUserGroupId,
   } = props;
 
-  const { t } = useTranslation('common');
-  const [isCreating, setIsCreating] = useState(false);
+  const { t } = useTranslation('commons');
 
+  const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
-  const _isBtnDisabled = isCreating || isBtnDisabled;
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const editButtonClickedHandler = useCallback(async() => {
+    if (isNotFound == null || isNotFound === false) {
+      mutateEditorMode(EditorMode.Editor);
+      return;
+    }
+
+    try {
+      await createAndTransit(
+        { path },
+        { shouldCheckPageExists: true },
+      );
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: path }));
+    }
+  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
 
-  const createPageAndTransit = useCreatePageAndTransit();
-
-  const editButtonClickedHandler = useCallback(() => {
-    createPageAndTransit(
-      path,
-      {
-        onCreationStart: () => { setIsCreating(true) },
-        onError: () => { toastError(t('toaster.create_failed', { target: path })) },
-        onTerminated: () => { setIsCreating(false) },
-      },
-    );
-  }, [createPageAndTransit, path, t]);
+  const _isBtnDisabled = isCreating || isBtnDisabled;
 
   return (
     <>

+ 6 - 6
apps/app/src/components/PageDuplicateModal.tsx

@@ -160,10 +160,10 @@ const PageDuplicateModal = (): JSX.Element => {
 
     return (
       <>
-        <div><label className="form-label">{t('modal_duplicate.label.Current page name')}</label><br />
+        <div className="mt-3"><label className="form-label">{t('modal_duplicate.label.Current page name')}</label><br />
           <code>{path}</code>
         </div>
-        <div>
+        <div className="mt-3">
           <label className="form-label" htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <div className="input-group">
             <div>
@@ -196,7 +196,7 @@ const PageDuplicateModal = (): JSX.Element => {
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
 
-        <div className="form-check form-check-warning">
+        <div className="form-check form-check-warning mt-3">
           <input
             className="form-check-input"
             name="recursively"
@@ -210,7 +210,7 @@ const PageDuplicateModal = (): JSX.Element => {
             <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
           </label>
 
-          <div>
+          <div className="mt-3">
             {isDuplicateRecursively && existingPaths.length !== 0 && (
               <div className="form-check form-check-warning">
                 <input
@@ -230,7 +230,7 @@ const PageDuplicateModal = (): JSX.Element => {
           </div>
         </div>
 
-        <div className="form-check form-check-warning mb-3">
+        <div className="form-check form-check-warning mt-2">
           <input
             className="form-check-input"
             id="cbOnlyDuplicateUserRelatedResources"
@@ -243,7 +243,7 @@ const PageDuplicateModal = (): JSX.Element => {
             <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
           </label>
         </div>
-        <div>
+        <div className="mt-3">
           {isDuplicateRecursively && existingPaths.length !== 0 && (
             <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
           ) }

+ 2 - 2
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -10,7 +10,7 @@ import {
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 
-import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
+import { DEFAULT_THEME, type KeyMapMode } from '../../interfaces/editor-settings';
 
 
 const AVAILABLE_THEMES = [
@@ -73,7 +73,7 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
   default: 'Default',
   vim: 'Vim',
   emacs: 'Emacs',
-  sublime: 'Sublime Text',
+  vscode: 'Visual Studio Code',
 };
 
 const KeymapSelector = memo((): JSX.Element => {

+ 24 - 57
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -19,10 +19,9 @@ import { throttle, debounce } from 'throttle-debounce';
 
 
 import { useShouldExpandContent } from '~/client/services/layout';
-import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave, updatePage } from '~/client/services/page-operation';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import {
   useDefaultIndentSize, useCurrentUser,
@@ -32,7 +31,6 @@ import {
 import {
   useEditorSettings,
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
-  useIsEnabledUnsavedWarning,
   useIsConflict,
   useEditingMarkdown,
   useWaitingSaveProcessing,
@@ -90,10 +88,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const router = useRouter();
 
   const previewRef = useRef<HTMLDivElement>(null);
-  const codeMirrorEditorContainerRef = useRef<HTMLDivElement>(null);
 
   const { data: isNotFound } = useIsNotFound();
-  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
@@ -125,14 +122,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: socket } = useGlobalSocket();
 
   const { data: rendererOptions } = usePreviewOptions();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsConflict } = useIsConflict();
 
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
 
   const shouldExpandContent = useShouldExpandContent(currentPage);
 
-  const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
   const { resolvedTheme } = useNextThemes();
@@ -216,40 +211,27 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   }, [socket, checkIsConflict]);
 
-  const optionsToSave = useMemo((): OptionsToSave | undefined => {
-    if (grantData == null) {
-      return;
-    }
-    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
-      return { item: group.id, type: group.type };
-    });
-    const optionsToSave = {
-      isSlackEnabled: isSlackEnabled ?? false,
-      slackChannels: '', // set in save method by opts in SavePageControlls.tsx
-      grant: grantData.grant,
-      // pageTags: pageTags ?? [],
-      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
-    };
-    return optionsToSave;
-  }, [grantData, isSlackEnabled]);
-
-
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
-    if (currentPathname == null || optionsToSave == null) {
-      logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
+    if (pageId == null || currentPagePath == null || currentRevisionId == null || grantData == null) {
+      logger.error('Some materials to save are invalid', {
+        pageId, currentPagePath, currentRevisionId, grantData,
+      });
       throw new Error('Some materials to save are invalid');
     }
 
-    const options = Object.assign(optionsToSave, opts);
-
     try {
       mutateWaitingSaveProcessing(true);
 
-      const { page } = await saveOrUpdate(
-        codeMirrorEditor?.getDoc() ?? '',
-        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
-        options,
-      );
+      const { page } = await updatePage({
+        pageId,
+        revisionId: currentRevisionId,
+        body: codeMirrorEditor?.getDoc() ?? '',
+        grant: grantData?.grant,
+        userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
+          return { item: group.id, type: group.type };
+        }),
+        ...(opts ?? {}),
+      });
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       mutatePageTree();
@@ -271,12 +253,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       mutateWaitingSaveProcessing(false);
     }
 
-  }, [
-    codeMirrorEditor,
-    currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId,
-    currentPagePath, currentRevisionId,
-    mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [codeMirrorEditor, grantData, pageId, currentPagePath, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     const page = await save(opts);
@@ -284,14 +262,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       return;
     }
 
-    if (isNotFound) {
-      await router.push(`/${page._id}`);
-    }
-    else {
-      updateStateAfterSave?.();
-    }
     mutateEditorMode(EditorMode.View);
-  }, [save, isNotFound, mutateEditorMode, router, updateStateAfterSave]);
+    updateStateAfterSave?.();
+  }, [mutateEditorMode, save, updateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
     const page = await save();
@@ -299,16 +272,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       return;
     }
 
-    if (isNotFound) {
-      await router.push(`/${page._id}#edit`);
-    }
-    else {
-      updateStateAfterSave?.();
-    }
     toastSuccess(t('toaster.save_succeeded'));
-    mutateEditorMode(EditorMode.Editor);
-
-  }, [isNotFound, mutateEditorMode, router, save, t, updateStateAfterSave]);
+    updateStateAfterSave?.();
+  }, [save, t, updateStateAfterSave]);
 
 
   // the upload event handler
@@ -473,7 +439,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   return (
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
-      <div className="flex-expand-vert justify-content-center" style={{ minHeight: '72px' }}>
+      <div className="px-4 py-2">
         <PageHeader />
       </div>
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
@@ -502,6 +468,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             initialValue={initialValue}
             onOpenEditor={markdown => setMarkdownToPreview(markdown)}
             editorTheme={editorSettings?.theme}
+            editorKeymap={editorSettings?.keymapMode}
           />
         </div>
         <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">

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

@@ -0,0 +1,2 @@
+.page-header :global {
+}

+ 10 - 8
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -1,28 +1,30 @@
-import { FC } from 'react';
+import type { FC } from 'react';
 
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 import { PagePathHeader } from './PagePathHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 
+import styles from './PageHeader.module.scss';
+
+const moduleClass = styles['page-header'] ?? '';
+
 export const PageHeader: FC = () => {
-  const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPage } = useSWRxCurrentPage();
 
-  if (currentPage == null || currentPagePath == null) {
+  if (currentPage == null) {
     return <></>;
   }
 
   return (
-    <>
+    <div className={moduleClass}>
       <PagePathHeader
-        currentPagePath={currentPagePath}
         currentPage={currentPage}
       />
       <PageTitleHeader
-        currentPagePath={currentPagePath}
+        className="mt-2"
         currentPage={currentPage}
       />
-    </>
+    </div>
   );
 };

+ 19 - 0
apps/app/src/components/PageHeader/PagePathHeader.module.scss

@@ -0,0 +1,19 @@
+.page-path-header :global {
+  input {
+    min-width: 20px;
+    min-height: unset;
+    padding-top: 0;
+    padding-bottom: 0;
+    line-height: 1em;
+  }
+
+  .page-path-header-buttons {
+    height: 0;
+
+    .btn {
+      width: 24px;
+      height: 24px;
+      transform: translateY(8px);
+    }
+  }
+}

+ 97 - 83
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -1,84 +1,84 @@
-import {
-  FC, useEffect, useMemo, useState,
-} from 'react';
+import { useState, useEffect, useCallback } from 'react';
+import type { FC } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { DevidedPagePath } from '@growi/core/dist/models';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
+import { useTranslation } from 'next-i18next';
 
+import { ValidationTarget } from '~/client/util/input-validator';
+import LinkedPagePath from '~/models/linked-page-path';
 import { usePageSelectModal } from '~/stores/modal';
-import { EditorMode, useEditorMode } from '~/stores/ui';
 
-import { PagePathNav } from '../Common/PagePathNav';
+import ClosableTextInput from '../Common/ClosableTextInput';
+import { PagePathHierarchicalLink } from '../Common/PagePathHierarchicalLink';
 import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
 import { usePagePathRenameHandler } from './page-header-utils';
 
+import styles from './PagePathHeader.module.scss';
+
+const moduleClass = styles['page-path-header'];
+
+
 type Props = {
-  currentPagePath: string
   currentPage: IPagePopulatedToShowRevision
 }
 
 export const PagePathHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { t } = useTranslation();
+  const { currentPage } = props;
+
+  const dPagePath = new DevidedPagePath(currentPage.path, true);
+  const parentPagePath = dPagePath.former;
+
+  const linkedPagePath = new LinkedPagePath(parentPagePath);
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const [isButtonsShown, setButtonShown] = useState(false);
-  const [inputText, setInputText] = useState('');
+  const [isHover, setHover] = useState(false);
+  const [editingParentPagePath, setEditingParentPagePath] = useState(parentPagePath);
 
-  const { data: editorMode } = useEditorMode();
   const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
+  const isOpened = PageSelectModalData?.isOpened ?? false;
 
-  const onRenameFinish = () => {
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const onRenameFinish = useCallback(() => {
     setRenameInputShown(false);
-  };
+  }, []);
 
-  const onRenameFailure = () => {
+  const onRenameFailure = useCallback(() => {
     setRenameInputShown(true);
-  };
-
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+  }, []);
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const onInputChange = useCallback((inputText: string) => {
+    setEditingParentPagePath(inputText);
+  }, []);
 
-  const isOpened = PageSelectModalData?.isOpened ?? false;
+  const onPressEnter = useCallback(() => {
+    const pathToRename = normalizePath(`${editingParentPagePath}/${dPagePath.latter}`);
+    pagePathRenameHandler(pathToRename, onRenameFinish, onRenameFailure);
+  }, [editingParentPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler, dPagePath.latter]);
 
-  const isViewMode = editorMode === EditorMode.View;
-  const isEditorMode = !isViewMode;
-
-  const PagePath = useMemo(() => (
-    <>
-      {currentPagePath != null && (
-        <PagePathNav
-          pageId={currentPage._id}
-          pagePath={currentPagePath}
-          isSingleLineMode={isEditorMode}
-        />
-      )}
-    </>
-  ), [currentPage._id, currentPagePath, isEditorMode]);
-
-  const handleInputChange = (inputText: string) => {
-    setInputText(inputText);
-  };
-
-  const handleEditButtonClick = () => {
-    if (isRenameInputShown) {
-      pagePathRenameHandler(inputText);
-    }
-    else {
-      setRenameInputShown(true);
-    }
-  };
+  const onPressEscape = useCallback(() => {
+    // reset
+    setEditingParentPagePath(parentPagePath);
+    setRenameInputShown(false);
+  }, [parentPagePath]);
 
-  const buttonStyle = isButtonsShown ? '' : 'd-none';
+  const onClickEditButton = useCallback(() => {
+    // reset
+    setEditingParentPagePath(parentPagePath);
+    setRenameInputShown(true);
+  }, [parentPagePath]);
 
-  const clickOutSideHandler = (e) => {
+  const clickOutSideHandler = useCallback((e) => {
     const container = document.getElementById('page-path-header');
 
     if (container && !container.contains(e.target)) {
       setRenameInputShown(false);
     }
-  };
+  }, []);
 
   useEffect(() => {
     document.addEventListener('click', clickOutSideHandler);
@@ -86,45 +86,59 @@ export const PagePathHeader: FC<Props> = (props) => {
     return () => {
       document.removeEventListener('click', clickOutSideHandler);
     };
-  }, []);
+  }, [clickOutSideHandler]);
+
+
+  if (dPagePath.isRoot) {
+    return <></>;
+  }
 
   return (
-    <>
-      <div
-        id="page-path-header"
-        onMouseLeave={() => setButtonShown(false)}
-      >
-        <div className="row">
-          <div
-            className="col-4"
-            onMouseEnter={() => setButtonShown(true)}
-          >
-            <TextInputForPageTitleAndPath
-              currentPage={currentPage}
-              stateHandler={stateHandler}
-              inputValue={currentPagePath}
-              CustomComponent={PagePath}
-              handleInputChange={handleInputChange}
+    <div
+      id="page-path-header"
+      className={`d-flex ${moduleClass} small`}
+      onMouseEnter={() => setHover(true)}
+      onMouseLeave={() => setHover(false)}
+    >
+      <div className="me-2">
+        { isRenameInputShown && (
+          <div className="position-absolute">
+            <ClosableTextInput
+              useAutosizeInput
+              value={editingParentPagePath}
+              placeholder={t('Input page name')}
+              inputClassName="form-control-sm"
+              onPressEnter={onPressEnter}
+              onPressEscape={onPressEscape}
+              onChange={onInputChange}
+              validationTarget={ValidationTarget.PAGE}
             />
           </div>
-          <div className={`${buttonStyle} col-4 row`}>
-            <div className="col-4">
-              <button type="button" onClick={handleEditButtonClick}>
-                {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
-              </button>
-            </div>
-            <div className="col-4">
-              <button type="button" onClick={openPageSelectModal}>
-                <span className="material-symbols-outlined">account_tree</span>
-              </button>
-            </div>
-          </div>
-          {isOpened
-            && (
-              <PageSelectModal />
-            )}
+        ) }
+        <div className={`${isRenameInputShown ? 'invisible' : ''}`}>
+          <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />
         </div>
       </div>
-    </>
+
+      <div className={`page-path-header-buttons d-flex align-items-center ${isHover && !isRenameInputShown ? '' : 'invisible'}`}>
+        <button
+          type="button"
+          className="btn btn-outline-neutral-secondary me-2 d-flex align-items-center justify-content-center"
+          onClick={onClickEditButton}
+        >
+          <span className="material-symbols-outlined fs-6">edit</span>
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-outline-neutral-secondary d-flex align-items-center justify-content-center"
+          onClick={openPageSelectModal}
+        >
+          <span className="material-symbols-outlined fs-6">account_tree</span>
+        </button>
+      </div>
+
+      {isOpened && <PageSelectModal />}
+    </div>
   );
 };

+ 9 - 0
apps/app/src/components/PageHeader/PageTitleHeader.module.scss

@@ -0,0 +1,9 @@
+.page-title-header :global {
+  input {
+    min-width: 20px;
+    min-height: unset;
+    padding: 0 0.5rem;
+    line-height: 1em;
+    transform: translateX(-0.55rem) translateY(-0.05rem);
+  }
+}

+ 90 - 17
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,35 +1,108 @@
-import { FC, useState, useMemo } from 'react';
+import type { FC } from 'react';
+import { useState, useCallback } from 'react';
 
 import nodePath from 'path';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { DevidedPagePath } from '@growi/core/dist/models';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+import { ValidationTarget } from '~/client/util/input-validator';
 
-type Props = {
-  currentPagePath: string,
-  currentPage: IPagePopulatedToShowRevision;
-}
+import ClosableTextInput from '../Common/ClosableTextInput';
+import { CopyDropdown } from '../Common/CopyDropdown';
+
+import { usePagePathRenameHandler } from './page-header-utils';
+
+import styles from './PageTitleHeader.module.scss';
 
+const moduleClass = styles['page-title-header'];
+
+type Props = {
+  currentPage: IPagePopulatedToShowRevision,
+  className?: string,
+};
 
 export const PageTitleHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { t } = useTranslation();
+  const { currentPage } = props;
+
+  const currentPagePath = currentPage.path;
+
+  const dPagePath = new DevidedPagePath(currentPage.path, true);
+  const pageTitle = dPagePath.latter;
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const pageName = nodePath.basename(currentPagePath ?? '') || '/';
+  const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const editedPageTitle = nodePath.basename(editedPagePath);
+
+  const onRenameFinish = useCallback(() => {
+    setRenameInputShown(false);
+  }, []);
+
+  const onRenameFailure = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const onInputChange = useCallback((inputText: string) => {
+    const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
+    const newPagePath = nodePath.resolve(parentPagePath, inputText);
+
+    setEditedPagePath(newPagePath);
+  }, [currentPage?.path, setEditedPagePath]);
+
+  const onPressEnter = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
+
+  const onPressEscape = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(false);
+  }, [currentPagePath]);
+
+  const onClickPageTitle = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(true);
+  }, [currentPagePath]);
 
-  const PageTitle = useMemo(() => (<div onClick={() => setRenameInputShown(true)}>{pageName}</div>), [pageName]);
 
   return (
-    <div onBlur={() => setRenameInputShown(false)}>
-      <TextInputForPageTitleAndPath
-        currentPage={currentPage}
-        stateHandler={stateHandler}
-        inputValue={pageName}
-        CustomComponent={PageTitle}
-      />
+    <div className={`d-flex align-items-center ${moduleClass} ${props.className ?? ''}`}>
+      <div className="me-1">
+        { isRenameInputShown && (
+          <div className="position-absolute">
+            <ClosableTextInput
+              useAutosizeInput
+              value={editedPageTitle}
+              placeholder={t('Input page name')}
+              inputClassName="fs-4"
+              onPressEnter={onPressEnter}
+              onPressEscape={onPressEscape}
+              onChange={onInputChange}
+              onClickOutside={() => setRenameInputShown(false)}
+              validationTarget={ValidationTarget.PAGE}
+            />
+          </div>
+        ) }
+        <h1 className={`mb-0 fs-4 ${isRenameInputShown ? 'invisible' : ''}`} onClick={onClickPageTitle}>
+          {pageTitle}
+        </h1>
+      </div>
+
+      <div className={`${isRenameInputShown ? 'invisible' : ''}`}>
+        <CopyDropdown
+          pageId={currentPage._id}
+          pagePath={currentPage.path}
+          dropdownToggleId={`copydropdown-${currentPage._id}`}
+          dropdownToggleClassName="ms-2 p-1"
+        >
+          <span className="material-symbols-outlined fs-6">content_paste</span>
+        </CopyDropdown>
+      </div>
     </div>
   );
 };

+ 0 - 76
apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx

@@ -1,76 +0,0 @@
-import { FC, useCallback } from 'react';
-import type { Dispatch, SetStateAction } from 'react';
-
-import nodePath from 'path';
-
-import type { IPagePopulatedToShowRevision } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-
-import { ValidationTarget } from '~/client/util/input-validator';
-
-import ClosableTextInput from '../Common/ClosableTextInput';
-
-
-import { usePagePathRenameHandler } from './page-header-utils';
-
-
-type StateHandler = {
-  isRenameInputShown: boolean
-  setRenameInputShown: Dispatch<SetStateAction<boolean>>
-}
-
-type Props = {
-  currentPage: IPagePopulatedToShowRevision
-  stateHandler: StateHandler
-  inputValue: string
-  CustomComponent: JSX.Element
-  handleInputChange?: (string) => void
-}
-
-export const TextInputForPageTitleAndPath: FC<Props> = (props) => {
-  const {
-    currentPage, stateHandler, inputValue, CustomComponent, handleInputChange,
-  } = props;
-
-  const { t } = useTranslation();
-
-  const { isRenameInputShown, setRenameInputShown } = stateHandler;
-
-  const onRenameFinish = () => {
-    setRenameInputShown(false);
-  };
-
-  const onRenameFailure = () => {
-    setRenameInputShown(true);
-  };
-
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
-
-  const onPressEnter = useCallback((inputPagePath: string) => {
-
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputPagePath);
-
-    pagePathRenameHandler(newPagePath);
-
-  }, [currentPage.path, pagePathRenameHandler]);
-
-  return (
-    <>
-      {isRenameInputShown ? (
-        <div className="flex-fill">
-          <ClosableTextInput
-            value={inputValue}
-            placeholder={t('Input page name')}
-            onPressEnter={onPressEnter}
-            validationTarget={ValidationTarget.PAGE}
-            handleInputChange={handleInputChange}
-          />
-        </div>
-      ) : (
-        <>{ CustomComponent }</>
-      )}
-    </>
-  );
-};

+ 7 - 5
apps/app/src/components/PageHeader/page-header-utils.ts

@@ -8,16 +8,18 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
 
+type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void) => Promise<void>
+
 export const usePagePathRenameHandler = (
-    currentPage: IPagePopulatedToShowRevision, onRenameFinish?: () => void, onRenameFailure?: () => void,
-): (newPagePath: string) => Promise<void> => {
+    currentPage: IPagePopulatedToShowRevision,
+): PagePathRenameHandler => {
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { t } = useTranslation();
 
   const currentPagePath = currentPage.path;
 
-  const pagePathRenameHandler = useCallback(async(newPagePath: string) => {
+  const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
 
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
       mutatePageTree();
@@ -34,7 +36,6 @@ export const usePagePathRenameHandler = (
     }
 
     try {
-      onRenameFinish?.();
       await apiv3Put('/pages/rename', {
         pageId: currentPage._id,
         revisionId: currentPage.revision._id,
@@ -42,6 +43,7 @@ export const usePagePathRenameHandler = (
       });
 
       onRenamed(currentPage.path, newPagePath);
+      onRenameFinish?.();
 
       toastSuccess(t('renamed_pages', { path: currentPage.path }));
     }
@@ -49,7 +51,7 @@ export const usePagePathRenameHandler = (
       onRenameFailure?.();
       toastError(err);
     }
-  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, onRenameFailure, onRenameFinish, t]);
+  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, t]);
 
   return pagePathRenameHandler;
 };

+ 3 - 2
apps/app/src/components/PageHistory/RevisionDiff.tsx

@@ -3,7 +3,8 @@ import React from 'react';
 import type { IRevisionHasPageId } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { createPatch } from 'diff';
-import { html, Diff2HtmlConfig } from 'diff2html';
+import type { Diff2HtmlConfig } from 'diff2html';
+import { html } from 'diff2html';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import urljoin from 'url-join';
@@ -43,7 +44,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
     drawFileList: false,
   };
 
-  const diffViewHTML = (currentRevision.body && previousRevision.body && revisionDiffOpened) ? html(patch, option) : '';
+  const diffViewHTML = revisionDiffOpened ? html(patch, option) : '';
 
   const diffView = { __html: diffViewHTML };
 

+ 3 - 2
apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx

@@ -13,12 +13,13 @@ type Props = {
   icon: ReactNode,
   label: ReactNode,
   count?: number,
+  offset?: number,
   onClick?: () => void,
 }
 
 export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
   const {
-    icon, label, count,
+    icon, label, count, offset,
     className,
     onClick,
   } = props;
@@ -34,7 +35,7 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
         {label}
         {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
         { count != null
-          ? <CountBadge count={count} />
+          ? <CountBadge count={count} offset={offset} />
           : <div className="px-2"></div>}
       </span>
     </button>

+ 1 - 0
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -107,6 +107,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
               label={t('page_list')}
               // Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600
               count={!isTrash && pageInfo != null ? (pageInfo as IPageInfoForOperation).descendantCount : undefined}
+              offset={1}
               onClick={() => openDescendantPageListModal(pagePath)}
             />
           </div>

+ 13 - 7
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,6 +1,8 @@
 import React, { useCallback, useState } from 'react';
 
-import { isPopulated, GroupType, type IGrantedGroup } from '@growi/core';
+import {
+  PageGrant, isPopulated, GroupType, type IGrantedGroup,
+} from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledDropdown,
@@ -16,24 +18,28 @@ import { useMyUserGroups } from './use-my-user-groups';
 
 const AVAILABLE_GRANTS = [
   {
-    grant: 1, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
+    grant: PageGrant.GRANT_PUBLIC, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
   },
   {
-    grant: 2, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+    grant: PageGrant.GRANT_RESTRICTED, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
   },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   {
-    grant: 4, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
+    grant: PageGrant.GRANT_OWNER, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
   },
   {
-    grant: 5, iconClass: 'icon-options', btnStyleClass: 'outline-purple', label: 'Only inside the group', reselectLabel: 'Reselect the group',
+    grant: PageGrant.GRANT_USER_GROUP,
+    iconClass: 'icon-options',
+    btnStyleClass: 'outline-purple',
+    label: 'Only inside the group',
+    reselectLabel: 'Reselect the group',
   },
 ];
 
 
 type Props = {
   disabled?: boolean,
-  grant: number,
+  grant: PageGrant,
   userRelatedGrantedGroups?: {
     id: string,
     name: string,
@@ -72,7 +78,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
   /**
    * change event handler for grant selector
    */
-  const changeGrantHandler = useCallback((grant: number) => {
+  const changeGrantHandler = useCallback((grant: PageGrant) => {
     // select group
     if (grant === 5) {
       showSelectGroupModal();

+ 28 - 22
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -3,21 +3,21 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 import { DropdownMenu, DropdownItem } from 'reactstrap';
 
-import { LabelType } from '~/interfaces/template';
+import type { LabelType } from '~/interfaces/template';
 
 
 type DropendMenuProps = {
-  onClickCreateNewPageButtonHandler: () => Promise<void>
-  onClickCreateTodaysButtonHandler: () => Promise<void>
-  onClickTemplateButtonHandler: (label: LabelType) => Promise<void>
+  onClickCreateNewPage: () => Promise<void>
+  onClickCreateTodaysMemo: () => Promise<void>
+  onClickCreateTemplate?: (label: LabelType) => Promise<void>
   todaysPath: string | null,
 }
 
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
   const {
-    onClickCreateNewPageButtonHandler,
-    onClickCreateTodaysButtonHandler,
-    onClickTemplateButtonHandler,
+    onClickCreateNewPage,
+    onClickCreateTodaysMemo,
+    onClickCreateTemplate,
     todaysPath,
   } = props;
 
@@ -28,33 +28,39 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       container="body"
     >
       <DropdownItem
-        onClick={onClickCreateNewPageButtonHandler}
+        onClick={onClickCreateNewPage}
       >
         {t('create_page_dropdown.new_page')}
       </DropdownItem>
-      {todaysPath != null && (
+
+      { todaysPath != null && (
         <>
           <DropdownItem divider />
           <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
           <DropdownItem
-            onClick={onClickCreateTodaysButtonHandler}
+            onClick={onClickCreateTodaysMemo}
           >
             {todaysPath}
           </DropdownItem>
         </>
       )}
-      <DropdownItem divider />
-      <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
-      <DropdownItem
-        onClick={() => onClickTemplateButtonHandler('_template')}
-      >
-        {t('create_page_dropdown.template.children')}
-      </DropdownItem>
-      <DropdownItem
-        onClick={() => onClickTemplateButtonHandler('__template')}
-      >
-        {t('create_page_dropdown.template.descendants')}
-      </DropdownItem>
+
+      { onClickCreateTemplate != null && (
+        <>
+          <DropdownItem divider />
+          <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
+          <DropdownItem
+            onClick={() => onClickCreateTemplate('_template')}
+          >
+            {t('create_page_dropdown.template.children')}
+          </DropdownItem>
+          <DropdownItem
+            onClick={() => onClickCreateTemplate('__template')}
+          >
+            {t('create_page_dropdown.template.descendants')}
+          </DropdownItem>
+        </>
+      ) }
     </DropdownMenu>
   );
 });

+ 27 - 39
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,60 +1,48 @@
 import React, { useState, useCallback } from 'react';
 
-import type { IUserHasId } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
-import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 import { Dropdown } from 'reactstrap';
 
-import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
-import { LabelType } from '~/interfaces/template';
-import { useCurrentUser } from '~/stores/context';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
-import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
+import { useCreateNewPage, useCreateTodaysMemo } from './hooks';
 
 
-const generateTodaysPath = (currentUser: IUserHasId, parentDirName: string) => {
-  const now = format(new Date(), 'yyyy/MM/dd');
-  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  return `${userHomepagePath}/${parentDirName}/${now}`;
-};
-
-export const PageCreateButton = React.memo((): JSX.Element => {
+const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
   const { t } = useTranslation('commons');
 
-  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
-  const { data: currentPage, isLoading } = useSWRxCurrentPage();
-  const { data: currentUser } = useCurrentUser();
+  return useCallback(async(param) => {
+    try {
+      return await method?.(param);
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: 'a page' }));
+    }
+  }, [method, t]);
+};
+
 
+export const PageCreateButton = React.memo((): JSX.Element => {
   const [isHovered, setIsHovered] = useState(false);
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
-  const todaysPath = currentUser == null
-    ? null
-    : generateTodaysPath(currentUser, t('create_page_dropdown.todays.memo'));
-
-  const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(
-    currentPage?.path, currentPage?.grant, currentPage?.grantedGroups, isLoading,
-  );
+  const { createNewPage, isCreating: isNewPageCreating } = useCreateNewPage();
   // TODO: https://redmine.weseek.co.jp/issues/138806
-  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath);
+  const { createTodaysMemo, isCreating: isTodaysPageCreating, todaysPath } = useCreateTodaysMemo();
   // TODO: https://redmine.weseek.co.jp/issues/138805
-  const { onClickHandler: onClickTemplateButton, isPageCreating: isTemplatePageCreating } = useOnTemplateButtonClicked(currentPagePath, isLoadingPagePath);
+  const {
+    createTemplate,
+    isCreating: isTemplatePageCreating, isCreatable: isTemplatePageCreatable,
+  } = useCreateTemplatePage();
 
-  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
-    try {
-      await onClickTemplateButton(label);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [onClickTemplateButton]);
+  const createNewPageWithToastr = useToastrOnError(createNewPage);
+  const createTodaysMemoWithToastr = useToastrOnError(createTodaysMemo);
+  const createTemplateWithToastr = useToastrOnError(createTemplate);
 
   const onMouseEnterHandler = () => {
     setIsHovered(true);
@@ -76,7 +64,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       <div className="btn-group flex-grow-1">
         <CreateButton
           className="z-2"
-          onClick={onClickNewButton}
+          onClick={createNewPageWithToastr}
           disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
         />
       </div>
@@ -89,9 +77,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
         >
           <DropendToggle />
           <DropendMenu
-            onClickCreateNewPageButtonHandler={onClickNewButton}
-            onClickCreateTodaysButtonHandler={onClickTodaysButton}
-            onClickTemplateButtonHandler={onClickTemplateButtonHandler}
+            onClickCreateNewPage={createNewPageWithToastr}
+            onClickCreateTodaysMemo={createTodaysMemoWithToastr}
+            onClickCreateTemplate={isTemplatePageCreatable ? createTemplateWithToastr : undefined}
             todaysPath={todaysPath}
           />
         </Dropdown>

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

@@ -1,109 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import type { PageGrant, IGrantedGroup } from '@growi/core';
-import { useRouter } from 'next/router';
-
-import { createPage, exist } from '~/client/services/page-operation';
-import { toastError } from '~/client/util/toastr';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-export const useOnNewButtonClicked = (
-    currentPagePath?: string,
-    currentPageGrant?: PageGrant,
-    currentPageGrantedGroups?: IGrantedGroup[],
-    isLoading?: boolean,
-): {
-  onClickHandler: () => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const onClickHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsPageCreating(true);
-
-      /**
-       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
-       * since the new page path is not generated on the client side.
-       * need shouldGeneratePath flag.
-       */
-      const shouldUseRootPath = currentPagePath == null || currentPageGrant == null;
-      const parentPath = shouldUseRootPath
-        ? '/'
-        : currentPagePath;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: shouldUseRootPath ? 1 : currentPageGrant,
-        grantUserGroupIds: shouldUseRootPath ? undefined : currentPageGrantedGroups,
-        shouldGeneratePath: true,
-      };
-
-      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
-      const response = await createPage(parentPath, '', params);
-
-      await router.push(`/${response.page.id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPageGrant, currentPageGrantedGroups, currentPagePath, isLoading, mutateEditorMode, router]);
-
-  return { onClickHandler, isPageCreating };
-};
-
-export const useOnTodaysButtonClicked = (
-    todaysPath: string | null,
-): {
-  onClickHandler: () => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const onClickHandler = useCallback(async() => {
-    if (todaysPath == null) {
-      return;
-    }
-
-    try {
-      setIsPageCreating(true);
-
-      // TODO: get grant, grantUserGroupId data from parent page
-      // https://redmine.weseek.co.jp/issues/133892
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-      };
-
-      const res = await exist(JSON.stringify([todaysPath]));
-      if (!res.pages[todaysPath]) {
-        await createPage(todaysPath, '', params);
-      }
-
-      await router.push(`${todaysPath}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [mutateEditorMode, router, todaysPath]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 2 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/index.ts

@@ -0,0 +1,2 @@
+export * from './use-create-new-page';
+export * from './use-create-todays-memo';

+ 29 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -0,0 +1,29 @@
+import { useCallback } from 'react';
+
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCurrentPagePath } from '~/stores/page';
+
+
+type UseCreateNewPage = () => {
+  isCreating: boolean,
+  createNewPage: () => Promise<void>,
+}
+
+export const useCreateNewPage: UseCreateNewPage = () => {
+  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
+
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const createNewPage = useCallback(async() => {
+    if (isLoadingPagePath) return;
+
+    return createAndTransit(
+      { parentPath: currentPagePath, optionalParentPath: '/' },
+    );
+  }, [createAndTransit, currentPagePath, isLoadingPagePath]);
+
+  return {
+    isCreating,
+    createNewPage,
+  };
+};

+ 45 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -0,0 +1,45 @@
+import { useCallback } from 'react';
+
+import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCurrentUser } from '~/stores/context';
+
+
+type UseCreateTodaysMemo = () => {
+  isCreating: boolean,
+  todaysPath: string | null,
+  createTodaysMemo: () => Promise<void>,
+}
+
+export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
+  const { t } = useTranslation('commons');
+
+  const { data: currentUser } = useCurrentUser();
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const isCreatable = currentUser != null;
+
+  const parentDirName = t('create_page_dropdown.todays.memo');
+  const now = format(new Date(), 'yyyy/MM/dd');
+  const todaysPath = isCreatable
+    ? `${userHomepagePath(currentUser)}/${parentDirName}/${now}`
+    : null;
+
+  const createTodaysMemo = useCallback(async() => {
+    if (!isCreatable || todaysPath == null) return;
+
+    return createAndTransit(
+      { path: todaysPath },
+      { shouldCheckPageExists: true },
+    );
+  }, [createAndTransit, isCreatable, todaysPath]);
+
+  return {
+    isCreating,
+    todaysPath,
+    createTodaysMemo,
+  };
+};

+ 51 - 44
apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx

@@ -5,6 +5,9 @@ import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
+import {
+  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
@@ -36,71 +39,75 @@ export const PersonalDropdown = (): JSX.Element => {
 
   return (
     <>
-      <div className="dropend">
-        {/* Button */}
-        {/* remove .dropdown-toggle for hide caret */}
-        {/* See https://stackoverflow.com/a/44577512/13183572 */}
-        <button
-          type="button"
+      <UncontrolledDropdown
+        direction="end"
+      >
+        <DropdownToggle
           className="btn btn-primary"
-          data-bs-toggle="dropdown"
           data-testid="personal-dropdown-button"
-          aria-expanded="false"
         >
           <UserPicture user={currentUser} noLink noTooltip />
-        </button>
+        </DropdownToggle>
 
-        {/* Menu */}
-        <ul className="dropdown-menu" data-testid="personal-dropdown-menu">
-          <li className="px-4 pt-3 pb-2">
-            <UserPicture user={currentUser} size="lg" noLink noTooltip />
-            <h5>{currentUser.name}</h5>
+        <DropdownMenu
+          container="body"
+          data-testid="personal-dropdown-menu"
+        >
+          <DropdownItem header>
+            <div className="mt-2 mb-3">
+              <UserPicture user={currentUser} size="lg" noLink noTooltip />
+            </div>
+            <h5 className="ms-1">{currentUser.name}</h5>
             <div className="d-flex align-items-center">
-              <i className="icon-user icon-fw"></i>{currentUser.username}
+              <span className="material-symbols-outlined me-1">person</span>
+              {currentUser.username}
             </div>
             <div className="d-flex align-items-center">
-              <i className="icon-envelope icon-fw"></i><span className="grw-email-sm">{currentUser.email}</span>
+              <span className="material-symbols-outlined me-1">mail</span>
+              <span className="grw-email-sm">{currentUser.email}</span>
             </div>
-          </li>
+          </DropdownItem>
 
-          <li className="dropdown-divider"></li>
+          <DropdownItem divider />
 
-          <li>
+          <DropdownItem>
             <Link
               href={pagePathUtils.userHomepagePath(currentUser)}
-              className="dropdown-item"
               data-testid="grw-personal-dropdown-menu-user-home"
             >
-              <i className="icon-fw icon-home"></i>{t('personal_dropdown.home')}
+              <span className="text-muted">
+                <span className="material-symbols-outlined me-1">home</span>{t('personal_dropdown.home')}
+              </span>
             </Link>
-          </li>
-          <li>
+          </DropdownItem>
+
+          <DropdownItem>
             <Link
               href="/me"
-              className="dropdown-item"
               data-testid="grw-personal-dropdown-menu-user-settings"
             >
-              <i className="icon-fw icon-wrench"></i>{t('personal_dropdown.settings')}
+              <span className="text-muted">
+                <span className="material-symbols-outlined me-1">build</span>{t('personal_dropdown.settings')}
+              </span>
             </Link>
-          </li>
-          <li>
-            <button
-              data-testid="grw-proactive-questionnaire-modal-toggle-btn"
-              type="button"
-              className="dropdown-item"
-              onClick={() => setQuestionnaireModalOpen(true)}
-            >
-              <span className="material-symbols-outlined">edit</span>{t('personal_dropdown.feedback')}
-            </button>
-          </li>
-          <li>
-            <button type="button" className="dropdown-item" onClick={logoutHandler}>
-              <i className="icon-fw icon-power"></i>{t('Sign out')}
-            </button>
-          </li>
-        </ul>
-
-      </div>
+          </DropdownItem>
+
+          <DropdownItem
+            data-testid="grw-proactive-questionnaire-modal-toggle-btn"
+            onClick={() => setQuestionnaireModalOpen(true)}
+          >
+            <span className="text-muted">
+              <span className="material-symbols-outlined me-1">edit</span>{t('personal_dropdown.feedback')}
+            </span>
+          </DropdownItem>
+
+          <DropdownItem onClick={logoutHandler}>
+            <span className="text-muted">
+              <span className="material-symbols-outlined me-1">logout</span>{t('Sign out')}
+            </span>
+          </DropdownItem>
+        </DropdownMenu>
+      </UncontrolledDropdown>
 
       <ProactiveQuestionnaireModal isOpen={isQuestionnaireModalOpen} onClose={() => setQuestionnaireModalOpen(false)} />
     </>

+ 2 - 1
apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -1,4 +1,5 @@
-import { FC, memo } from 'react';
+import type { FC } from 'react';
+import { memo } from 'react';
 
 import dynamic from 'next/dynamic';
 import Link from 'next/link';

+ 2 - 1
apps/app/src/components/Sidebar/SidebarNav/SidebarNav.tsx

@@ -1,6 +1,6 @@
 import React, { memo } from 'react';
 
-import { SidebarContentsType } from '~/interfaces/ui';
+import type { SidebarContentsType } from '~/interfaces/ui';
 
 import { PageCreateButton } from '../PageCreateButton';
 
@@ -23,6 +23,7 @@ export const SidebarNav = memo((props: SidebarNavProps) => {
       <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>

+ 1 - 1
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -67,7 +67,7 @@ export const useNewPageInput = (): UseNewPageInput => {
 
       setShowInput(false);
 
-      await apiv3Post('/pages/', {
+      await apiv3Post('/page', {
         path: newPagePath,
         body: undefined,
         grant: page.grant,

+ 1 - 1
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -188,7 +188,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
       <li
         ref={itemRef}
         role="button"
-        className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center
+        className={`list-group-item list-group-item-action rounded border-0 py-0 pr-3 d-flex align-items-center
         ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
         id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
         onClick={itemClickHandler}

+ 1 - 0
apps/app/src/interfaces/apiv3/index.ts

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

+ 37 - 0
apps/app/src/interfaces/apiv3/page.ts

@@ -0,0 +1,37 @@
+import type {
+  IPageHasId, IRevisionHasId, ITag,
+} from '@growi/core';
+
+import type { IOptionsForCreate, IOptionsForUpdate } from '../page';
+
+export type IApiv3PageCreateParams = IOptionsForCreate & {
+  path?: string,
+  parentPath?: string,
+  optionalParentPath?: string,
+
+  body?: string,
+  pageTags?: string[],
+
+  isSlackEnabled?: boolean,
+  slackChannels?: string,
+};
+
+export type IApiv3PageCreateResponse = {
+  page: IPageHasId,
+  tags: ITag[],
+  revision: IRevisionHasId,
+};
+
+export type IApiv3PageUpdateParams = IOptionsForUpdate & {
+  pageId: string,
+  revisionId: string,
+  body: string,
+
+  isSlackEnabled?: boolean,
+  slackChannels?: string,
+};
+
+export type IApiv3PageUpdateResponse = {
+  page: IPageHasId,
+  revision: IRevisionHasId,
+};

+ 1 - 1
apps/app/src/interfaces/editor-settings.ts

@@ -4,7 +4,7 @@ const KeyMapMode = {
   default: 'default',
   vim: 'vim',
   emacs: 'emacs',
-  sublime: 'sublime',
+  vscode: 'vscode',
 } as const;
 
 export type KeyMapMode = typeof KeyMapMode[keyof typeof KeyMapMode];

+ 0 - 10
apps/app/src/interfaces/page-operation.ts

@@ -1,5 +1,3 @@
-import type { IGrantedGroup } from '@growi/core';
-
 export const PageActionType = {
   Create: 'Create',
   Update: 'Update',
@@ -28,11 +26,3 @@ export type IPageOperationProcessData = {
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
 }
-
-export type OptionsToSave = {
-  isSlackEnabled: boolean;
-  slackChannels: string;
-  grant: number;
-  // userRelatedGrantUserGroupIds?: IGrantedGroup[];
-  // isSyncRevisionToHackmd?: boolean;
-};

+ 17 - 2
apps/app/src/interfaces/page.ts

@@ -1,4 +1,6 @@
-import type { GroupType, IPageHasId, Nullable } from '@growi/core';
+import type {
+  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant,
+} from '@growi/core';
 
 import type { IPageOperationProcessData } from './page-operation';
 
@@ -9,7 +11,7 @@ export {
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 
 export type IPageGrantData = {
-  grant: number,
+  grant: PageGrant,
   userRelatedGrantedGroups?: {
     id: string,
     name: string,
@@ -29,3 +31,16 @@ export type IDeleteManyPageApiv3Result = {
   isRecursively: Nullable<true>,
   isCompletely: Nullable<true>,
 };
+
+export type IOptionsForUpdate = {
+  grant?: PageGrant,
+  userRelatedGrantUserGroupIds?: IGrantedGroup[],
+  // isSyncRevisionToHackmd?: boolean,
+  overwriteScopesOfDescendants?: boolean,
+};
+
+export type IOptionsForCreate = {
+  grant?: PageGrant,
+  grantUserGroupIds?: IGrantedGroup[],
+  overwriteScopesOfDescendants?: boolean,
+};

+ 2 - 2
apps/app/src/models/linked-page-path.js

@@ -8,9 +8,9 @@ const { isTrashPage } = pagePathUtils;
  */
 export default class LinkedPagePath {
 
-  constructor(path, skipNormalize = false) {
+  constructor(path) {
 
-    const pagePath = new DevidedPagePath(path, skipNormalize);
+    const pagePath = new DevidedPagePath(path);
 
     this.path = path;
     this.pathName = pagePath.latter;

+ 6 - 4
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -1,5 +1,5 @@
-import type { IUser, IUserHasId } from '@growi/core';
-import {
+import type { IUser } from '@growi/core';
+import type {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { useTranslation } from 'next-i18next';
@@ -12,9 +12,9 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri, useIsEnabledMarp,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri, useIsEnabledMarp, useCurrentPathname,
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 import type { CommonProps } from './utils/commons';
 import {
@@ -50,6 +50,8 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/_private-legacy-pages');
 
   // Search
   useIsSearchPage(true);

+ 6 - 4
apps/app/src/pages/_search.page.tsx

@@ -1,6 +1,6 @@
-import { ReactNode } from 'react';
+import type { ReactNode } from 'react';
 
-import type { IUser, IUserHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
@@ -12,9 +12,9 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
   useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri, useCurrentPathname,
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 import { SearchPage } from '../components/SearchPage';
 
@@ -54,6 +54,8 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/_search');
 
   // Search
   useIsSearchPage(true);

+ 10 - 8
apps/app/src/pages/me/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React, { type ReactNode, useMemo } from 'react';
 
-import {
+import type {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import { useTranslation } from 'next-i18next';
@@ -10,18 +10,18 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
-import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
   useCurrentUser, useIsSearchPage, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig, useIsEnabledMarp,
+  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig, useIsEnabledMarp, useCurrentPathname,
 } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import { NextPageWithLayout } from '../_app.page';
+import type { NextPageWithLayout } from '../_app.page';
 import type { CommonProps } from '../utils/commons';
 import {
   getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle, useInitSidebarConfig,
@@ -101,6 +101,8 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/me');
 
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
@@ -122,15 +124,15 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
       </Head>
       <div className="dynamic-layout-root">
         <header className="py-3">
-          <div className="container-fluid">
-            <h1 className="title">{ targetPage.title }</h1>
+          <div className="container">
+            <h1 className="title fs-3 mt-5">{ targetPage.title }</h1>
           </div>
         </header>
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
 
         <div id="main" className="main">
-          <div id="content-main" className="content-main container-lg">
+          <div id="content-main" className="content-main container">
             {targetPage.component}
           </div>
         </div>

+ 9 - 6
apps/app/src/pages/tags.page.tsx

@@ -1,7 +1,8 @@
-import React, { useState, useCallback, ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React, { useState, useCallback } from 'react';
 
-import type { IUser, IUserHasId } from '@growi/core';
-import { GetServerSideProps, GetServerSidePropsContext } from 'next';
+import type { IUser } from '@growi/core';
+import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
@@ -10,17 +11,17 @@ import Head from 'next/head';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IDataTagCount } from '~/interfaces/tag';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri,
+  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri, useCurrentPathname,
 } from '../stores/context';
 
-import { NextPageWithLayout } from './_app.page';
+import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import {
   getServerSideCommonProps, getNextI18NextConfig, generateCustomTitle, useInitSidebarConfig,
@@ -49,6 +50,8 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   // clear the cache for the current page
   const { mutate } = useSWRxCurrentPage();
   mutate(undefined, { revalidate: false });
+  useCurrentPageId(null);
+  useCurrentPathname('/tags');
 
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
   const { t } = useTranslation('');

+ 0 - 43
apps/app/src/server/models/interfaces/page-operation.ts

@@ -1,43 +0,0 @@
-import { PageGrant, type IGrantedGroup } from '@growi/core';
-
-import { ObjectIdLike } from '../../interfaces/mongoose-utils';
-
-export type IPageForResuming = {
-  _id: ObjectIdLike,
-  path: string,
-  isEmpty: boolean,
-  parent?: ObjectIdLike,
-  grant?: number,
-  grantedUsers?: ObjectIdLike[],
-  grantedGroups: IGrantedGroup[],
-  descendantCount: number,
-  status?: number,
-  revision?: ObjectIdLike,
-  lastUpdateUser?: ObjectIdLike,
-  creator?: ObjectIdLike,
-};
-
-export type IUserForResuming = {
-  _id: ObjectIdLike,
-};
-
-export type IOptionsForUpdate = {
-  grant?: PageGrant,
-  userRelatedGrantUserGroupIds?: IGrantedGroup[],
-  isSyncRevisionToHackmd?: boolean,
-  overwriteScopesOfDescendants?: boolean,
-};
-
-export type IOptionsForCreate = {
-  format?: string,
-  grantUserGroupIds?: IGrantedGroup[],
-  grant?: PageGrant,
-  overwriteScopesOfDescendants?: boolean,
-  isSynchronously?: boolean,
-};
-
-export type IOptionsForResuming = {
-  updateMetadata?: boolean,
-  createRedirectPage?: boolean,
-  prevDescendantCount?: number,
-} & IOptionsForUpdate & IOptionsForCreate;

+ 35 - 6
apps/app/src/server/models/page-operation.ts

@@ -1,17 +1,18 @@
+import type { IGrantedGroup } from '@growi/core';
 import { GroupType } from '@growi/core';
 import { addSeconds } from 'date-fns';
+import type {
+  Model, Document, QueryOptions, FilterQuery,
+} from 'mongoose';
 import mongoose, {
-  Schema, Model, Document, QueryOptions, FilterQuery,
+  Schema,
 } from 'mongoose';
 
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
-import {
-  IPageForResuming, IUserForResuming, IOptionsForResuming,
-} from '~/server/models/interfaces/page-operation';
-
 
 import loggerFactory from '../../utils/logger';
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 const TIME_TO_ADD_SEC = 10;
@@ -20,6 +21,34 @@ const logger = loggerFactory('growi:models:page-operation');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
+
+type IPageForResuming = {
+  _id: ObjectIdLike,
+  path: string,
+  isEmpty: boolean,
+  parent?: ObjectIdLike,
+  grant?: number,
+  grantedUsers?: ObjectIdLike[],
+  grantedGroups: IGrantedGroup[],
+  descendantCount: number,
+  status?: number,
+  revision?: ObjectIdLike,
+  lastUpdateUser?: ObjectIdLike,
+  creator?: ObjectIdLike,
+};
+
+type IUserForResuming = {
+  _id: ObjectIdLike,
+};
+
+type IOptionsForResuming = {
+  format: 'md' | 'pdf',
+  updateMetadata?: boolean,
+  createRedirectPage?: boolean,
+  prevDescendantCount?: number,
+} & IOptionsForUpdate & IOptionsForCreate;
+
+
 /*
  * Main Schema
  */

+ 5 - 11
apps/app/src/server/models/page.ts

@@ -5,10 +5,8 @@ import nodePath from 'path';
 
 import {
   type IPage,
-  type IGrantedGroup,
   GroupType, type HasObjectId,
 } from '@growi/core';
-import type { ITag } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -21,6 +19,7 @@ import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import type { IOptionsForCreate } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
@@ -61,7 +60,8 @@ type PaginatedPages = {
   offset: number
 }
 
-export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: IOptionsForCreate) => Promise<PageDocument & { _id: any }>
+
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
@@ -74,6 +74,8 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
+  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | undefined>
+  findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   findTemplate(path: string): Promise<{
     templateBody?: string,
@@ -92,7 +94,6 @@ export interface PageModel extends Model<PageDocument> {
   STATUS_DELETED
 }
 
-type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 const schema = new Schema<PageDocument, PageModel>({
@@ -1050,13 +1051,6 @@ schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this:
   await this.save();
 };
 
-export type PageCreateOptions = {
-  format?: string
-  grantUserGroupIds?: IGrantedGroup[],
-  grant?: number
-  overwriteScopesOfDescendants?: boolean
-}
-
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */

+ 8 - 6
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -3,19 +3,20 @@ import type {
 } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-import express, { Request, Router } from 'express';
+import type { Request, Router } from 'express';
+import express from 'express';
 import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 
 
-import { IPageGrantService } from '~/server/service/page-grant';
+import type { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../../crowi';
+import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { PageModel } from '../../models/page';
+import type { PageModel } from '../../models/page';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
@@ -149,7 +150,8 @@ const routerFactory = (crowi: Crowi): Router => {
         // construct isIPageInfoForListing
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
-        const canDeleteCompletely = pageService.canDeleteCompletely(page, req.user, false, userRelatedGroups); // use normal delete config
+        // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
+        const canDeleteCompletely = pageService.canDeleteCompletely(page, page.creator, req.user, false, userRelatedGroups); // use normal delete config
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo

+ 54 - 0
apps/app/src/server/routes/apiv3/page/check-page-existence.ts

@@ -0,0 +1,54 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { query } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:check-page-existence');
+
+
+type ReqQuery = {
+  path: string,
+}
+
+interface Req extends Request<ReqQuery, ApiV3Response> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    query('path').isString().withMessage('The param "path" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequired,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { path } = req.query;
+
+      if (path == null || Array.isArray(path)) {
+        return res.apiv3Err(new ErrorV3('The param "path" must be an page id'));
+      }
+
+      const count = await Page.countByPathAndViewer(path.toString(), req.user);
+      res.apiv3({ isExist: count > 0 });
+    },
+  ];
+};

+ 254 - 0
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -0,0 +1,254 @@
+import type {
+  IPage, IUser, IUserHasId,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import type { IOptionsForCreate } from '~/interfaces/page';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
+} from '~/server/models';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageTagRelation from '~/server/models/page-tag-relation';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:create-page');
+
+
+async function generateUntitledPath(parentPath: string, basePathname: string, index = 1): Promise<string> {
+  const Page = mongoose.model<IPage>('Page');
+
+  const path = normalizePath(`${normalizePath(parentPath)}/${basePathname}-${index}`);
+  if (await Page.exists({ path, isEmpty: false }) != null) {
+    return generateUntitledPath(parentPath, basePathname, index + 1);
+  }
+  return path;
+}
+
+async function determinePath(_parentPath?: string, _path?: string, optionalParentPath?: string): Promise<string> {
+  // TODO: i18n
+  const basePathname = 'Untitled';
+
+  if (_path != null) {
+    const path = normalizePath(_path);
+
+    // when path is valid
+    if (isCreatablePage(path)) {
+      return normalizePath(path);
+    }
+    // when optionalParentPath is set
+    if (optionalParentPath != null) {
+      return generateUntitledPath(optionalParentPath, basePathname);
+    }
+    // when path is invalid
+    throw new Error('Could not create the page for the path');
+  }
+
+  if (_parentPath != null) {
+    const parentPath = normalizePath(_parentPath);
+
+    // when parentPath is valid
+    if (isCreatablePage(parentPath)) {
+      return generateUntitledPath(parentPath, basePathname);
+    }
+    // when optionalParentPath is set
+    if (optionalParentPath != null) {
+      return generateUntitledPath(optionalParentPath, basePathname);
+    }
+    // when parentPath is invalid
+    throw new Error('Could not create the page for the parentPath');
+  }
+
+  // when both path and parentPath are not specified
+  return generateUntitledPath('/', basePathname);
+}
+
+
+type ReqBody = IApiv3PageCreateParams
+
+interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    body('path').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'path'"),
+    body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'parentPath'"),
+    body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("The empty value is not allowd for the 'optionalParentPath'"),
+    body('body').optional().isString()
+      .withMessage('body must be string or undefined'),
+    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('pageTags').optional().isArray().withMessage('pageTags must be array'),
+    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+  ];
+
+
+  async function determineBodyAndTags(
+      path: string,
+      _body: string | null | undefined, _tags: string[] | null | undefined,
+  ): Promise<{ body: string, tags: string[] }> {
+
+    let body: string = _body ?? '';
+    let tags: string[] = _tags ?? [];
+
+    if (_body == null) {
+      const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+      if (isEnabledAttachTitleHeader) {
+        body += `${attachTitleHeader(path)}\n`;
+      }
+
+      const templateData = await Page.findTemplate(path);
+      if (templateData.templateTags != null) {
+        tags = templateData.templateTags;
+      }
+      if (templateData.templateBody != null) {
+        body += `${templateData.templateBody}\n`;
+      }
+    }
+
+    return { body, tags };
+  }
+
+  async function saveTags({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+    const tagEvent = crowi.event('tag');
+    await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+    tagEvent.emit('update', createdPage, pageTags);
+    return PageTagRelation.listTagNamesByPage(createdPage.id);
+  }
+
+  async function postAction(req: CreatePageRequest, res: ApiV3Response, createdPage: PageDocument) {
+    // persist activity
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: createdPage,
+      action: SupportedAction.ACTION_PAGE_CREATE,
+    };
+    const activityEvent = crowi.event('activity');
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
+    // global notification
+    try {
+      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
+    }
+    catch (err) {
+      logger.error('Create grobal notification failed', err);
+    }
+
+    // user notification
+    const { isSlackEnabled, slackChannels } = req.body;
+    if (isSlackEnabled) {
+      try {
+        const results = await crowi.userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
+    }
+
+    // create subscription
+    try {
+      await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
+    }
+    catch (err) {
+      logger.error('Failed to create subscription document', err);
+    }
+  }
+
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: CreatePageRequest, res: ApiV3Response) => {
+      const {
+        body: bodyByParam, pageTags: tagsByParam,
+      } = req.body;
+
+      let pathToCreate: string;
+      try {
+        const { path, parentPath, optionalParentPath } = req.body;
+        pathToCreate = await determinePath(parentPath, path, optionalParentPath);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(err.toString(), 'could_not_create_page'));
+      }
+
+      if (isUserPage(pathToCreate)) {
+        const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+        }
+      }
+
+      const { body, tags } = await determineBodyAndTags(pathToCreate, bodyByParam, tagsByParam);
+
+      let createdPage;
+      try {
+        const { grant, grantUserGroupIds, overwriteScopesOfDescendants } = req.body;
+        const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+        if (grant != null) {
+          options.grant = grant;
+          options.grantUserGroupIds = grantUserGroupIds;
+        }
+        createdPage = await crowi.pageService.create(
+          pathToCreate,
+          body,
+          req.user,
+          options,
+        );
+      }
+      catch (err) {
+        logger.error('Error occurred while creating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const savedTags = await saveTags({ createdPage, pageTags: tags });
+
+      const result = {
+        page: serializePageSecurely(createdPage),
+        tags: savedTags,
+        revision: serializeRevisionSecurely(createdPage.revision),
+      };
+
+      res.apiv3(result, 201);
+
+      postAction(req, res, createdPage);
+    },
+  ];
+};

+ 0 - 234
apps/app/src/server/routes/apiv3/page/cteate-page.ts

@@ -1,234 +0,0 @@
-import type {
-  IGrantedGroup,
-  IPage, IUser, IUserHasId, PageGrant,
-} from '@growi/core';
-import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
-import { addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
-import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
-import { body } from 'express-validator';
-import mongoose from 'mongoose';
-
-import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { subscribeRuleNames } from '~/interfaces/in-app-notification';
-import type Crowi from '~/server/crowi';
-import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
-import {
-  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
-} from '~/server/models';
-import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
-import type { PageDocument, PageModel } from '~/server/models/page';
-import PageTagRelation from '~/server/models/page-tag-relation';
-import { configManager } from '~/server/service/config-manager';
-import loggerFactory from '~/utils/logger';
-
-import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
-import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
-import type { ApiV3Response } from '../interfaces/apiv3-response';
-
-
-const logger = loggerFactory('growi:routes:apiv3:page:create-page');
-
-
-async function generateUniquePath(basePath: string, index = 1): Promise<string> {
-  const Page = mongoose.model<IPage>('Page');
-
-  const path = basePath + index;
-  const existingPageId = await Page.exists({ path, isEmpty: false });
-  if (existingPageId != null) {
-    return generateUniquePath(basePath, index + 1);
-  }
-  return path;
-}
-
-type ReqBody = {
-  path: string,
-
-  grant?: PageGrant,
-  grantUserGroupIds?: IGrantedGroup[],
-
-  body?: string,
-  overwriteScopesOfDescendants?: boolean,
-  isSlackEnabled?: boolean,
-  slackChannels?: any,
-  pageTags?: string[],
-  shouldGeneratePath?: boolean,
-}
-
-interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
-  user: IUserHasId,
-}
-
-type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
-
-export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
-  const Page = mongoose.model<IPage, PageModel>('Page');
-  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
-
-  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
-  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
-
-  const activityEvent = crowi.event('activity');
-  const addActivity = generateAddActivityMiddleware(crowi);
-
-  const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-
-  async function saveTagsAction({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
-    if (pageTags != null) {
-      const tagEvent = crowi.event('tag');
-      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
-      tagEvent.emit('update', createdPage, pageTags);
-      return PageTagRelation.listTagNamesByPage(createdPage.id);
-    }
-
-    return [];
-  }
-
-  const validator: ValidationChain[] = [
-    body('body').optional().isString()
-      .withMessage('body must be string or undefined'),
-    body('path').exists().not().isEmpty({ ignore_whitespace: true })
-      .withMessage('path is required'),
-    body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-    body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-    body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
-    body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-    body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
-    body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
-  ];
-
-  return [
-    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
-    validator, apiV3FormValidator,
-    async(req: CreatePageRequest, res: ApiV3Response) => {
-      const {
-        body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-      } = req.body;
-
-      let { path, grant, grantUserGroupIds } = req.body;
-
-      // check whether path starts slash
-      path = addHeadingSlash(path);
-
-      if (shouldGeneratePath) {
-        try {
-          const rootPath = '/';
-          const defaultTitle = '/Untitled';
-          const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
-          path = await generateUniquePath(basePath);
-
-          // if the generated path is not creatable, create the path under the root path
-          if (!isCreatablePage(path)) {
-            path = await generateUniquePath(defaultTitle);
-            // initialize grant data
-            grant = 1;
-            grantUserGroupIds = undefined;
-          }
-        }
-        catch (err) {
-          return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
-        }
-      }
-
-      if (!isCreatablePage(path)) {
-        return res.apiv3Err(`Could not use the path '${path}'`);
-      }
-
-      if (isUserPage(path)) {
-        const isExistUser = await User.isExistUserByUserPagePath(path);
-        if (!isExistUser) {
-          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
-        }
-      }
-
-      const options: IOptionsForCreate = { overwriteScopesOfDescendants };
-      if (grant != null) {
-        options.grant = grant;
-        options.grantUserGroupIds = grantUserGroupIds;
-      }
-
-      const isNoBodyPage = body === undefined;
-      let initialTags: string[] = [];
-      let initialBody = '';
-      if (isNoBodyPage) {
-        const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
-        if (isEnabledAttachTitleHeader) {
-          initialBody += `${attachTitleHeader(path)}\n`;
-        }
-
-        const templateData = await Page.findTemplate(path);
-        if (templateData.templateTags != null) {
-          initialTags = templateData.templateTags;
-        }
-        if (templateData.templateBody != null) {
-          initialBody += `${templateData.templateBody}\n`;
-        }
-      }
-
-      let createdPage;
-      try {
-        createdPage = await crowi.pageService.create(
-          path,
-          body ?? initialBody,
-          req.user,
-          options,
-        );
-      }
-      catch (err) {
-        logger.error('Error occurred while creating a page.', err);
-        return res.apiv3Err(err);
-      }
-
-      const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : (pageTags ?? ['']) });
-
-      const result = {
-        page: serializePageSecurely(createdPage),
-        tags: savedTags,
-        revision: serializeRevisionSecurely(createdPage.revision),
-      };
-
-      const parameters = {
-        targetModel: SupportedTargetModel.MODEL_PAGE,
-        target: createdPage,
-        action: SupportedAction.ACTION_PAGE_CREATE,
-      };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      res.apiv3(result, 201);
-
-      try {
-      // global notification
-        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
-      }
-      catch (err) {
-        logger.error('Create grobal notification failed', err);
-      }
-
-      // user notification
-      if (isSlackEnabled) {
-        try {
-          const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-          results.forEach((result) => {
-            if (result.status === 'rejected') {
-              logger.error('Create user notification failed', result.reason);
-            }
-          });
-        }
-        catch (err) {
-          logger.error('Create user notification failed', err);
-        }
-      }
-
-      // create subscription
-      try {
-        await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-      }
-      catch (err) {
-        logger.error('Failed to create subscription document', err);
-      }
-    },
-  ];
-};

+ 109 - 0
apps/app/src/server/routes/apiv3/page/index.js

@@ -20,6 +20,10 @@ import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 
+import { checkPageExistenceHandlersFactory } from './check-page-existence';
+import { createPageHandlersFactory } from './create-page';
+import { updatePageHandlersFactory } from './update-page';
+
 
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
@@ -311,6 +315,111 @@ module.exports = (crowi) => {
     return res.apiv3({ page, pages });
   });
 
+  router.get('/exist', checkPageExistenceHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      post:
+   *        tags: [Page]
+   *        operationId: createPage
+   *        description: Create page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    type: string
+   *                    description: Text of page
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                  grantUserGroupId:
+   *                    type: string
+   *                    description: UserGroup ID
+   *                    example: 5ae5fccfc5577b0004dbd8ab
+   *                  pageTags:
+   *                    type: array
+   *                    items:
+   *                      $ref: '#/components/schemas/Tag'
+   *                  shouldGeneratePath:
+   *                    type: boolean
+   *                    description: Determine whether a new path should be generated
+   *                required:
+   *                  - body
+   *                  - path
+   *        responses:
+   *          201:
+   *            description: Succeeded to create page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        tags:
+   *                          type: array
+   *                          items:
+   *                            $ref: '#/components/schemas/Tags'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
+   *          409:
+   *            description: page path is already existed
+   */
+  router.post('/', createPageHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      put:
+   *        tags: [Page]
+   *        operationId: updatePage
+   *        description: Update page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    $ref: '#/components/schemas/Revision/properties/body'
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  revision_id:
+   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                required:
+   *                  - body
+   *                  - page_id
+   *                  - revision_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to update page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  router.put('/', updatePageHandlersFactory(crowi));
+
   /**
    * @swagger
    *

+ 172 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -0,0 +1,172 @@
+import type {
+  IPage, IRevisionHasId, IUserHasId,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IApiv3PageUpdateParams } from '~/interfaces/apiv3';
+import type { IOptionsForUpdate } from '~/interfaces/page';
+import { RehypeSanitizeOption } from '~/interfaces/rehype';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely, serializeUserSecurely,
+} from '~/server/models';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+import { preNotifyService } from '~/server/service/pre-notify';
+import Xss from '~/services/xss';
+import XssOption from '~/services/xss/xssOption';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:page:update-page');
+
+
+type ReqBody = IApiv3PageUpdateParams;
+
+interface UpdatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type UpdatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const Revision = mongoose.model<IRevisionHasId>('Revision');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+
+  const xss = (() => {
+    const initializedConfig = {
+      isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+      tagWhitelist: crowi.xssService.getTagWhitelist(),
+      attrWhitelist: crowi.xssService.getAttrWhitelist(),
+      // TODO: Omit rehype related property from XssOptionConfig type
+      //  Server side xss implementation does not require it.
+      xssOption: RehypeSanitizeOption.CUSTOM,
+    };
+    const xssOption = new XssOption(initializedConfig);
+    return new Xss(xssOption);
+  })();
+
+  // define validators for req.body
+  const validator: ValidationChain[] = [
+    body('pageId').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("'pageId' must be specified"),
+    body('revisionId').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage("'revisionId' must be specified"),
+    body('body').exists().isString()
+      .withMessage("The empty value is not allowd for the 'body'"),
+    body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
+    body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+  ];
+
+
+  async function postAction(req: UpdatePageRequest, res: ApiV3Response, updatedPage: PageDocument) {
+    // persist activity
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: updatedPage,
+      action: SupportedAction.ACTION_PAGE_UPDATE,
+    };
+    const activityEvent = crowi.event('activity');
+    activityEvent.emit(
+      'update', res.locals.activity._id, parameters,
+      { path: updatedPage.path, creator: updatedPage.creator._id.toString() },
+      preNotifyService.generatePreNotify,
+    );
+
+    // global notification
+    try {
+      await crowi.globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, updatedPage, req.user);
+    }
+    catch (err) {
+      logger.error('Edit notification failed', err);
+    }
+
+    // user notification
+    const { revisionId, isSlackEnabled, slackChannels } = req.body;
+    if (isSlackEnabled) {
+      try {
+        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', { previousRevision: revisionId });
+        results.forEach((result) => {
+          if (result.status === 'rejected') {
+            logger.error('Create user notification failed', result.reason);
+          }
+        });
+      }
+      catch (err) {
+        logger.error('Create user notification failed', err);
+      }
+    }
+  }
+
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: UpdatePageRequest, res: ApiV3Response) => {
+      const { pageId, revisionId, body } = req.body;
+
+      // check page existence
+      const isExist = await Page.count({ _id: pageId }) > 0;
+      if (!isExist) {
+        return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
+      }
+
+      // check revision
+      const currentPage = await Page.findByIdAndViewer(pageId, req.user);
+      if (currentPage != null && !currentPage.isUpdatable(revisionId)) {
+        const latestRevision = await Revision.findById(currentPage.revision).populate('author');
+        const returnLatestRevision = {
+          revisionId: latestRevision?._id.toString(),
+          revisionBody: xss.process(latestRevision?.body),
+          createdAt: latestRevision?.createdAt,
+          user: serializeUserSecurely(latestRevision?.author),
+        };
+        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', 'conflict'), 409, {
+          returnLatestRevision,
+        });
+      }
+
+      let updatedPage;
+      try {
+        const { grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants } = req.body;
+        const options: IOptionsForUpdate = { overwriteScopesOfDescendants };
+        if (grant != null) {
+          options.grant = grant;
+          options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
+        }
+        const previousRevision = await Revision.findById(revisionId);
+        updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
+      }
+      catch (err) {
+        logger.error('Error occurred while updating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const result = {
+        page: serializePageSecurely(updatedPage),
+        revision: serializeRevisionSecurely(updatedPage.revision),
+      };
+
+      res.apiv3(result, 201);
+
+      postAction(req, res, updatedPage);
+    },
+  ];
+};

+ 0 - 58
apps/app/src/server/routes/apiv3/pages/index.js

@@ -19,7 +19,6 @@ import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user
 import { serializePageSecurely } from '../../../models/serializers/page-serializer';
 import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
 import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
-import { createPageHandlersFactory } from '../page/cteate-page';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
@@ -205,63 +204,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages:
-   *      post:
-   *        tags: [Pages]
-   *        operationId: createPage
-   *        description: Create page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    type: string
-   *                    description: Text of page
-   *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                  grantUserGroupId:
-   *                    type: string
-   *                    description: UserGroup ID
-   *                    example: 5ae5fccfc5577b0004dbd8ab
-   *                  pageTags:
-   *                    type: array
-   *                    items:
-   *                      $ref: '#/components/schemas/Tag'
-   *                  shouldGeneratePath:
-   *                    type: boolean
-   *                    description: Determine whether a new path should be generated
-   *                required:
-   *                  - body
-   *                  - path
-   *        responses:
-   *          201:
-   *            description: Succeeded to create page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    data:
-   *                      type: object
-   *                      properties:
-   *                        page:
-   *                          $ref: '#/components/schemas/Page'
-   *                        tags:
-   *                          type: array
-   *                          items:
-   *                            $ref: '#/components/schemas/Tags'
-   *                        revision:
-   *                          $ref: '#/components/schemas/Revision'
-   *          409:
-   *            description: page path is already existed
-   */
-  router.post('/', createPageHandlersFactory(crowi));
-
   /**
    * @swagger
    *

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -121,8 +121,6 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
-  apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in

+ 12 - 227
apps/app/src/server/routes/page.js

@@ -1,7 +1,6 @@
 import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
-import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
@@ -9,11 +8,7 @@ import { GlobalNotificationSettingEvent } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
 import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
-import { preNotifyService } from '../service/pre-notify';
-
-const { serializePageSecurely } = require('../models/serializers/page-serializer');
-const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
-const { serializeUserSecurely } = require('../models/serializers/user-serializer');
+import { configManager } from '../service/config-manager';
 
 /**
  * @swagger
@@ -135,22 +130,24 @@ const { serializeUserSecurely } = require('../models/serializers/user-serializer
  */
 
 /* eslint-disable no-use-before-define */
+/**
+ * @type { (crowi: import('../crowi').default, app) => any }
+ */
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
   const { pagePathUtils } = require('@growi/core/dist/utils');
 
+  /** @type {import('../models/page').PageModel} */
   const Page = crowi.model('Page');
+
   const PageRedirect = mongoose.model('PageRedirect');
 
   const ApiResponse = require('../util/apiResponse');
 
-  const { configManager, xssService } = crowi;
+  const { xssService } = crowi;
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-  const activityEvent = crowi.event('activity');
 
   const Xss = require('~/services/xss/index');
   const initializedConfig = {
@@ -220,211 +217,6 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.validator = validator;
 
-  /**
-   * @swagger
-   *
-   *    /pages.update:
-   *      post:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: updatePage
-   *        summary: /pages.update
-   *        description: Update page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  body:
-   *                    $ref: '#/components/schemas/Revision/properties/body'
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  revision_id:
-   *                    $ref: '#/components/schemas/Revision/properties/_id'
-   *                  grant:
-   *                    $ref: '#/components/schemas/Page/properties/grant'
-   *                required:
-   *                  - body
-   *                  - page_id
-   *                  - revision_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to update page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    revision:
-   *                      $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.update Update page
-   * @apiName UpdatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} body
-   * @apiParam {String} page_id
-   * @apiParam {String} revision_id
-   * @apiParam {String} grant
-   *
-   * In the case of the page exists:
-   * - If revision_id is specified => update the page,
-   * - If revision_id is not specified => force update by the new contents.
-   */
-  api.update = async function(req, res) {
-    const pageBody = req.body.body ?? null;
-    const pageId = req.body.page_id || null;
-    const revisionId = req.body.revision_id || null;
-    const grant = req.body.grant || null;
-    const userRelatedGrantUserGroupIds = req.body.userRelatedGrantUserGroupIds || null;
-    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
-    const slackChannels = req.body.slackChannels || null;
-
-    if (pageId === null || pageBody === null || revisionId === null) {
-      return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
-    }
-
-    // check page existence
-    const isExist = await Page.count({ _id: pageId }) > 0;
-    if (!isExist) {
-      return res.json(ApiResponse.error(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-    }
-
-    // check revision
-    const Revision = crowi.model('Revision');
-    let page = await Page.findByIdAndViewer(pageId, req.user);
-    if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      const latestRevision = await Revision.findById(page.revision).populate('author');
-      const returnLatestRevision = {
-        revisionId: latestRevision._id.toString(),
-        revisionBody: xss.process(latestRevision.body),
-        createdAt: latestRevision.createdAt,
-        user: serializeUserSecurely(latestRevision.author),
-      };
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
-    }
-
-    const previousRevision = await Revision.findById(revisionId);
-    try {
-      page = await crowi.pageService.updatePage(page, pageBody, previousRevision.body, req.user, options);
-    }
-    catch (err) {
-      logger.error('error on _api/pages.update', err);
-      return res.json(ApiResponse.error(err));
-    }
-
-
-    const result = {
-      page: serializePageSecurely(page),
-      revision: serializeRevisionSecurely(page.revision),
-    };
-    res.json(ApiResponse.success(result));
-
-    // global notification
-    try {
-      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, page, req.user);
-    }
-    catch (err) {
-      logger.error('Edit notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(page, req.user, slackChannels, 'update', { previousRevision });
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_UPDATE,
-    };
-
-    activityEvent.emit(
-      'update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() }, preNotifyService.generatePreNotify,
-    );
-  };
-
-  /**
-   * @swagger
-   *
-   *    /pages.exist:
-   *      get:
-   *        tags: [Pages]
-   *        operationId: getPageExistence
-   *        summary: /pages.exist
-   *        description: Get page existence
-   *        parameters:
-   *          - in: query
-   *            name: pagePaths
-   *            schema:
-   *              type: string
-   *              description: Page path list in JSON Array format
-   *              example: '["/", "/user/unknown"]'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get page existence.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: string
-   *                      description: Properties of page path and existence
-   *                      example: '{"/": true, "/user/unknown": false}'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.exist Get if page exists
-   * @apiName GetPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} pages (stringified JSON)
-   */
-  api.exist = async function(req, res) {
-    const pagePaths = JSON.parse(req.query.pagePaths || '[]');
-
-    const pages = {};
-    await Promise.all(pagePaths.map(async(path) => {
-      // check page existence
-      const isExist = await Page.count({ path }) > 0;
-      pages[path] = isExist;
-      return;
-    }));
-
-    const result = { pages };
-
-    return res.json(ApiResponse.success(result));
-  };
-
   /**
    * @swagger
    *
@@ -563,6 +355,7 @@ module.exports = function(crowi, app) {
       endpoint: req.originalUrl,
     };
 
+    /** @type {import('../models/page').PageDocument | undefined} */
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
     if (page == null) {
@@ -573,22 +366,14 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
     }
 
-    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;
-    }
+    const creatorId = await crowi.pageService.getCreatorIdForCanDelete(page);
 
     debug('Delete page', page._id, page.path);
 
     try {
       if (isCompletely) {
         const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(req.user);
-        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, req.user, isRecursively, userRelatedGroups);
+        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, creatorId, req.user, isRecursively, userRelatedGroups);
         if (!canDeleteCompletely) {
           return res.json(ApiResponse.error('You cannot delete this page completely', 'complete_deletion_not_allowed_for_user'));
         }
@@ -615,8 +400,8 @@ 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.path, creator, req.user, isRecursively)) {
-          return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
+        if (!crowi.pageService.canDelete(page, creatorId, req.user, isRecursively)) {
+          return res.json(ApiResponse.error('You cannot delete this page', 'user_not_admin'));
         }
 
         if (pagePathUtils.isUsersHomepage(page.path)) {

+ 40 - 48
apps/app/src/server/service/installer.ts

@@ -1,7 +1,8 @@
 import path from 'path';
 
-import { Lang } from '@growi/core';
-import type { IPage, IUser } from '@growi/core';
+import type {
+  Lang, IPage, IUser,
+} from '@growi/core';
 import { addSeconds } from 'date-fns';
 import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
@@ -9,10 +10,10 @@ import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
 import { generateConfigsForInstalling } from '../models/config';
 
-import type { ConfigManager } from './config-manager';
-import SearchService from './search';
+import { configManager } from './config-manager';
 
 const logger = loggerFactory('growi:service:installer');
 
@@ -26,17 +27,16 @@ export type AutoInstallOptions = {
 
 export class InstallerService {
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  crowi: any;
+  crowi: Crowi;
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi: any) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
   }
 
   private async initSearchIndex() {
-    const searchService: SearchService = this.crowi.searchService;
-    if (!searchService.isReachable) {
+    const { searchService } = this.crowi;
+
+    if (searchService == null || !searchService.isReachable) {
       return;
     }
 
@@ -48,18 +48,19 @@ export class InstallerService {
     }
   }
 
-  private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
+  private async createPage(filePath, pagePath): Promise<IPage|undefined> {
+    const { pageService } = this.crowi;
+
     try {
       const markdown = fs.readFileSync(filePath);
-      return this.crowi.pageService.create(pagePath, markdown, owner, { isSynchronously: true }) as IPage;
+      return pageService.forceCreateBySystem(pagePath, markdown.toString(), {});
     }
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);
     }
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  private async createInitialPages(owner, lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
+  private async createInitialPages(lang: Lang, initialPagesCreatedAt?: Date): Promise<any> {
     const { localeDir } = this.crowi;
     // create /Sandbox/*
     /*
@@ -67,10 +68,10 @@ export class InstallerService {
      *   1. avoid creating the same pages
      *   2. avoid difference for order in VRT
      */
-    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner);
-    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner);
+    await this.createPage(path.join(localeDir, lang, 'sandbox.md'), '/Sandbox');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams');
+    await this.createPage(path.join(localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math');
 
     // update createdAt and updatedAt fields of all pages
     if (initialPagesCreatedAt != null) {
@@ -110,8 +111,6 @@ export class InstallerService {
    * Execute only once for installing application
    */
   private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
-    const configManager: ConfigManager = this.crowi.configManager;
-
     const initialConfig = generateConfigsForInstalling();
     initialConfig['app:globalLang'] = globalLang;
 
@@ -125,45 +124,38 @@ export class InstallerService {
   async install(firstAdminUserToSave: Pick<IUser, 'name' | 'username' | 'email' | 'password'>, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
     await this.initDB(globalLang, options);
 
-    // TODO typescriptize models/user.js and remove eslint-disable-next-line
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const User = mongoose.model('User') as any;
-    const Page = mongoose.model('Page') as any;
+    const User = mongoose.model<IUser, { createUser }>('User');
 
     // create portal page for '/' before creating admin user
-    await this.createPage(
-      path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
-      '/',
-      { _id: '000000000000000000000000' }, // use 0 as a mock user id
-    );
-
-    // create first admin user
-    // TODO: with transaction
-    let adminUser;
     try {
+      await this.createPage(
+        path.join(this.crowi.localeDir, globalLang, 'welcome.md'),
+        '/',
+      );
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
+
+    try {
+      // create first admin user
       const {
         name, username, email, password,
       } = firstAdminUserToSave;
-      adminUser = await User.createUser(name, username, email, password, globalLang);
-      await adminUser.asyncGrantAdmin();
+      const adminUser = await User.createUser(name, username, email, password, globalLang);
+      await (adminUser as any).asyncGrantAdmin();
+
+      // create initial pages
+      await this.createInitialPages(globalLang, options?.serverDate);
+
+      return adminUser;
     }
     catch (err) {
+      logger.error(err);
       throw new FailedToCreateAdminUserError(err);
     }
 
-    // add owner after creating admin user
-    const Revision = this.crowi.model('Revision');
-    const rootPage = await Page.findOne({ path: '/' });
-    const rootRevision = await Revision.findOne({ path: '/' });
-    rootPage.creator = adminUser._id;
-    rootPage.lastUpdateUser = adminUser._id;
-    rootRevision.author = adminUser._id;
-    await Promise.all([rootPage.save(), rootRevision.save()]);
-
-    // create initial pages
-    await this.createInitialPages(adminUser, globalLang, options?.serverDate);
-
-    return adminUser;
   }
 
 }

+ 3 - 3
apps/app/src/server/service/page-grant.ts

@@ -10,12 +10,12 @@ import mongoose from 'mongoose';
 
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import { PageDocument, PageModel } from '~/server/models/page';
+import type { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import UserGroup from '~/server/models/user-group';
 import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import UserGroupRelation from '../models/user-group-relation';
 import { divideByType } from '../util/granted-group';
 

+ 77 - 65
apps/app/src/server/service/page/index.ts

@@ -21,6 +21,7 @@ import { Comment } from '~/features/comment/server';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config';
 import {
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
@@ -30,8 +31,9 @@ import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
+import type { CreateMethod } from '~/server/models/page';
 import {
-  type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
+  type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -42,7 +44,6 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import type { ObjectIdLike } from '../../interfaces/mongoose-utils';
 import { Attachment } from '../../models';
 import { PathAlreadyExistsError } from '../../models/errors';
-import type { IOptionsForCreate, IOptionsForUpdate } from '../../models/interfaces/page-operation';
 import type { PageOperationDocument } from '../../models/page-operation';
 import PageOperation from '../../models/page-operation';
 import PageRedirect from '../../models/page-redirect';
@@ -199,13 +200,15 @@ class PageService implements IPageService {
 
   /**
    * Check if page can be deleted completely.
-   * Use pageGrantService.getUserRelatedGroups before execution of canDeleteCompletely to get value for userRelatedGroups.
-   * Do NOT use getUserRelatedGrantedGroups inside this method, because canDeleteCompletely should not be async as for now.
-   * The reason for this is because canDeleteCompletely is called in /page-listing/info in a for loop,
+   * Use the following methods before execution of canDeleteCompletely to get params.
+   *   - pageService.getCreatorIdForCanDelete: creatorId
+   *   - pageGrantService.getUserRelatedGroups: userRelatedGroups
+   * Do NOT make this method async as for now, because canDeleteCompletely is called in /page-listing/info in a for loop,
    * and /page-listing/info should not be an execution heavy API.
    */
   canDeleteCompletely(
       page: PageDocument,
+      creatorId: ObjectIdLike | null,
       operator: any | null,
       isRecursively: boolean,
       userRelatedGroups: PopulatedGrantedGroup[],
@@ -215,25 +218,28 @@ class PageService implements IPageService {
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
-    if (!this.canDeleteCompletelyAsMultiGroupGrantedPage(page, operator, userRelatedGroups)) return false;
+    if (!this.canDeleteCompletelyAsMultiGroupGrantedPage(page, creatorId, operator, userRelatedGroups)) return false;
 
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
 
-    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
   /**
    * If page is multi-group granted, check if operator is allowed to completely delete the page.
    * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E5%AE%8C%E5%85%A8%E3%81%AB%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B%E6%93%8D%E4%BD%9C
+   * creatorId must be obtained by getCreatorIdForCanDelete
    */
-  canDeleteCompletelyAsMultiGroupGrantedPage(page: PageDocument, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+  canDeleteCompletelyAsMultiGroupGrantedPage(
+      page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean {
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const isAllGroupMembershipRequiredForPageCompleteDeletion = this.crowi.configManager.getConfig(
       'crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
     );
 
     const isAdmin = operator?.admin ?? false;
-    const isAuthor = operator?._id == null ? false : operator._id.equals(page.creator);
+    const isAuthor = operator?._id == null ? false : operator._id.equals(creatorId);
     const isAdminOrAuthor = isAdmin || isAuthor;
 
     if (page.grant === PageGrant.GRANT_USER_GROUP
@@ -248,7 +254,19 @@ class PageService implements IPageService {
     return true;
   }
 
-  canDelete(page: PageDocument, operator: any | null, isRecursively: boolean): boolean {
+  // When page is empty, the 'canDelete' judgement should be done using the creator of the closest non-empty ancestor page.
+  async getCreatorIdForCanDelete(page: PageDocument): Promise<ObjectIdLike | null> {
+    if (page.isEmpty) {
+      const Page = mongoose.model<IPage, PageModel>('Page');
+      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+      return notEmptyClosestAncestor?.creator ?? null;
+    }
+
+    return page.creator ?? null;
+  }
+
+  // Use getCreatorIdForCanDelete before execution of canDelete to get creatorId.
+  canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean {
     if (operator == null || isTopPage(page.path) || isUsersTopPage(page.path)) return false;
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
@@ -256,7 +274,7 @@ class PageService implements IPageService {
 
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
 
-    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
   canDeleteUserHomepageByConfig(): boolean {
@@ -274,7 +292,7 @@ class PageService implements IPageService {
   }
 
   private canDeleteLogic(
-      creatorId: ObjectIdLike,
+      creatorId: ObjectIdLike | null,
       operator,
       isRecursively: boolean,
       authority: IPageDeleteConfigValueToProcessValidation | null,
@@ -328,12 +346,14 @@ class PageService implements IPageService {
       pages: PageDocument[],
       user: IUserHasId,
       isRecursively: boolean,
-      canDeleteFunction: (page: PageDocument, operator: any, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
+      canDeleteFunction: (
+        page: PageDocument, creatorId: ObjectIdLike, operator: any, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]
+      ) => boolean,
   ): Promise<PageDocument[]> {
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
     const filteredPages = pages.filter(async(p) => {
       if (p.isEmpty) return true;
-      const canDelete = canDeleteFunction(p, user, isRecursively, userRelatedGroups);
+      const canDelete = canDeleteFunction(p, p.creator, user, isRecursively, userRelatedGroups);
       return canDelete;
     });
 
@@ -420,18 +440,12 @@ class PageService implements IPageService {
 
     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 creatorId = await this.getCreatorIdForCanDelete(page);
 
     const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
 
-    const isDeletable = this.canDelete(page, user, false);
-    const isAbleToDeleteCompletely = this.canDeleteCompletely(page, user, false, userRelatedGroups); // use normal delete config
+    const isDeletable = this.canDelete(page, creatorId, user, false);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely(page, creatorId, user, false, userRelatedGroups); // use normal delete config
 
     return {
       data: page,
@@ -1124,7 +1138,7 @@ class PageService implements IPageService {
     const copyPage = { ...page };
 
     // 3. Duplicate target
-    const options: PageCreateOptions = {
+    const options: IOptionsForCreate = {
       grant,
       grantUserGroupIds: grantedGroupIds,
     };
@@ -2626,7 +2640,7 @@ class PageService implements IPageService {
   }
 
   async normalizeParentByPath(path: string, user): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
     const { PageQueryBuilder } = Page;
 
     // This validation is not 100% correct since it ignores user to count
@@ -2664,16 +2678,14 @@ class PageService implements IPageService {
     if (shouldCreateNewPage) {
       const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
 
-      const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
-        grant: notEmptyParent.grant,
-        grantUserGroupIds: notEmptyParent.grantedGroups,
-        grantedUsers: notEmptyParent.grantedUsers,
-      };
-
       systematicallyCreatedPage = await this.forceCreateBySystem(
         path,
         '',
-        options,
+        {
+          grant: notEmptyParent?.grant,
+          grantUserIds: notEmptyParent?.grantedUsers.map(u => getIdForRef(u)),
+          grantUserGroupIds: notEmptyParent?.grantedGroups,
+        },
       );
       page = systematicallyCreatedPage;
     }
@@ -3680,12 +3692,12 @@ class PageService implements IPageService {
       path: string,
       grantData: {
         grant?: PageGrant,
-        grantedUserIds?: ObjectIdLike[],
+        grantUserIds?: ObjectIdLike[],
         grantUserGroupIds?: IGrantedGroup[],
       },
       shouldValidateGrant: boolean,
       user?,
-      options?: Partial<PageCreateOptions>,
+      options?: IOptionsForCreate,
   ): Promise<boolean> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
@@ -3704,7 +3716,7 @@ class PageService implements IPageService {
     }
 
     // UserGroup & Owner validation
-    const { grant, grantedUserIds, grantUserGroupIds } = grantData;
+    const { grant, grantUserIds, grantUserGroupIds } = grantData;
     if (shouldValidateGrant) {
       if (user == null) {
         throw Error('user is required to validate grant');
@@ -3716,7 +3728,7 @@ class PageService implements IPageService {
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
         const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
-        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantUserIds, grantUserGroupIds, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
@@ -3743,25 +3755,32 @@ class PageService implements IPageService {
    * Create a page
    * Set options.isSynchronously to true to await all process when you want to run this method multiple times at short intervals.
    */
-  async create(path: string, body: string, user, options: IOptionsForCreate = {}): Promise<PageDocument> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
+  async create(_path: string, body: string, user: HasObjectId, options: IOptionsForCreate = {}): Promise<PageDocument> {
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
-      return this.createV4(path, body, user, options);
+      return this.createV4(_path, body, user, options);
     }
 
     // Values
-    // eslint-disable-next-line no-param-reassign
-    path = this.crowi.xss.process(path); // sanitize path
-    const {
-      format = 'markdown', grantUserGroupIds,
-    } = options;
-    const grant = isTopPage(path) ? PageGrant.GRANT_PUBLIC : options.grant;
+    const path: string = this.crowi.xss.process(_path); // sanitize path
+
+    // Retrieve closest ancestor document
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
+    const closestAncestor = await Page.findNonEmptyClosestAncestor(path);
+
+    // Determine grantData
+    const grant = options.grant ?? closestAncestor?.grant ?? PageGrant.GRANT_PUBLIC;
+    const grantUserIds = grant === PageGrant.GRANT_OWNER ? [user._id] : undefined;
+    const grantUserGroupIds = options.grantUserGroupIds
+      ?? (
+        closestAncestor != null
+          ? await this.pageGrantService.getUserRelatedGrantedGroups(closestAncestor, user)
+          : undefined
+      );
     const grantData = {
       grant,
-      grantedUserIds: grant === PageGrant.GRANT_OWNER ? [user._id] : undefined,
+      grantUserIds,
       grantUserGroupIds,
     };
 
@@ -3797,7 +3816,7 @@ class PageService implements IPageService {
 
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
@@ -3821,12 +3840,7 @@ class PageService implements IPageService {
       throw err;
     }
 
-    if (options.isSynchronously) {
-      await this.createSubOperation(savedPage, user, options, pageOp._id);
-    }
-    else {
-      this.createSubOperation(savedPage, user, options, pageOp._id);
-    }
+    this.createSubOperation(savedPage, user, options, pageOp._id);
 
     return savedPage;
   }
@@ -3912,7 +3926,7 @@ class PageService implements IPageService {
       path: string,
       grantData: {
         grant: PageGrant,
-        grantedUserIds?: ObjectIdLike[],
+        grantUserIds?: ObjectIdLike[],
         grantUserGroupId?: ObjectIdLike,
       },
   ): Promise<boolean> {
@@ -3921,13 +3935,13 @@ class PageService implements IPageService {
 
   /**
    * @private
-   * This method receives the same arguments as the PageService.create method does except for the added type '{ grantedUsers?: ObjectIdLike[] }'.
+   * This method receives the same arguments as the PageService.create method does except for the added type '{ grantUserIds?: ObjectIdLike[] }'.
    * This additional value is used to determine the grantedUser of the page to be created by system.
    * This method must not run isGrantNormalized method to validate grant. **If necessary, run it before use this method.**
    * -- Reason 1: This is because it is not expected to use this method when the grant validation is required.
    * -- Reason 2: This is because it is not expected to use this method when the program cannot determine the operator.
    */
-  private async forceCreateBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
+  async forceCreateBySystem(path: string, body: string, options: IOptionsForCreate & { grantUserIds?: ObjectIdLike[] }): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
@@ -3940,7 +3954,7 @@ class PageService implements IPageService {
     path = this.crowi.xss.process(path); // sanitize path
 
     const {
-      format = 'markdown', grantUserGroupIds, grantedUsers,
+      grantUserGroupIds, grantUserIds,
     } = options;
     const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
 
@@ -3949,12 +3963,12 @@ class PageService implements IPageService {
 
     const grantData = {
       grant,
-      grantedUserIds: isGrantOwner ? grantedUsers : undefined,
+      grantUserIds: isGrantOwner ? grantUserIds : undefined,
       grantUserGroupIds,
     };
 
     // Validate
-    if (isGrantOwner && grantedUsers?.length !== 1) {
+    if (isGrantOwner && grantUserIds?.length !== 1) {
       throw Error('grantedUser must exist when grant is GRANT_OWNER');
     }
     const canProcessForceCreateBySystem = await this.canProcessForceCreateBySystem(path, grantData);
@@ -3970,7 +3984,7 @@ class PageService implements IPageService {
     this.setFieldExceptForGrantRevisionParent(page, path);
 
     // Apply scope
-    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupIds);
+    page.applyScope({ _id: grantUserIds?.[0] }, grant, grantUserGroupIds);
 
     // Set parent
     if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
@@ -3987,7 +4001,7 @@ class PageService implements IPageService {
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
     const dummyUser = { _id: new mongoose.Types.ObjectId() };
-    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser);
     savedPage = await pushRevision(savedPage, newRevision, dummyUser);
 
     // Update descendantCount
@@ -4016,7 +4030,6 @@ class PageService implements IPageService {
     const options: IOptionsForUpdate = {
       grant,
       userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
-      isSyncRevisionToHackmd: false,
     };
 
     return this.updatePage(page, null, null, user, options);
@@ -4091,7 +4104,7 @@ class PageService implements IPageService {
       pageData: PageDocument,
       body: string | null,
       previousBody: string | null,
-      user,
+      user: IUserHasId,
       options: IOptionsForUpdate = {},
   ): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
@@ -4230,7 +4243,6 @@ class PageService implements IPageService {
     const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
       ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, pageData, user))
       : pageData.grantedGroups;
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
 
     // validate multiple group grant before save using pageData and options
     await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);

+ 15 - 4
apps/app/src/server/service/page/page-service.ts

@@ -1,15 +1,20 @@
 import type EventEmitter from 'events';
 
-import type { IPageInfo, IPageInfoForEntity, IUser } from '@growi/core';
+import type {
+  HasObjectId,
+  IPageInfo, IPageInfoForEntity, IUser,
+} from '@growi/core';
 import type { ObjectId } from 'mongoose';
 
+import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
 import type { PageDocument } from '~/server/models/page';
 
 export interface IPageService {
-  create(path: string, body: string, user: IUser, options: IOptionsForCreate): Promise<PageDocument>,
+  create(path: string, body: string, user: HasObjectId, options: IOptionsForCreate): Promise<PageDocument>,
+  forceCreateBySystem(path: string, body: string, options: IOptionsForCreate): Promise<PageDocument>,
+  updatePage(pageData: PageDocument, body: string | null, previousBody: string | null, user: IUser, options: IOptionsForUpdate,): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
@@ -18,5 +23,11 @@ export interface IPageService {
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
   shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
-  canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
+  canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
+  canDeleteCompletely(
+    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]
+  ): boolean,
+  canDeleteCompletelyAsMultiGroupGrantedPage(
+    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
+  ): boolean,
 }

+ 8 - 4
apps/app/test/integration/service/page.test.js

@@ -769,8 +769,9 @@ describe('PageService', () => {
         });
 
         test('is not deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser1, false, userRelatedGroups);
           expect(isDeleteable).toBe(false);
         });
       });
@@ -789,8 +790,9 @@ describe('PageService', () => {
         });
 
         test('is not deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser3);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser3, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser3, false, userRelatedGroups);
           expect(isDeleteable).toBe(true);
         });
       });
@@ -809,8 +811,9 @@ describe('PageService', () => {
         });
 
         test('is deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser1, false, userRelatedGroups);
           expect(isDeleteable).toBe(true);
         });
       });
@@ -829,8 +832,9 @@ describe('PageService', () => {
         });
 
         test('is deletable', async() => {
+          const creatorId = await crowi.pageService.getCreatorIdForCanDelete(canDeleteCompletelyTestPage);
           const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser2);
-          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser2, false, userRelatedGroups);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, creatorId, testUser2, false, userRelatedGroups);
           expect(isDeleteable).toBe(true);
         });
       });

+ 59 - 2
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -1154,7 +1154,7 @@ describe('PageService page operations with non-public pages', () => {
   });
   describe('Duplicate', () => {
 
-    const duplicate = async(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources) => {
+    const duplicate = async(page, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean) => {
       // mock return value
       const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
       const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources);
@@ -1274,7 +1274,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
-    test('Should duplicate only user related resources when onlyDuplicateUserRelatedResources is true', async() => {
+    test('Should duplicate only user related pages and granted groups when onlyDuplicateUserRelatedResources is true', async() => {
       const _path1 = '/np_duplicate7';
       const _path2 = '/np_duplicate7/np_duplicate8';
       const _path3 = '/np_duplicate7/np_duplicate9';
@@ -1308,6 +1308,63 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedRevision1.body).toBe(_revision1.body);
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
     });
+    test('Should duplicate all pages and granted groups when onlyDuplicateUserRelatedResources is false', async() => {
+      const _path1 = '/np_duplicate7';
+      const _path2 = '/np_duplicate7/np_duplicate8';
+      const _path3 = '/np_duplicate7/np_duplicate9';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page3 = await Page.findOne({ path: _path3, parent: _page1._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _revision1 = _page1.revision;
+      const _revision2 = _page2.revision;
+      const _revision3 = _page3.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_revision1).toBeTruthy();
+      expect(_revision2).toBeTruthy();
+      expect(_revision3).toBeTruthy();
+
+      const newPagePath = '/dup2_np_duplicate7';
+      await duplicate(_page1, newPagePath, npDummyUser1, true, false);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup2_np_duplicate7/np_duplicate8' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage3 = await Page.findOne({ path: '/dup2_np_duplicate7/np_duplicate9' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      const duplicatedRevision2 = duplicatedPage2.revision;
+      const duplicatedRevision3 = duplicatedPage3.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeTruthy();
+      expect(duplicatedPage3).toBeTruthy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(duplicatedRevision2).toBeTruthy();
+      expect(duplicatedRevision3).toBeTruthy();
+      expect(normalizeGrantedGroups(duplicatedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+        { item: groupIdB, type: GroupType.userGroup },
+        { item: externalGroupIdB, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedRevision1.body).toBe(_revision1.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(normalizeGrantedGroups(duplicatedPage2.grantedGroups)).toStrictEqual([
+        { item: groupIdC, type: GroupType.userGroup },
+        { item: externalGroupIdC, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision2.body).toBe(_revision2.body);
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
+      expect(duplicatedPage3.grantedUsers).toStrictEqual([npDummyUser2._id]);
+      expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision3.body).toBe(_revision3.body);
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
+    });
 
   });
   describe('Delete', () => {

+ 10 - 3
packages/core/src/models/devided-page-path.js → packages/core/src/models/devided-page-path.ts

@@ -5,12 +5,19 @@ const PATTERN_INCLUDE_DATE = /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d
 
 export class DevidedPagePath {
 
-  constructor(path, skipNormalize = false, evalDatePath = false) {
+  isRoot: boolean;
+
+  isFormerRoot: boolean;
+
+  former: string;
+
+  latter: string;
+
+  constructor(path: string, skipNormalize = false, evalDatePath = false) {
 
     this.isRoot = false;
     this.isFormerRoot = false;
-    this.former = null;
-    this.latter = null;
+    this.former = '';
 
     // root
     if (path == null || path === '' || path === '/') {

+ 3 - 0
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -52,6 +52,9 @@ describe.concurrent('isCreatablePage test', () => {
   test('should decide creatable or not', () => {
     expect(isCreatablePage('/hoge')).toBeTruthy();
 
+    // starts with multiple slash
+    expect(isCreatablePage('//multiple-slash')).toBeFalsy();
+
     // edge cases
     expect(isCreatablePage('/me')).toBeFalsy();
     expect(isCreatablePage('/me/')).toBeFalsy();

+ 4 - 3
packages/core/src/utils/page-path-utils/index.ts

@@ -1,5 +1,7 @@
 import escapeStringRegexp from 'escape-string-regexp';
 
+import { IUser } from '~/interfaces';
+
 import { isValidObjectId } from '../objectid-utils';
 import { addTrailingSlash } from '../path-utils';
 
@@ -124,9 +126,8 @@ export const isCreatablePage = (path: string): boolean => {
  * return user's homepage path
  * @param user
  */
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const userHomepagePath = (user: any): string => {
-  if (!user || !user.username) {
+export const userHomepagePath = (user: IUser | null | undefined): string => {
+  if (user?.username == null) {
     return '';
   }
   return `/user/${user.username}`;

+ 8 - 0
packages/custom-icons/svg/recently_created.svg

@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21.569" viewBox="0 0 21 21.569">
+  <g id="8883" data-name="8883" transform="translate(-288.73 -162.502)">
+    <path id="14245" data-name="14245" d="M18.841,15.3a4.123,4.123,0,1,1-1.028-2.714H16.271v1.158h3.213V10.53H18.326v.929A5.261,5.261,0,1,0,20,15.3Z" transform="translate(289.23 163.002)" fill="#C4C2BD" stroke="rgba(0,0,0,0)" stroke-miterlimit="10" stroke-width="1"/>
+    <path id="14246" data-name="14246" d="M14.151,12.351v3.165l2.021,2.312.872-.762L15.31,15.081v-2.73Z" transform="translate(289.23 163.002)" fill="#C4C2BD" stroke="rgba(0,0,0,0)" stroke-miterlimit="10" stroke-width="1"/>
+    <path id="14247" data-name="14247" d="M16.933,4.241a1.645,1.645,0,0,0,.489-1.19,1.582,1.582,0,0,0-.469-1.19L15.6.5a1.631,1.631,0,0,0-2.36,0L12.075,1.667l3.706,3.726Z" transform="translate(289.23 163.002)" fill="#C4C2BD" stroke="rgba(0,0,0,0)" stroke-miterlimit="10" stroke-width="1"/>
+    <path id="14248" data-name="14248" d="M10.932,2.819,0,13.751v3.706H3.706L14.638,6.525ZM3,15.81H1.647V14.459l9.3-9.3.682.683.667.667Z" transform="translate(289.23 163.002)" fill="#C4C2BD" stroke="rgba(0,0,0,0)" stroke-miterlimit="10" stroke-width="1"/>
+  </g>
+</svg>

+ 3 - 0
packages/editor/package.json

@@ -27,6 +27,9 @@
     "@codemirror/state": "^6.2.1",
     "@codemirror/view": "^6.15.3",
     "@popperjs/core": "^2.11.8",
+    "@replit/codemirror-emacs": "^6.0.1",
+    "@replit/codemirror-vim": "6.0.14",
+    "@replit/codemirror-vscode-keymap": "^6.0.2",
     "@types/react": "^18.2.14",
     "@types/react-dom": "^18.2.6",
     "@uiw/codemirror-theme-eclipse": "^4.21.21",

+ 26 - 1
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -9,7 +9,7 @@ import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
 import {
-  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme,
+  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme, getKeymap, type KeyMapMode,
 } from '../../services';
 import {
   adjustPasteData, getStrFromBol,
@@ -31,10 +31,12 @@ type Props = {
   editorKey: string | GlobalCodeMirrorEditorKey,
   acceptedFileType: AcceptedUploadFileType,
   onChange?: (value: string) => void,
+  onSave?: () => void,
   onUpload?: (files: File[]) => void,
   onScroll?: () => void,
   indentSize?: number,
   editorTheme?: string,
+  editorKeymap?: string,
 }
 
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
@@ -42,10 +44,12 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     editorKey,
     acceptedFileType,
     onChange,
+    onSave,
     onUpload,
     onScroll,
     indentSize,
     editorTheme,
+    editorKeymap,
   } = props;
 
   const containerRef = useRef(null);
@@ -160,6 +164,27 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     return cleanupFunction;
   }, [codeMirrorEditor, themeExtension]);
 
+
+  const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(undefined);
+  useEffect(() => {
+    const settingKeyMap = async(name?: KeyMapMode) => {
+      setKeymapExtension(await getKeymap(name ?? 'default'));
+    };
+    settingKeyMap(editorKeymap as KeyMapMode);
+
+  }, [codeMirrorEditor, editorKeymap, setKeymapExtension]);
+
+  useEffect(() => {
+    if (keymapExtension == null) {
+      return;
+    }
+
+    // Prevent these Keybind from overwriting the originally defined keymap.
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.low(keymapExtension));
+    return cleanupFunction;
+
+  }, [codeMirrorEditor, keymapExtension, onSave]);
+
   const {
     getRootProps,
     isDragActive,

+ 4 - 1
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -28,11 +28,12 @@ type Props = {
   initialValue?: string,
   onOpenEditor?: (markdown: string) => void,
   editorTheme?: string,
+  editorKeymap?: string,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor, editorTheme,
+    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor, editorTheme, editorKeymap,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -76,11 +77,13 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       onChange={onChange}
+      onSave={onSave}
       onUpload={onUpload}
       onScroll={onScroll}
       acceptedFileType={acceptedFileTypeNoOpt}
       indentSize={indentSize}
       editorTheme={editorTheme}
+      editorKeymap={editorKeymap}
     />
   );
 };

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

@@ -15,6 +15,7 @@ export const Playground = (): JSX.Element => {
 
   const [markdownToPreview, setMarkdownToPreview] = useState('');
   const [editorTheme, setEditorTheme] = useState('');
+  const [editorKeymap, setEditorKeymap] = useState('');
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
@@ -63,11 +64,12 @@ export const Playground = (): JSX.Element => {
             indentSize={4}
             acceptedFileType={AcceptedUploadFileType.ALL}
             editorTheme={editorTheme}
+            editorKeymap={editorKeymap}
           />
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">
           <Preview markdown={markdownToPreview} />
-          <PlaygroundController setEditorTheme={setEditorTheme} />
+          <PlaygroundController setEditorTheme={setEditorTheme} setEditorKeymap={setEditorKeymap} />
         </div>
       </div>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '50px' }}>

+ 27 - 28
packages/editor/src/components/playground/PlaygroundController.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import { useForm } from 'react-hook-form';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
-import { AllEditorTheme } from '../../services';
+import { AllEditorTheme, AllKeyMap } from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 export const InitEditorValueRow = (): JSX.Element => {
@@ -69,38 +69,35 @@ export const SetCaretLineRow = (): JSX.Element => {
   );
 };
 
-type SetThemeRowProps = {
-  setEditorTheme: (value: string) => void,
+
+type SetParamRowProps = {
+    update: (value: string) => void,
+    items: string[],
 }
-const SetThemeRow = (props: SetThemeRowProps): JSX.Element => {
-
-  const { setEditorTheme } = props;
-
-  const createItems = (items: string[]): JSX.Element => {
-    return (
-      <div>
-        { items.map((theme) => {
-          return (
-            <button
-              type="button"
-              className="btn btn-outline-secondary"
-              onClick={() => {
-                setEditorTheme(theme);
-              }}
-            >{theme}
-            </button>
-          );
-        }) }
-      </div>
-    );
-  };
 
+const SetParamRow = (
+    props: SetParamRowProps,
+): JSX.Element => {
+  const { update, items } = props;
   return (
     <>
       <div className="row mt-3">
         <h2>default</h2>
         <div className="col">
-          {createItems(AllEditorTheme)}
+          <div>
+            { items.map((item) => {
+              return (
+                <button
+                  type="button"
+                  className="btn btn-outline-secondary"
+                  onClick={() => {
+                    update(item);
+                  }}
+                >{item}
+                </button>
+              );
+            }) }
+          </div>
         </div>
       </div>
     </>
@@ -110,15 +107,17 @@ const SetThemeRow = (props: SetThemeRowProps): JSX.Element => {
 
 type PlaygroundControllerProps = {
   setEditorTheme: (value: string) => void
+  setEditorKeymap: (value: string) => void
 };
 
 export const PlaygroundController = (props: PlaygroundControllerProps): JSX.Element => {
-  const { setEditorTheme } = props;
+  const { setEditorTheme, setEditorKeymap } = props;
   return (
     <div className="container mt-5">
       <InitEditorValueRow />
       <SetCaretLineRow />
-      <SetThemeRow setEditorTheme={setEditorTheme} />
+      <SetParamRow update={setEditorTheme} items={AllEditorTheme} />
+      <SetParamRow update={setEditorKeymap} items={AllKeyMap} />
     </div>
   );
 };

+ 4 - 4
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -1,8 +1,8 @@
 import { useMemo } from 'react';
 
-import { indentWithTab, defaultKeymap } from '@codemirror/commands';
+import { indentWithTab, defaultKeymap, deleteCharBackward } from '@codemirror/commands';
 import {
-  markdown, markdownLanguage, deleteMarkupBackward,
+  markdown, markdownLanguage,
 } from '@codemirror/lang-markdown';
 import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language';
 import { languages } from '@codemirror/language-data';
@@ -32,11 +32,11 @@ 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';
 
+
 // set new markdownKeymap instead of default one
-// I also bound the deleteMarkupBackward to the backspace key to align with the existing keymap
 // https://github.com/codemirror/lang-markdown/blob/main/src/index.ts#L17
 const markdownKeymap = [
-  { key: 'Backspace', run: deleteMarkupBackward },
+  { key: 'Backspace', run: deleteCharBackward },
   { key: 'Enter', run: insertNewlineContinueMarkup },
 ];
 

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

@@ -1,3 +1,4 @@
 export * from './codemirror-editor';
 export * from './file-dropzone';
 export * from './editor-theme';
+export * from './keymaps';

+ 26 - 0
packages/editor/src/services/keymaps/index.ts

@@ -0,0 +1,26 @@
+import { Extension } from '@codemirror/state';
+import { keymap } from '@codemirror/view';
+
+
+export const getKeymap = async(keyMapName: KeyMapMode, onSave?: () => void): Promise<Extension> => {
+  switch (keyMapName) {
+    case 'vim':
+      return (await import('./vim')).vimKeymap(onSave);
+    case 'emacs':
+      return (await import('@replit/codemirror-emacs')).emacs();
+    case 'vscode':
+      return keymap.of((await import('@replit/codemirror-vscode-keymap')).vscodeKeymap);
+    case 'default':
+      return keymap.of((await import('@codemirror/commands')).defaultKeymap);
+  }
+};
+
+const KeyMapMode = {
+  default: 'default',
+  vim: 'vim',
+  emacs: 'emacs',
+  vscode: 'vscode',
+} as const;
+
+export const AllKeyMap = Object.values(KeyMapMode);
+export type KeyMapMode = typeof KeyMapMode[keyof typeof KeyMapMode];

+ 13 - 0
packages/editor/src/services/keymaps/vim.ts

@@ -0,0 +1,13 @@
+import { Extension } from '@codemirror/state';
+import { Vim, vim } from '@replit/codemirror-vim';
+
+// vim useful keymap custom
+Vim.map('jj', '<Esc>', 'insert');
+Vim.map('jk', '<Esc>', 'insert');
+
+export const vimKeymap = (onSave?: () => void): Extension => {
+  if (onSave != null) {
+    Vim.defineEx('write', 'w', onSave);
+  }
+  return vim();
+};

+ 6 - 6
packages/preset-themes/src/styles/default.scss

@@ -9,7 +9,7 @@
   @include generate-color-palette('primary', $primary, #000010, white, 22%, 22%);
   @include generate-color-palette('highlight', $highlight);
 
-  $body-color:                #223246;
+  $body-color:                $gray-800;
   $body-bg:                   white;
 
   $body-secondary-color:      rgba($body-color, .75);
@@ -29,8 +29,8 @@
 
   @import '@growi/core/scss/bootstrap/theming/apply-light';
 
-  --grw-wiki-link-color-rgb: var(--grw-highlight-800-rgb);
-  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-900-rgb);
+  --grw-wiki-link-color-rgb: var(--grw-highlight-700-rgb);
+  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-600-rgb);
   --grw-sidebar-nav-btn-color: var(--grw-highlight-600);
 }
 
@@ -43,7 +43,7 @@
   $highlight: #c4c2bd;
 
   @include generate-color-palette('primary', $primary, #000010, white, 22%, 22%);
-  @include generate-color-palette('highlight', $highlight, black, white);
+  @include generate-color-palette('highlight', $highlight, black, white, 22%, 22%);
 
   $body-color-dark:                   $gray-300;
   $body-bg-dark:                      #1c1a1a;
@@ -65,8 +65,8 @@
 
   @import '@growi/core/scss/bootstrap/theming/apply-dark';
 
-  --grw-wiki-link-color-rgb: var(--grw-highlight-500-rgb);
-  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-300-rgb);
+  --grw-wiki-link-color-rgb: var(--grw-highlight-600-rgb);
+  --grw-wiki-link-hover-color-rgb: var(--grw-highlight-400-rgb);
   --grw-sidebar-nav-btn-color: rgba(var(--grw-highlight-400-rgb), 0.8);
 }
 

+ 7 - 7
packages/ui/src/components/Attachment.tsx

@@ -37,8 +37,8 @@ export const Attachment = (props: AttachmentProps): JSX.Element => {
       </a>
     )
     : '';
-  const fileType = <span className="attachment-filetype badge badge-pill badge-secondary">{attachment.fileFormat}</span>;
-  const fileInUse = (inUse) ? <span className="attachment-in-use badge badge-pill badge-info">In Use</span> : '';
+  const fileType = <span className="attachment-filetype badge bg-secondary rounded-pill">{attachment.fileFormat}</span>;
+  const fileInUse = (inUse) ? <span className="attachment-in-use badge bg-info rounded-pill">In Use</span> : '';
   // Should UserDate be used like PageRevisionTable ?
   const formatType = 'yyyy/MM/dd HH:mm:ss';
   const createdAt = format(new Date(attachment.createdAt), formatType);
@@ -51,11 +51,11 @@ export const Attachment = (props: AttachmentProps): JSX.Element => {
       <a className="me-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
         <i className={formatIcon}></i> {attachment.originalName}
       </a>
-      <span className="mr-2">{fileType}</span>
-      <span className="mr-2">{createdAt}</span>
-      <span className="mr-2">{fileInUse}</span>
-      <span className="mr-2">{btnDownload}</span>
-      <span className="mr-2">{btnTrash}</span>
+      <span className="me-2">{fileType}</span>
+      <span className="me-2">{createdAt}</span>
+      <span className="me-2">{fileInUse}</span>
+      <span className="me-2">{btnDownload}</span>
+      <span className="me-2">{btnTrash}</span>
     </div>
   );
 };

+ 22 - 0
yarn.lock

@@ -2858,6 +2858,21 @@
   resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz#a3031eb54129f2c66b2753f8404266ec7bf67f0a"
   integrity sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==
 
+"@replit/codemirror-emacs@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@replit/codemirror-emacs/-/codemirror-emacs-6.0.1.tgz#6e74453e456f40cbb18ed1d15030fa0dbd218098"
+  integrity sha512-2WYkODZGH1QVAXWuOxTMCwktkoZyv/BjYdJi2A5w4fRrmOQFuIACzb6pO9dgU3J+Pm2naeiX2C8veZr/3/r6AA==
+
+"@replit/codemirror-vim@6.0.14":
+  version "6.0.14"
+  resolved "https://registry.yarnpkg.com/@replit/codemirror-vim/-/codemirror-vim-6.0.14.tgz#8f44740b0497406b551726946c9b30f21c867671"
+  integrity sha512-wwhqhvL76FdRTdwfUWpKCbv0hkp2fvivfMosDVlL/popqOiNLtUhL02ThgHZH8mus/NkVr5Mj582lyFZqQrjOA==
+
+"@replit/codemirror-vscode-keymap@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@replit/codemirror-vscode-keymap/-/codemirror-vscode-keymap-6.0.2.tgz#cc9b9092db5afb9800fda5a03801b4f6600b427e"
+  integrity sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==
+
 "@restart/hooks@^0.3.26":
   version "0.3.27"
   resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.27.tgz#91f356d66d4699a8cd8b3d008402708b6a9dc505"
@@ -14784,6 +14799,13 @@ react-image-crop@^8.3.0:
     core-js "^3.2.1"
     prop-types "^15.7.2"
 
+react-input-autosize@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-3.0.0.tgz#6b5898c790d4478d69420b55441fcc31d5c50a85"
+  integrity sha512-nL9uS7jEs/zu8sqwFE5MAPx6pPkNAriACQ2rGLlqmKr2sPGtN7TXTyDdQt4lbNXVx7Uzadb40x8qotIuru6Rhg==
+  dependencies:
+    prop-types "^15.5.8"
+
 react-is@^16.13.1, react-is@^16.7.0:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"