Просмотр исходного кода

Merge branch 'master' into support/apply-nextjs-2

Yuki Takei 3 лет назад
Родитель
Сommit
6f1c6875fb
57 измененных файлов с 979 добавлено и 569 удалено
  1. 3 0
      .github/workflows/reusable-app-prod.yml
  2. 4 1
      .vscode/settings.json
  3. 21 1
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 2 2
      package.json
  6. 1 0
      packages/app/_obsolete/config/webpack.common.js
  7. 1 0
      packages/app/config/ci/.env.local.for-ci
  8. 2 2
      packages/app/docker/README.md
  9. 10 10
      packages/app/package.json
  10. 7 5
      packages/app/public/static/locales/en_US/admin.json
  11. 2 0
      packages/app/public/static/locales/en_US/translation.json
  12. 6 4
      packages/app/public/static/locales/ja_JP/admin.json
  13. 2 0
      packages/app/public/static/locales/ja_JP/translation.json
  14. 7 4
      packages/app/public/static/locales/zh_CN/admin.json
  15. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  16. 1 0
      packages/app/regconfig.json
  17. 23 0
      packages/app/src/client/services/page-operation.ts
  18. 18 2
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  19. 28 20
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  20. 8 11
      packages/app/src/components/Admin/AuditLogManagement.tsx
  21. 20 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  22. 3 0
      packages/app/src/components/Layout/Admin.module.scss
  23. 70 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  24. 70 0
      packages/app/src/components/Page/RevisionRenderer.tsx
  25. 0 406
      packages/app/src/components/PageEditor/Editor.jsx
  26. 362 0
      packages/app/src/components/PageEditor/Editor.tsx
  27. 1 1
      packages/app/src/components/PageList/PageListItemL.tsx
  28. 2 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  29. 0 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  30. 2 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  31. 17 0
      packages/app/src/interfaces/editor-methods.ts
  32. 15 4
      packages/app/src/interfaces/page-operation.ts
  33. 4 1
      packages/app/src/server/models/obsolete-page.js
  34. 1 16
      packages/app/src/server/models/page-operation.ts
  35. 1 0
      packages/app/src/server/models/page.ts
  36. 27 2
      packages/app/src/server/routes/apiv3/page.js
  37. 7 1
      packages/app/src/server/routes/apiv3/pages.js
  38. 15 5
      packages/app/src/server/service/page-operation.ts
  39. 95 10
      packages/app/src/server/service/page.ts
  40. 3 0
      packages/app/src/server/util/rate-limiter.ts
  41. 9 1
      packages/app/src/server/views/layout/layout.html
  42. 3 1
      packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts
  43. 5 0
      packages/app/src/utils/page-operation.ts
  44. 3 2
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  45. 3 0
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  46. 1 1
      packages/app/test/integration/service/v5.page.test.ts
  47. 1 1
      packages/app/test/integration/service/v5.public-page.test.ts
  48. 1 1
      packages/codemirror-textlint/package.json
  49. 1 1
      packages/core/package.json
  50. 1 0
      packages/core/src/interfaces/page.ts
  51. 1 1
      packages/plugin-attachment-refs/package.json
  52. 1 1
      packages/plugin-lsx/package.json
  53. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  54. 1 1
      packages/slack/package.json
  55. 2 2
      packages/slackbot-proxy/package.json
  56. 2 2
      packages/ui/package.json
  57. 79 24
      yarn.lock

+ 3 - 0
.github/workflows/reusable-app-prod.yml

@@ -210,6 +210,9 @@ jobs:
     steps:
     - uses: actions/checkout@v3
 
+    - name: Install fonts
+      run: sudo apt install fonts-noto
+
     - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}

+ 4 - 1
.vscode/settings.json

@@ -21,5 +21,8 @@
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": true,
     "source.fixAll.markdownlint": true
-  }
+  },
+  "githubPullRequests.ignoredPullRequestBranches": [
+    "master"
+  ]
 }

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.1...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.1](https://github.com/weseek/growi/compare/v5.1.0...v5.1.1) - 2022-08-01
+
+### 💎 Features
+
+- feat: Users can set users per ip from env var at API Rate Limit  (#6379) @yukendev
+- feat: Show user picture in Audit Log (#6342) @miya
+- feat: Reset search criteria button (#6327) @miya
+
+### 🚀 Improvement
+
+- imprv(auditlog): Display number of actions that can be saved (#6353) @miya
+- imprv(auditlog): Include delete-related actions in small group (#6351) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Default markdown linker with relative path does not respect the current page path (v5.1.0) (#6378) @yuki-takei
+- fix: Recover page path operation (#6368) @hakumizuki
+- fix: Migration script for inserting NamedQuery (#6364) @yuki-takei
+- fix: "Error: cannnot get grant label" occured with lsx (#6348) @yukendev
+
 ## [v5.1.0](https://github.com/weseek/growi/compare/v5.0.11...v5.1.0) - 2022-07-21
 
 ### 💎 Features

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "packages": [
     "packages/*"
   ]

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -76,7 +76,7 @@
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
-    "reg-suit": "^0.11.1",
+    "reg-suit": "^0.12.1",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",

+ 1 - 0
packages/app/_obsolete/config/webpack.common.js

@@ -77,6 +77,7 @@ module.exports = (options) => {
     },
     node: {
       fs: 'empty',
+      module: 'empty',
     },
     module: {
       rules: options.module.rules.concat([

+ 1 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1 +1,2 @@
 FORMAT_NODE_LOG=true
+MATHJAX=1

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.0`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
-* [`5.1.0-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
+* [`5.1.1`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.1/docker/Dockerfile)
+* [`5.1.1-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.1/docker/Dockerfile)
 * [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 10 - 10
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -57,17 +57,17 @@
   "dependencies": {
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
-    "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.1-RC.0",
-    "@growi/core": "^5.1.1-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.1-RC.0",
-    "@growi/plugin-lsx": "^5.1.1-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.1-RC.0",
-    "@growi/slack": "^5.1.1-RC.0",
+    "@growi/codemirror-textlint": "^5.1.2-RC.0",
+    "@growi/core": "^5.1.2-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.2-RC.0",
+    "@growi/plugin-lsx": "^5.1.2-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.2-RC.0",
+    "@growi/slack": "^5.1.2-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -81,7 +81,7 @@
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
-    "browser-bunyan": "^1.6.3",
+    "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
@@ -184,7 +184,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.1-RC.0",
+    "@growi/ui": "^5.1.2-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

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

@@ -422,7 +422,7 @@
     }
   },
   "slack_integration_legacy": {
-    "alert_disabled": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
+    "alert_Pd": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
   },
   "user_management": {
@@ -537,15 +537,17 @@
     "url": "URL",
     "settings": "Settings",
     "return": "Return",
-    "clear": "Clear search criteria",
-    "reload": "Reload",
+    "clear": "Clear",
     "activity_expiration_date": "Audit Log expiration date",
     "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
     "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "available_action_list": "Search / View All Available Actions",
-    "available_action_list_explain": "List of actions that can be search / view in the Audit Log",
+    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
-    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true."
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
   },
   "audit_log_action_category": {
     "Page": "Page",

+ 2 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -168,6 +168,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
+  "wide_view": "Wide View",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
@@ -545,6 +546,7 @@
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",

+ 6 - 4
packages/app/public/static/locales/ja_JP/admin.json

@@ -536,15 +536,17 @@
     "url": "URL",
     "settings": "設定",
     "return": "戻る",
-    "clear": "検索条件のクリア",
-    "reload": "再読み込み",
+    "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
   },
   "audit_log_action_category": {
     "Page": "ページ",

+ 2 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -170,6 +170,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
+  "wide_view": "ワイドビュー",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
@@ -545,6 +546,7 @@
     "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_failed": "{{target}}の更新に失敗しました",
+    "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

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

@@ -546,15 +546,18 @@
     "url": "URL",
     "settings": "设置",
     "return": "返回",
-    "clear": "清除搜索标准",
-    "reload": "重新加载",
+    "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
     "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "可以在审计日志中 搜索/查看 的行动列表",
+    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+
   },
   "audit_log_action_category": {
     "Page": "页面",

+ 2 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -176,6 +176,7 @@
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
+  "wide_view": "视野开阔",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
@@ -523,6 +524,7 @@
     "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",

+ 1 - 0
packages/app/regconfig.json

@@ -13,6 +13,7 @@
     "reg-notify-github-plugin": {
       "prCommentBehavior": "new",
       "setCommitStatus": false,
+      "shortDescription": true,
       "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
     },
     "reg-notify-slack-plugin": {

+ 23 - 0
packages/app/src/client/services/page-operation.ts

@@ -36,6 +36,29 @@ export const toggleBookmark = async(pageId: string, currentValue?: boolean): Pro
   }
 };
 
+// Utility to update body class
+const updateBodyClassByView = (expandContentWidth: boolean): void => {
+  const bodyClasses = document.body.classList;
+  const isLayoutFluid = bodyClasses.contains('growi-layout-fluid');
+
+  if (expandContentWidth && !isLayoutFluid) {
+    bodyClasses.add('growi-layout-fluid');
+  }
+  else if (isLayoutFluid) {
+    bodyClasses.remove('growi-layout-fluid');
+  }
+};
+
+export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
+  try {
+    await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
+    updateBodyClassByView(newValue);
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
 export const bookmark = async(pageId: string): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });

+ 18 - 2
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -3,6 +3,7 @@ import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 
+import { AllSupportedActions } from '~/interfaces/activity';
 import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
 
 export const AuditLogSettings: FC = () => {
@@ -34,8 +35,23 @@ export const AuditLogSettings: FC = () => {
         />
       </p>
 
-      <h4 className="mt-4">{t('admin:audit_log_management.available_action_list')}</h4>
-      <p className="form-text text-muted">{t('admin:audit_log_management.available_action_list_explain')}</p>
+      <h4 className="mt-4">
+        {t('admin:audit_log_management.available_action_list')}
+        <span className="badge badge-pill badge-secondary ml-2">
+          {`${availableActions.length} / ${AllSupportedActions.length}`}
+        </span>
+        <a
+          className="ml-2"
+          href={t('admin:audit_log_management.docs_url.log_type')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <i className="icon-fw icon-question" aria-hidden="true"></i>
+        </a>
+      </h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.available_action_list_explain')}
+      </p>
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
           <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>

+ 28 - 20
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,29 +1,39 @@
-import React, {
-  FC, useRef, forwardRef, useCallback,
-} from 'react';
+import React, { FC, forwardRef, useCallback } from 'react';
 
+import { addDays, format } from 'date-fns';
 import DatePicker from 'react-datepicker';
 import 'react-datepicker/dist/react-datepicker.css';
 
-import { useTranslation } from 'react-i18next';
-
 
 type CustomInputProps = {
-  buttonRef: React.Ref<HTMLButtonElement>
-  onClick?: () => void;
+  value?: string
+  onChange?: () => void
+  onFocus?: () => void
 }
 
-const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
-  const { t } = useTranslation();
+const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: CustomInputProps, ref) => {
+  const dateFormat = 'MM/dd/yyyy';
+  const date = new Date();
+  const placeholder = `${format(date, dateFormat)} - ${format(addDays(date, 1), dateFormat)}`;
+
   return (
-    <button
-      type="button"
-      className="btn btn-outline-secondary dropdown-toggle"
-      ref={props.buttonRef}
-      onClick={props.onClick}
-    >
-      <i className="fa fa-fw fa-calendar" /> {t('admin:audit_log_management.date')}
-    </button>
+    <div className="input-group admin-audit-log">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="fa fa-fw fa-calendar" />
+        </span>
+      </div>
+      <input
+        ref={ref}
+        type="text"
+        value={props?.value}
+        onFocus={props?.onFocus}
+        onChange={props?.onChange}
+        placeholder={placeholder}
+        className="form-control date-range-picker"
+        aria-describedby="basic-addon1"
+      />
+    </div>
   );
 });
 
@@ -38,8 +48,6 @@ type DateRangePickerProps = {
 export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
   const { startDate, endDate, onChange } = props;
 
-  const buttonRef = useRef(null);
-
   const changeHandler = useCallback((dateList: Date[] | null[]) => {
     if (onChange != null) {
       const [start, end] = dateList;
@@ -60,7 +68,7 @@ export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePicker
         startDate={startDate}
         endDate={endDate}
         onChange={changeHandler}
-        customInput={<CustomInput buttonRef={buttonRef} />}
+        customInput={<CustomInput />}
       />
     </div>
   );

+ 8 - 11
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -135,6 +135,11 @@ export const AuditLogManagement: FC = () => {
         <span>
           {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
         </span>
+        { !isSettingPage && (
+          <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+            <i className="icon icon-reload"></i>
+          </button>
+        )}
       </h2>
 
       {isSettingPage ? (
@@ -160,17 +165,9 @@ export const AuditLogManagement: FC = () => {
               onChangeMultipleAction={multipleActionCheckboxChangedHandler}
             />
 
-            <div className="ml-auto">
-              <button type="button" className="btn btn-outline-secondary btn-sm mr-2" onClick={clearButtonPushedHandler}>
-                <span className="icon-refresh mr-1" />
-                {t('admin:audit_log_management.clear')}
-              </button>
-
-              <button type="button" className="btn btn-outline-secondary btn-sm" onClick={reloadButtonPushedHandler}>
-                <i className="icon icon-reload mr-1" />
-                {t('admin:audit_log_management.reload')}
-              </button>
-            </div>
+            <button type="button" className="btn btn-link" onClick={clearButtonPushedHandler}>
+              {t('admin:audit_log_management.clear')}
+            </button>
           </div>
 
           <p

+ 20 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -11,6 +11,7 @@ import {
 import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
@@ -22,6 +23,7 @@ export const MenuItemType = {
   DELETE: 'delete',
   REVERT: 'revert',
   PATH_RECOVERY: 'pathRecovery',
+  SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -41,6 +43,7 @@ type CommonProps = {
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
+  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
   alignRight?: boolean,
@@ -57,13 +60,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
+    pageId, isLoading, pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickRevertMenuItem, onClickPathRecoveryMenuItem,
+    additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
+    additionalMenuItemRenderer: AdditionalMenuItems,
+    isInstantRename, alignRight,
   } = props;
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
     if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
@@ -136,7 +140,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
     // PathRecovery
     // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+    const shouldShowPathRecoveryButton = operationProcessData != null ? shouldRecoverPagePaths(operationProcessData) : false;
 
     contents = (
       <>
@@ -148,8 +152,15 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        { AdditionalMenuItemsOnTop && (
+          <>
+            <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
+            <DropdownItem divider />
+          </>
+        ) }
+
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -253,9 +264,8 @@ type PageItemControlSubstanceProps = CommonProps & {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
   const {
-    pageId, pageInfo: presetPageInfo, fetchOnInit,
-    children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId, pageInfo: presetPageInfo, fetchOnInit, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
+    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);

+ 3 - 0
packages/app/src/components/Layout/Admin.module.scss

@@ -227,6 +227,9 @@ $slack-work-space-name-card-border: #efc1f6;
       max-height: 500px;
       overflow-y: auto;
     }
+    .date-range-picker {
+      width: 188px;
+    }
   }
 
   #layoutOptions {

+ 70 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,10 +1,14 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
-import dynamic from 'next/dynamic';
+import { useTranslation } from 'next-i18next';
+import { DropdownItem } from 'reactstrap';
 
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import {
-  IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+  toggleBookmark, toggleLike, toggleSubscribe, updateContentWidth,
+} from '~/client/services/page-operation';
+import { toastError } from '~/client/util/apiNotification';
+import {
+  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 import { useIsGuestUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -22,6 +26,43 @@ import SubscribeButton from '../SubscribeButton';
 import SeenUserInfo from '../User/SeenUserInfo';
 
 
+type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
+  onClickMenuItem: (newValue: boolean) => void,
+}
+
+const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    pageInfo, onClickMenuItem,
+  } = props;
+
+  if (!isIPageInfoForEntity(pageInfo)) {
+    return <></>;
+  }
+
+  return (
+    <DropdownItem
+      onClick={() => onClickMenuItem(!pageInfo.expandContentWidth)}
+      className="grw-page-control-dropdown-item"
+    >
+      <div className="custom-control custom-switch ml-1">
+        <input
+          id="switchContentWidth"
+          className="custom-control-input"
+          type="checkbox"
+          checked={pageInfo.expandContentWidth}
+          onChange={() => {}}
+        />
+        <label className="custom-control-label" htmlFor="switchContentWidth">
+          { t('wide_view') }
+        </label>
+      </div>
+    </DropdownItem>
+  );
+};
+
+
 type CommonProps = {
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
@@ -38,7 +79,7 @@ type SubNavButtonsSubstanceProps = CommonProps & {
   shareLinkId?: string | null,
   revisionId: string | null,
   path?: string | null,
-  pageInfo: IPageInfoAll,
+  pageInfo: IPageInfoForOperation,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
@@ -143,11 +184,33 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
+  const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return;
+    }
+    try {
+      await updateContentWidth(pageId, newValue);
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const wideviewMenuItemRenderer = useMemo(() => {
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return undefined;
+    }
+    return props => <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} />;
+  }, [pageInfo, switchContentWidthClickHandler]);
+
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
   }
 
-
   const {
     sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
@@ -195,6 +258,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          additionalMenuItemOnTopRenderer={wideviewMenuItemRenderer}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 70 - 0
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -122,6 +122,76 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   // }, [editorSettings?.renderDrawioInRealtime, markdown, pagePath]);
 
 
+  // const renderHtml = useCallback(async() => {
+  //   if (interceptorManager == null) {
+  //     return;
+  //   }
+
+  //   const context = currentRenderingContext;
+
+  //   await interceptorManager.process('preRender', context);
+  //   await interceptorManager.process('prePreProcess', context);
+  //   context.markdown = growiRenderer.preProcess(context.markdown, context);
+  //   await interceptorManager.process('postPreProcess', context);
+  //   context.parsedHTML = growiRenderer.process(context.markdown, context);
+  //   await interceptorManager.process('prePostProcess', context);
+  //   context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+
+  //   const isMarkdownEmpty = context.markdown.trim().length === 0;
+  //   if (highlightKeywords != null && !isMarkdownEmpty) {
+  //     context.parsedHTML = getHighlightedBody(context.parsedHTML, highlightKeywords);
+  //   }
+  //   await interceptorManager.process('postPostProcess', context);
+  //   await interceptorManager.process('preRenderHtml', context);
+
+  //   setHtml(context.parsedHTML);
+  // }, [currentRenderingContext, growiRenderer, highlightKeywords, interceptorManager]);
+
+  // useEffect(() => {
+  //   if (interceptorManager == null) {
+  //     return;
+  //   }
+
+  //   renderHtml()
+  //     .then(() => {
+  //       // const HeaderLink = document.getElementsByClassName('revision-head-link');
+  //       // const HeaderLinkArray = Array.from(HeaderLink);
+  //       // addSmoothScrollEvent(HeaderLinkArray as HTMLAnchorElement[], blinkElem);
+
+  //       // interceptorManager.process('postRenderHtml', currentRenderingContext);
+  //     });
+
+  // }, [currentRenderingContext, interceptorManager, renderHtml]);
+
+  // const config = props.appContainer.getConfig();
+  // const isMathJaxEnabled = !!config.env.MATHJAX;
+
+  // return (
+  //   <RevisionBody
+  //     html={html}
+  //     isMathJaxEnabled={isMathJaxEnabled}
+  //     additionalClassName={props.additionalClassName}
+  //     renderMathJaxOnInit
+  //   />
+  // );
+
+  // const [html, setHtml] = useState('');
+
+  // const { data: interceptorManager } = useInterceptorManager();
+  // const { data: editorSettings } = useEditorSettings();
+  // const { data: currentPathname } = useCurrentPathname();
+
+  // const currentRenderingContext = useMemo(() => {
+  //   return {
+  //     markdown,
+  //     parsedHTML: '',
+  //     pagePath,
+  //     renderDrawioInRealtime: editorSettings?.renderDrawioInRealtime,
+  //     currentPathname: decodeURIComponent(currentPathname ?? '/'),
+  //   };
+  // }, [editorSettings?.renderDrawioInRealtime, markdown, pagePath]);
+
+
   // const renderHtml = useCallback(async() => {
   //   if (interceptorManager == null) {
   //     return;

+ 0 - 406
packages/app/src/components/PageEditor/Editor.jsx

@@ -1,406 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import Dropzone from 'react-dropzone';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import { useDefaultIndentSize } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
-
-import AbstractEditor from './AbstractEditor';
-import Cheatsheet from './Cheatsheet';
-import CodeMirrorEditor from './CodeMirrorEditor';
-import pasteHelper from './PasteHelper';
-import TextAreaEditor from './TextAreaEditor';
-
-
-class Editor extends AbstractEditor {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isComponentDidMount: false,
-      dropzoneActive: false,
-      isUploading: false,
-      isCheatsheetModalShown: false,
-    };
-
-    this.getEditorSubstance = this.getEditorSubstance.bind(this);
-
-    this.pasteFilesHandler = this.pasteFilesHandler.bind(this);
-
-    this.dragEnterHandler = this.dragEnterHandler.bind(this);
-    this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
-    this.dropHandler = this.dropHandler.bind(this);
-
-    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
-    this.addAttachmentHandler = this.addAttachmentHandler.bind(this);
-
-    this.getAcceptableType = this.getAcceptableType.bind(this);
-    this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
-    this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
-  }
-
-  componentDidMount() {
-    this.setState({ isComponentDidMount: true });
-  }
-
-  getEditorSubstance() {
-    return this.props.isMobile
-      ? this.taEditor
-      : this.cmEditor;
-  }
-
-  /**
-   * @inheritDoc
-   */
-  forceToFocus() {
-    this.getEditorSubstance().forceToFocus();
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setValue(newValue) {
-    this.getEditorSubstance().setValue(newValue);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setGfmMode(bool) {
-    this.getEditorSubstance().setGfmMode(bool);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setCaretLine(line) {
-    this.getEditorSubstance().setCaretLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setScrollTopByLine(line) {
-    this.getEditorSubstance().setScrollTopByLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  insertText(text) {
-    this.getEditorSubstance().insertText(text);
-  }
-
-  /**
-   * remove overlay and set isUploading to false
-   */
-  terminateUploadingState() {
-    this.setState({
-      dropzoneActive: false,
-      isUploading: false,
-    });
-  }
-
-  /**
-   * dispatch onUpload event
-   */
-  dispatchUpload(files) {
-    if (this.props.onUpload != null) {
-      this.props.onUpload(files);
-    }
-  }
-
-  /**
-   * get acceptable(uploadable) file type
-   */
-  getAcceptableType() {
-    let accept = 'null'; // reject all
-    if (this.props.isUploadable) {
-      if (!this.props.isUploadableFile) {
-        accept = 'image/*'; // image only
-      }
-      else {
-        accept = ''; // allow all
-      }
-    }
-
-    return accept;
-  }
-
-  pasteFilesHandler(event) {
-    const items = event.clipboardData.items || event.clipboardData.files || [];
-
-    // abort if length is not 1
-    if (items.length < 1) {
-      return;
-    }
-
-    for (let i = 0; i < items.length; i++) {
-      try {
-        const file = items[i].getAsFile();
-        // check file type (the same process as Dropzone)
-        if (file != null && pasteHelper.isAcceptableType(file, this.getAcceptableType())) {
-          this.dispatchUpload(file);
-          this.setState({ isUploading: true });
-        }
-      }
-      catch (e) {
-        this.logger.error(e);
-      }
-    }
-  }
-
-  dragEnterHandler(event) {
-    const dataTransfer = event.dataTransfer;
-
-    // do nothing if contents is not files
-    if (!dataTransfer.types.includes('Files')) {
-      return;
-    }
-
-    this.setState({ dropzoneActive: true });
-  }
-
-  dragLeaveHandler() {
-    this.setState({ dropzoneActive: false });
-  }
-
-  dropHandler(accepted, rejected) {
-    // rejected
-    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
-      this.setState({ dropzoneActive: false });
-      return;
-    }
-
-    const file = accepted[0];
-    this.dispatchUpload(file);
-    this.setState({ isUploading: true });
-  }
-
-  showMarkdownHelp() {
-    this.setState({ isCheatsheetModalShown: true });
-  }
-
-  addAttachmentHandler() {
-    this.dropzone.open();
-  }
-
-  getDropzoneClassName(isDragAccept, isDragReject) {
-    let className = 'dropzone';
-    if (!this.props.isUploadable) {
-      className += ' dropzone-unuploadable';
-    }
-    else {
-      className += ' dropzone-uploadable';
-
-      if (this.props.isUploadableFile) {
-        className += ' dropzone-uploadablefile';
-      }
-    }
-
-    // uploading
-    if (this.state.isUploading) {
-      className += ' dropzone-uploading';
-    }
-
-    if (isDragAccept) {
-      className += ' dropzone-accepted';
-    }
-
-    if (isDragReject) {
-      className += ' dropzone-rejected';
-    }
-
-    return className;
-  }
-
-  renderDropzoneOverlay() {
-    return (
-      <div className="overlay overlay-dropzone-active">
-        {this.state.isUploading
-          && (
-            <span className="overlay-content">
-              <div className="speeding-wheel d-inline-block"></div>
-              <span className="sr-only">Uploading...</span>
-            </span>
-          )
-        }
-        {!this.state.isUploading && <span className="overlay-content"></span>}
-      </div>
-    );
-  }
-
-  renderNavbar() {
-    return (
-      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
-        <ul className="pl-2 nav nav-navbar">
-          { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
-            // eslint-disable-next-line react/no-array-index-key
-            return <li key={`navbarItem-${idx}`}>{item}</li>;
-          }) }
-        </ul>
-      </div>
-    );
-  }
-
-  getNavbarItems() {
-    // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
-    const navbarItems = [];
-
-    // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
-    return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
-  }
-
-  renderCheatsheetModal() {
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
-
-    return (
-      <Modal isOpen={this.state.isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
-        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
-          <i className="icon-fw icon-question" />Markdown help
-        </ModalHeader>
-        <ModalBody>
-          <Cheatsheet />
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-
-  render() {
-    const flexContainer = {
-      height: '100%',
-      display: 'flex',
-      flexDirection: 'column',
-    };
-
-    const {
-      isMobile,
-      indentSize,
-    } = this.props;
-
-    return (
-      <>
-        <div style={flexContainer} className="editor-container">
-          <Dropzone
-            ref={(c) => { this.dropzone = c }}
-            accept={this.getAcceptableType()}
-            noClick
-            noKeyboard
-            multiple={false}
-            onDragLeave={this.dragLeaveHandler}
-            onDrop={this.dropHandler}
-          >
-            {({
-              getRootProps,
-              getInputProps,
-              isDragAccept,
-              isDragReject,
-            }) => {
-              return (
-                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                  { this.state.isComponentDidMount && this.renderNavbar() }
-
-                  {/* for PC */}
-                  { !isMobile && (
-                    // eslint-disable-next-line arrow-body-style
-                    <CodeMirrorEditor
-                      ref={(c) => { this.cmEditor = c }}
-                      indentSize={indentSize}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                      onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  {/* for mobile */}
-                  { isMobile && (
-                    <TextAreaEditor
-                      ref={(c) => { this.taEditor = c }}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  <input {...getInputProps()} />
-                </div>
-              );
-            }}
-          </Dropzone>
-
-          { this.props.isUploadable
-            && (
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-block btn-open-dropzone"
-                onClick={this.addAttachmentHandler}
-              >
-                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-                Attach files
-                <span className="d-none d-sm-inline">
-                &nbsp;by dragging &amp; dropping,&nbsp;
-                  <span className="btn-link">selecting them</span>,&nbsp;
-                  or pasting from the clipboard.
-                </span>
-
-              </button>
-            )
-          }
-
-          { this.renderCheatsheetModal() }
-
-        </div>
-      </>
-    );
-  }
-
-}
-
-Editor.propTypes = Object.assign({
-  noCdn: PropTypes.bool,
-  // this value is markdown
-  value: PropTypes.string,
-  isMobile: PropTypes.bool,
-  isUploadable: PropTypes.bool,
-  isUploadableFile: PropTypes.bool,
-  onChange: PropTypes.func,
-  onUpload: PropTypes.func,
-  editorSettings: PropTypes.object.isRequired,
-  indentSize: PropTypes.number,
-}, AbstractEditor.propTypes);
-
-
-const EditorWrapper = React.forwardRef((props, ref) => {
-  const { data: editorSettings } = useEditorSettings();
-  const { data: defaultIndentSize } = useDefaultIndentSize();
-
-  if (editorSettings == null) {
-    return <></>;
-  }
-
-  return (
-    <Editor
-      ref={ref}
-      {...props}
-      editorSettings={editorSettings}
-      // eslint-disable-next-line react/prop-types
-      indentSize={props.indentSize ?? defaultIndentSize}
-    />
-  );
-});
-
-EditorWrapper.displayName = 'EditorWrapper';
-
-export default EditorWrapper;

+ 362 - 0
packages/app/src/components/PageEditor/Editor.tsx

@@ -0,0 +1,362 @@
+import React, {
+  useState, useRef, useImperativeHandle, useCallback, useMemo,
+} from 'react';
+
+import Dropzone from 'react-dropzone';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { toastError } from '~/client/util/apiNotification';
+import { useDefaultIndentSize } from '~/stores/context';
+import { useEditorSettings } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+import { IEditorMethods } from '../../interfaces/editor-methods';
+
+import Cheatsheet from './Cheatsheet';
+import CodeMirrorEditor from './CodeMirrorEditor';
+import pasteHelper from './PasteHelper';
+import TextAreaEditor from './TextAreaEditor';
+
+
+type EditorPropsType = {
+  value?: string,
+  isGfmMode?: boolean,
+  noCdn?: boolean,
+  isUploadable?: boolean,
+  isUploadableFile?: boolean,
+  onChange?: () => void,
+  onUpload?: (file) => void,
+  indentSize?: number,
+  onScrollCursorIntoView?: (line: number) => void,
+  onSave?: () => Promise<void>,
+  onPasteFiles?: (event: Event) => void,
+  onCtrlEnter?: (event: Event) => void,
+}
+
+type DropzoneRef = {
+  open: () => void
+}
+
+const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
+  const {
+    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+  } = props;
+
+  const [dropzoneActive, setDropzoneActive] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const [isCheatsheetModalShown, setIsCheatsheetModalShown] = useState(false);
+
+  const { t } = useTranslation();
+  const { data: editorSettings } = useEditorSettings();
+  const { data: defaultIndentSize } = useDefaultIndentSize();
+  const { data: isMobile } = useIsMobile();
+
+  const dropzoneRef = useRef<DropzoneRef>(null);
+  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  const taEditorRef = useRef<TextAreaEditor>(null);
+
+  const editorSubstance = isMobile ? taEditorRef.current : cmEditorRef.current;
+
+  const methods: Partial<IEditorMethods> = useMemo(() => {
+    return {
+      forceToFocus: () => {
+        if (editorSubstance == null) { return }
+        editorSubstance.forceToFocus();
+      },
+      setValue: (newValue: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setValue(newValue);
+      },
+      setGfmMode: (bool: boolean) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setGfmMode(bool);
+      },
+      setCaretLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setCaretLine(line);
+      },
+      setScrollTopByLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setScrollTopByLine(line);
+      },
+      insertText: (text: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.insertText(text);
+      },
+      getNavbarItems: (): JSX.Element[] => {
+        if (editorSubstance == null) { return [] }
+        // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
+        const navbarItems = editorSubstance.getNavbarItems() ?? [];
+        return navbarItems;
+      },
+    };
+  }, [editorSubstance]);
+
+  // methods for ref
+  useImperativeHandle(ref, () => ({
+    forceToFocus: methods.forceToFocus,
+    setValue: methods.setValue,
+    setGfmMode: methods.setGfmMode,
+    setCaretLine: methods.setCaretLine,
+    setScrollTopByLine: methods.setScrollTopByLine,
+    insertText: methods.insertText,
+    /**
+   * remove overlay and set isUploading to false
+   */
+    terminateUploadingState: () => {
+      setDropzoneActive(false);
+      setIsUploading(false);
+    },
+  }));
+
+  /**
+   * dispatch onUpload event
+   */
+  const dispatchUpload = useCallback((files) => {
+    if (onUpload != null) {
+      onUpload(files);
+    }
+  }, [onUpload]);
+
+  /**
+   * get acceptable(uploadable) file type
+   */
+  const getAcceptableType = useCallback(() => {
+    let accept = 'null'; // reject all
+    if (isUploadable) {
+      if (!isUploadableFile) {
+        accept = 'image/*'; // image only
+      }
+      else {
+        accept = ''; // allow all
+      }
+    }
+
+    return accept;
+  }, [isUploadable, isUploadableFile]);
+
+  const pasteFilesHandler = useCallback((event) => {
+    const items = event.clipboardData.items || event.clipboardData.files || [];
+
+    toastError(t('toaster.file_upload_failed'));
+
+    // abort if length is not 1
+    if (items.length < 1) {
+      return;
+    }
+
+    for (let i = 0; i < items.length; i++) {
+      try {
+        const file = items[i].getAsFile();
+        // check file type (the same process as Dropzone)
+        if (file != null && pasteHelper.isAcceptableType(file, getAcceptableType())) {
+          dispatchUpload(file);
+          setIsUploading(true);
+        }
+      }
+      catch (e) {
+        toastError(t('toaster.file_upload_failed'));
+      }
+    }
+  }, [dispatchUpload, getAcceptableType, t]);
+
+  const dragEnterHandler = useCallback((event) => {
+    const dataTransfer = event.dataTransfer;
+
+    // do nothing if contents is not files
+    if (!dataTransfer.types.includes('Files')) {
+      return;
+    }
+
+    setDropzoneActive(true);
+  }, []);
+
+  const dropHandler = useCallback((accepted) => {
+    // rejected
+    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
+      setDropzoneActive(false);
+      return;
+    }
+
+    const file = accepted[0];
+    dispatchUpload(file);
+    setIsUploading(true);
+  }, [dispatchUpload]);
+
+  const addAttachmentHandler = useCallback(() => {
+    if (dropzoneRef.current == null) { return }
+    dropzoneRef.current.open();
+  }, []);
+
+  const getDropzoneClassName = useCallback((isDragAccept: boolean, isDragReject: boolean) => {
+    let className = 'dropzone';
+    if (!isUploadable) {
+      className += ' dropzone-unuploadable';
+    }
+    else {
+      className += ' dropzone-uploadable';
+
+      if (isUploadableFile) {
+        className += ' dropzone-uploadablefile';
+      }
+    }
+
+    // uploading
+    if (isUploading) {
+      className += ' dropzone-uploading';
+    }
+
+    if (isDragAccept) {
+      className += ' dropzone-accepted';
+    }
+
+    if (isDragReject) {
+      className += ' dropzone-rejected';
+    }
+
+    return className;
+  }, [isUploadable, isUploading, isUploadableFile]);
+
+  const renderDropzoneOverlay = useCallback(() => {
+    return (
+      <div className="overlay overlay-dropzone-active">
+        {isUploading
+          && (
+            <span className="overlay-content">
+              <div className="speeding-wheel d-inline-block"></div>
+              <span className="sr-only">Uploading...</span>
+            </span>
+          )
+        }
+        {!isUploading && <span className="overlay-content"></span>}
+      </div>
+    );
+  }, [isUploading]);
+
+  const renderNavbar = useCallback(() => {
+    return (
+      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+        <ul className="pl-2 nav nav-navbar">
+          { methods.getNavbarItems?.().map((item, idx) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`navbarItem-${idx}`}>{item}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }, [methods]);
+
+  const renderCheatsheetModal = useCallback(() => {
+    const hideCheatsheetModal = () => {
+      setIsCheatsheetModalShown(false);
+    };
+
+    return (
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
+        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
+          <i className="icon-fw icon-question" />Markdown help
+        </ModalHeader>
+        <ModalBody>
+          <Cheatsheet />
+        </ModalBody>
+      </Modal>
+    );
+  }, [isCheatsheetModalShown]);
+
+  if (editorSettings == null) {
+    return <></>;
+  }
+
+  const flexContainer: React.CSSProperties = {
+    height: '100%',
+    display: 'flex',
+    flexDirection: 'column',
+  };
+
+  return (
+    <>
+      <div style={flexContainer} className="editor-container">
+        <Dropzone
+          ref={dropzoneRef}
+          accept={getAcceptableType()}
+          noClick
+          noKeyboard
+          multiple={false}
+          onDragLeave={() => { setDropzoneActive(false) }}
+          onDrop={dropHandler}
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            isDragAccept,
+            isDragReject,
+          }) => {
+            return (
+              <div className={getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                { dropzoneActive && renderDropzoneOverlay() }
+
+                { renderNavbar() }
+
+                {/* for PC */}
+                { !isMobile && (
+                  // eslint-disable-next-line arrow-body-style
+                  <CodeMirrorEditor
+                    ref={cmEditorRef}
+                    indentSize={indentSize ?? defaultIndentSize}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    onMarkdownHelpButtonClicked={() => { setIsCheatsheetModalShown(true) }}
+                    onAddAttachmentButtonClicked={addAttachmentHandler}
+                    editorSettings={editorSettings}
+                    isGfmMode={isGfmMode}
+                    {...props}
+                  />
+                )}
+
+                {/* for mobile */}
+                { isMobile && (
+                  <TextAreaEditor
+                    ref={taEditorRef}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    {...props}
+                  />
+                )}
+
+                <input {...getInputProps()} />
+              </div>
+            );
+          }}
+        </Dropzone>
+
+        { isUploadable
+          && (
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-block btn-open-dropzone"
+              onClick={addAttachmentHandler}
+            >
+              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+              Attach files
+              <span className="d-none d-sm-inline">
+              &nbsp;by dragging &amp; dropping,&nbsp;
+                <span className="btn-link">selecting them</span>,&nbsp;
+                or pasting from the clipboard.
+              </span>
+
+            </button>
+          )
+        }
+
+        { renderCheatsheetModal() }
+
+      </div>
+    </>
+  );
+});
+
+
+export default Editor;

+ 1 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -99,7 +99,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
   useEffect(() => {
-    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+    if (isIPageInfoForEntity(pageInfo)) {
       // likerCount
       setLikerCount(pageInfo.likerIds?.length ?? 0);
       // bookmarkCount

+ 2 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -20,7 +20,7 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 
 
 import AppContainer from '../../client/services/AppContainer';
-import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
@@ -175,6 +175,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       ? page.revision
       : page.revision._id;
 
+
     return (
       <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
         <SubNavButtons

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

@@ -125,7 +125,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     advanceFts();
   };
 
-
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPages ?? pages).map((page, i) => {

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

@@ -21,7 +21,7 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
-
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import CountBadge from '../../Common/CountBadge';
@@ -411,7 +411,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   // Rename process
   // Icon that draw attention from users for some actions
-  const shouldShowAttentionIcon = !!page.processData?.Rename?.isProcessable;
+  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
   return (
     <div

+ 17 - 0
packages/app/src/interfaces/editor-methods.ts

@@ -0,0 +1,17 @@
+export interface IEditorMethods {
+  forceToFocus: () => void,
+  setValue: (newValue: string) => void,
+  setGfmMode: (bool: boolean) => void,
+  setCaretLine: (line: number) => void,
+  setScrollTopByLine: (line: number) => void,
+  getStrFromBol(): void,
+  getStrToEol: () => void,
+  getStrFromBolToSelectedUpperPos: () => void,
+  replaceBolToCurrentPos: (text: string) => void,
+  replaceLine: (text: string) => void,
+  insertText: (text: string) => void,
+  insertLinebreak: () => void,
+  dispatchSave: () => void,
+  dispatchPasteFiles: (event: Event) => void,
+  getNavbarItems: () => JSX.Element[],
+}

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

@@ -6,10 +6,21 @@ export const PageActionType = {
   Revert: 'Revert',
   NormalizeParent: 'NormalizeParent',
 } as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType]
-export type IPageOperationProcessData = Partial<{
-  [key in PageActionType]: {isProcessable: boolean}
-}>
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+export type IPageOperationProcessData = {
+  [key in PageActionType]?: {
+    [PageActionStage.Main]?: { isProcessable: boolean },
+    [PageActionStage.Sub]?: { isProcessable: boolean },
+  }
+}
+
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
 }

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

@@ -683,6 +683,7 @@ export const getPageSchema = (crowi) => {
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
     const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -704,7 +705,9 @@ export const getPageSchema = (crowi) => {
     page.creator = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
 

+ 1 - 16
packages/app/src/server/models/page-operation.ts

@@ -3,6 +3,7 @@ import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
 
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
 } from '~/server/models/interfaces/page-operation';
@@ -19,22 +20,6 @@ const logger = loggerFactory('growi:models:page-operation');
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-export const PageActionType = {
-  Rename: 'Rename',
-  Duplicate: 'Duplicate',
-  Delete: 'Delete',
-  DeleteCompletely: 'DeleteCompletely',
-  Revert: 'Revert',
-  NormalizeParent: 'NormalizeParent',
-} as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
-
-export const PageActionStage = {
-  Main: 'Main',
-  Sub: 'Sub',
-} as const;
-export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
-
 /*
  * Main Schema
  */

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

@@ -104,6 +104,7 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
+  expandContentWidth: { type: Boolean },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },

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

@@ -17,7 +17,6 @@ const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
-
 /**
  * @swagger
  *  tags:
@@ -164,8 +163,9 @@ module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
+  const configManager = crowi.configManager;
+
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
 
@@ -219,6 +219,9 @@ module.exports = (crowi) => {
     subscribeStatus: [
       query('pageId').isString(),
     ],
+    contentWidth: [
+      body('expandContentWidth').isBoolean(),
+    ],
   };
 
   /**
@@ -825,5 +828,27 @@ module.exports = (crowi) => {
     }
   });
 
+
+  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly,
+    validator.contentWidth, apiV3FormValidator, async(req, res) => {
+      const { pageId } = req.params;
+      const { expandContentWidth } = req.body;
+
+      const isContainerFluidBySystem = configManager.getConfig('crowi', 'customize:isContainerFluid');
+
+      try {
+        const updateQuery = expandContentWidth === isContainerFluidBySystem
+          ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one
+          : { $set: { expandContentWidth } };
+
+        const page = await Page.updateOne({ _id: pageId }, updateQuery);
+        return res.apiv3({ page });
+      }
+      catch (err) {
+        logger.error('update-content-width-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   return router;
 };

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

@@ -574,8 +574,14 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, code), 403);
     }
 
+    const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+    if (pageOp == null) {
+      const msg = 'PageOperation document for Rename Sub operation not found.';
+      const code = 'document_not_found';
+      return res.apiv3Err(new ErrorV3(msg, code), 404);
+    }
+
     try {
-      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
       await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     catch (err) {

+ 15 - 5
packages/app/src/server/service/page-operation.ts

@@ -1,7 +1,9 @@
 import { pagePathUtils } from '@growi/core';
 
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
-import PageOperation, { PageActionType, PageActionStage, PageOperationDocument } from '~/server/models/page-operation';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionType, PageActionStage,
+} from '~/interfaces/page-operation';
+import PageOperation, { PageOperationDocument } from '~/server/models/page-operation';
 import loggerFactory from '~/utils/logger';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -26,9 +28,10 @@ class PageOperationService {
   }
 
   async init(): Promise<void> {
-    // cleanup PageOperation documents except ones with actionType: Rename
+    // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
     const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
+    await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }
 
   /**
@@ -137,12 +140,19 @@ class PageOperationService {
       const isProcessable = pageOp.isProcessable();
 
       // processData for processInfo
-      const processData: IPageOperationProcessData = { [actionType]: { isProcessable } };
+      const mainProcessableInfo = pageOp.actionStage === PageActionStage.Main ? { isProcessable } : undefined;
+      const subProcessableInfo = pageOp.actionStage === PageActionStage.Sub ? { isProcessable } : undefined;
+      const processData: IPageOperationProcessData = {
+        [actionType]: {
+          [PageActionStage.Main]: mainProcessableInfo,
+          [PageActionStage.Sub]: subProcessableInfo,
+        },
+      };
 
       // Merge processData if other processData exist
       if (processInfo[pageId] != null) {
         const otherProcessData = processInfo[pageId];
-        processInfo[pageId] = { ...otherProcessData, ...processData };
+        processInfo[pageId] = Object.assign(otherProcessData, processData);
         return;
       }
       // add new process data to processInfo

+ 95 - 10
packages/app/src/server/service/page.ts

@@ -15,7 +15,9 @@ import {
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionStage, PageActionType,
+} from '~/interfaces/page-operation';
 import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
@@ -27,7 +29,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
+import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
@@ -401,7 +403,19 @@ class PageService {
       logger.error('Failed to create PageOperation document.', err);
       throw err;
     }
-    const renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+
+    let renamedPage: PageDocument | null = null;
+    try {
+      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+    }
+    catch (err) {
+      logger.error('Error occurred while running renameMainOperation', err);
+
+      // cleanup
+      await PageOperation.deleteOne({ _id: pageOp._id });
+
+      throw err;
+    }
 
     return renamedPage;
   }
@@ -996,7 +1010,20 @@ class PageService {
         logger.error('Failed to create PageOperation document.', err);
         throw err;
       }
-      this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+
+      (async() => {
+        try {
+          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running duplicateRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     const result = serializePageSecurely(duplicatedTarget);
@@ -1387,7 +1414,19 @@ class PageService {
       /*
        * Resumable Operation
        */
-      this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return deletedPage;
@@ -1703,7 +1742,19 @@ class PageService {
       /*
        * Main Operation
        */
-      this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteCompletelyRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return;
@@ -1911,7 +1962,19 @@ class PageService {
       /*
        * Resumable Operation
        */
-      this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return updatedPage;
@@ -2110,6 +2173,7 @@ class PageService {
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
+    const expandContentWidth = page.expandContentWidth ?? this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     return {
       isV5Compatible: isTopPage(page.path) || page.parent != null,
@@ -2123,6 +2187,7 @@ class PageService {
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),
+      expandContentWidth,
     };
 
   }
@@ -2296,7 +2361,19 @@ class PageService {
       throw err;
     }
 
-    this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+    (async() => {
+      try {
+        await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+      }
+      catch (err) {
+        logger.error('Error occurred while running normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
+        throw err;
+      }
+    })();
   }
 
   async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
@@ -2481,7 +2558,11 @@ class PageService {
       }
       catch (err) {
         errorPagePaths.push(page.path);
-        logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
+        logger.error('Failed to run normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
         throw err;
       }
     }
@@ -3276,6 +3357,8 @@ class PageService {
   async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
+    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
@@ -3322,7 +3405,9 @@ class PageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
     }
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     // Save
     let savedPage = await page.save();
 

+ 3 - 0
packages/app/src/server/util/rate-limiter.ts

@@ -20,8 +20,10 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     }
     const methodKey = `API_RATE_LIMIT_${target}_METHODS`;
     const maxRequestsKey = `API_RATE_LIMIT_${target}_MAX_REQUESTS`;
+    const usersPerIpProspectionKey = `API_RATE_LIMIT_${target}_USERS_PER_IP`;
     const method = envVar[methodKey] ?? 'ALL';
     const maxRequests = Number(envVar[maxRequestsKey]);
+    const usersPerIpProspection = Number(envVar[usersPerIpProspectionKey]);
 
     if (endpoint == null || maxRequests == null) {
       return;
@@ -30,6 +32,7 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     const config = {
       method,
       maxRequests,
+      usersPerIpProspection,
     };
 
     apiRateLimitConfig[endpoint] = config;

+ 9 - 1
packages/app/src/server/views/layout/layout.html

@@ -61,9 +61,17 @@
 {% block html_body %}
 {% set additionalBodyClasses = []; %}
 {% block html_additional_body_classes %}{% endblock %}
-{% if getConfig('crowi', 'customize:isContainerFluid') %}
+
+{% if page.expandContentWidth !== undefined %}
+  {% set isContainerFluid = page.expandContentWidth; %}
+{% else %}
+  {% set isContainerFluid = getConfig('crowi', 'customize:isContainerFluid'); %}
+{% endif %}
+
+{% if isContainerFluid  %}
   {% set additionalBodyClasses = additionalBodyClasses|push('growi-layout-fluid') %}
 {% endif %}
+
 <body
   class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClasses|join(' ') }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"

+ 3 - 1
packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts

@@ -12,7 +12,9 @@ export default class LinkerByRelativePathConfigurer {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  configure(md, pagePath: string): void {
+  configure(md): void {
+    const pagePath = this.pagePath;
+
     // Remember old renderer, if overridden, or proxy to default renderer
     const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
       return self.renderToken(tokens, idx, options);

+ 5 - 0
packages/app/src/utils/page-operation.ts

@@ -0,0 +1,5 @@
+import { IPageOperationProcessData } from '~/interfaces/page-operation';
+
+export const shouldRecoverPagePaths = (processData: IPageOperationProcessData): boolean => {
+  return processData.Rename?.Sub != null ? processData.Rename.Sub.isProcessable : false;
+};

+ 3 - 2
packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -26,6 +26,9 @@ context('Access to page', () => {
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
+
+    cy.get('mjx-container').should('be.visible');
+
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
@@ -163,8 +166,6 @@ context('Access to /me/all-in-app-notifications', () => {
     cy.get('.notification-wrapper > a').click();
     cy.get('.notification-wrapper > .dropdown-menu > a').click();
 
-    cy.get('#all-in-app-notifications').should('be.visible');
-
     cy.screenshot(`${ssPrefix}-see-all`, { capture: 'viewport' });
 
     cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();

+ 3 - 0
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -22,6 +22,9 @@ context('Access to page by guest', () => {
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
+
+    cy.get('mjx-container').should('be.visible');
+
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 

+ 1 - 1
packages/app/test/integration/service/v5.page.test.ts

@@ -1,7 +1,7 @@
 import { addSeconds } from 'date-fns';
 import mongoose from 'mongoose';
 
-import { PageActionStage, PageActionType } from '../../../src/server/models/page-operation';
+import { PageActionStage, PageActionType } from '../../../src/interfaces/page-operation';
 import { getInstance } from '../setup-crowi';
 
 

+ 1 - 1
packages/app/test/integration/service/v5.public-page.test.ts

@@ -2,7 +2,7 @@
 import { advanceTo } from 'jest-date-mock';
 import mongoose from 'mongoose';
 
-import { PageActionType, PageActionStage } from '../../../src/server/models/page-operation';
+import { PageActionType, PageActionStage } from '../../../src/interfaces/page-operation';
 import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 0
packages/core/src/interfaces/page.ts

@@ -73,6 +73,7 @@ export type IPageInfoForEntity = IPageInfo & {
   sumOfSeenUsers: number,
   seenUserIds: string[],
   contentAge: number,
+  expandContentWidth?: boolean,
 }
 
 export type IPageInfoForOperation = IPageInfoForEntity & {

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.1.1-slackbot-proxy.0",
+  "version": "5.1.2-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -26,7 +26,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.1.1-RC.0",
+    "@growi/slack": "^5.1.2-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [
@@ -21,7 +21,7 @@
     "test": "jest --verbose"
   },
   "dependencies": {
-    "@growi/core": "^5.1.1-RC.0"
+    "@growi/core": "^5.1.2-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 79 - 24
yarn.lock

@@ -1896,6 +1896,13 @@
   dependencies:
     "@browser-bunyan/levels" "^1.6.0"
 
+"@browser-bunyan/console-formatted-stream@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.8.0.tgz#dda9dcab6ce445cbf2911045709930757e5d48c1"
+  integrity sha512-Lg5SC2uXrvZ6aLwLZT6SErfN1Is4NcrTOb5km4BW/BfL8Lv0CfpsYuhuD7ltdURL6awTYBUiT+BwhKw1Xd9glQ==
+  dependencies:
+    "@browser-bunyan/levels" "^1.8.0"
+
 "@browser-bunyan/console-plain-stream@^1.6.0":
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-plain-stream/-/console-plain-stream-1.6.0.tgz#295404482150e7693846ccb07045676218bcc911"
@@ -1903,6 +1910,13 @@
   dependencies:
     "@browser-bunyan/levels" "^1.6.0"
 
+"@browser-bunyan/console-plain-stream@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-plain-stream/-/console-plain-stream-1.8.0.tgz#18cd8fe879a0f576cf84c4fa4647e86cd3feea3e"
+  integrity sha512-S0WNsH5zvMfkbayIx90wANGHQ8l3Bvd7mjgy95/bYmUzcI+Mwkv2eJcSufdTP/MbdHBhjv/lEdLDOXEPBi+w3A==
+  dependencies:
+    "@browser-bunyan/levels" "^1.8.0"
+
 "@browser-bunyan/console-raw-stream@^1.6.0":
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.6.0.tgz#255f4734c064dc046fe7896353982c563e2ec150"
@@ -1910,11 +1924,23 @@
   dependencies:
     "@browser-bunyan/levels" "^1.6.0"
 
+"@browser-bunyan/console-raw-stream@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.8.0.tgz#5d0438139bbffd9ed779241df6ae7e5f3a2a7b0c"
+  integrity sha512-6M/xEiNckbFslQMaS1BHAxvuvN1Wtbh/aq4UzQD3fjEPFCxtubvf4KyzwPxUXA5CXq7leVZ+cibEUCRBsm5bzg==
+  dependencies:
+    "@browser-bunyan/levels" "^1.8.0"
+
 "@browser-bunyan/levels@^1.6.0":
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.6.0.tgz#3a50b8118254aa2ac26caf9d2aafa72d157e374b"
   integrity sha512-wte6nXXZH62Y/RGysYRlOkKxuJn+4S8xEamMF0fDncxxy0SriCHYwGPyWGF0FWYWmRzbZuEkp7dNebBf9Xfeeg==
 
+"@browser-bunyan/levels@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.8.0.tgz#1c0a98d04284e0620e8ee414d7ce43385080a5cf"
+  integrity sha512-f9oSDik8kAl+4rhVyHqIr012P1boHFUKc7D9nzA5+lDsFoP90UQnDwpseqBdF2mTaWYju10E7h+GdH8u+7MHOQ==
+
 "@cspotcode/source-map-support@^0.8.0":
   version "0.8.1"
   resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
@@ -5901,6 +5927,16 @@ browser-bunyan@^1.6.3:
     "@browser-bunyan/console-raw-stream" "^1.6.0"
     "@browser-bunyan/levels" "^1.6.0"
 
+browser-bunyan@^1.8.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/browser-bunyan/-/browser-bunyan-1.8.0.tgz#6b9662fea571c642fce80ad002d62e3ea1453393"
+  integrity sha512-Et1TaRUm8m2oy4OTi69g0qAM8wqpofACUgkdBnj1Kq2aC8Wpl8w+lNevebPG6zKH2w0Aq+BHiAXWwjm0/QbkaQ==
+  dependencies:
+    "@browser-bunyan/console-formatted-stream" "^1.8.0"
+    "@browser-bunyan/console-plain-stream" "^1.8.0"
+    "@browser-bunyan/console-raw-stream" "^1.8.0"
+    "@browser-bunyan/levels" "^1.8.0"
+
 browser-or-node@>=1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/browser-or-node/-/browser-or-node-1.2.1.tgz#cd65172da6a7fd689c7a650d326bd2ad145419a7"
@@ -11396,10 +11432,10 @@ inquirer@7.1.0:
     strip-ansi "^6.0.0"
     through "^2.3.6"
 
-inquirer@8.1.5:
-  version "8.1.5"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.1.5.tgz#2dc5159203c826d654915b5fe6990fd17f54a150"
-  integrity sha512-G6/9xUqmt/r+UvufSyrPpt84NYwhKZ9jLsgMbQzlx804XErNupor8WQdBnBRrXmBfTPpuwf1sV+ss2ovjgdXIg==
+inquirer@8.2.1:
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.1.tgz#e00022e3e8930a92662f760f020686530a84671d"
+  integrity sha512-pxhBaw9cyTFMjwKtkjePWDhvwzvrNGAw7En4hottzlPvz80GZaMZthdDU35aA6/f5FRZf3uhE057q8w1DE3V2g==
   dependencies:
     ansi-escapes "^4.2.1"
     chalk "^4.1.1"
@@ -11411,7 +11447,7 @@ inquirer@8.1.5:
     mute-stream "0.0.8"
     ora "^5.4.1"
     run-async "^2.4.0"
-    rxjs "^7.2.0"
+    rxjs "^7.5.5"
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
     through "^2.3.6"
@@ -17858,14 +17894,14 @@ reg-publish-s3-plugin@^0.11.0:
     reg-suit-util "^0.11.0"
     uuid "^8.3.0"
 
-reg-suit-core@^0.11.1:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/reg-suit-core/-/reg-suit-core-0.11.1.tgz#e554fab4da79a6caf2c8a312fadbdc4539a1583e"
-  integrity sha512-v3U6c8Mn8f9pz44YrnvxCLCRUWDs4t86/55XfBtxt3LGB+QxN9ekK2dNRPk67UsX3OZoB1n1dSjAJONTlWpNPw==
+reg-suit-core@^0.12.1:
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/reg-suit-core/-/reg-suit-core-0.12.1.tgz#511f63d2053a5bb76181d994074bbbb6ca432848"
+  integrity sha512-lc8MSax1CAZVJgps3EjhRIsHjfUFZun0FjC+FOwjKXohWOq+z0HfI8+IfCjhMHNbkG2kIa/YcnQF6Zd0/X/Usw==
   dependencies:
     cpx "^1.5.0"
     reg-cli "^0.17.0"
-    reg-suit-util "^0.11.0"
+    reg-suit-util "^0.12.1"
     rimraf "^3.0.2"
 
 reg-suit-util@^0.11.0:
@@ -17887,16 +17923,35 @@ reg-suit-util@^0.11.0:
     mime-types "^2.1.27"
     mkdirp "^1.0.4"
 
-reg-suit@^0.11.1:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/reg-suit/-/reg-suit-0.11.1.tgz#2bc7180873cf793724825eb492e5396a2d95daa3"
-  integrity sha512-tGCPEoQhHcUn4oZGj5nu26yaFEpksPHUwGPoKpBxSV5ldmwS/zY13KyJZaetlZh/IV7HTxLP/i9TXBlhzc3QWw==
+reg-suit-util@^0.12.1:
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/reg-suit-util/-/reg-suit-util-0.12.1.tgz#ceec40cf116ec4986d151b0af96d9fe49a60fb9e"
+  integrity sha512-w/cLYCBX8ULDsSZEJHArOuaWQms/YErFFhMsnKClvuf/mlvRQgok/zKcksaYoyAQVe/seY+/SRnHdPVtw5YViQ==
+  dependencies:
+    "@types/cli-progress" "^3.8.0"
+    "@types/cli-spinner" "^0.2.0"
+    "@types/glob" "^7.1.3"
+    "@types/lodash" "^4.14.161"
+    "@types/mime-types" "^2.1.0"
+    "@types/mkdirp" "^1.0.1"
+    chalk "^4.1.0"
+    cli-progress "^3.8.2"
+    cli-spinner "^0.2.6"
+    glob "^7.1.6"
+    lodash "^4.17.20"
+    mime-types "^2.1.27"
+    mkdirp "^1.0.4"
+
+reg-suit@^0.12.1:
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/reg-suit/-/reg-suit-0.12.1.tgz#c6897c1e909d932673dc9ed93f0ce544b8c0262f"
+  integrity sha512-aXVcRK7fVE582F+iv3VEJEsnJ3g7CfF1pjeSS+WbObCYBN+FucO3eMMq8AvgYDwhH7oc0eTBofW7Nc0Ko2zumg==
   dependencies:
     cp-file "9.1.0"
     ignore "5.1.8"
-    inquirer "8.1.5"
-    reg-suit-core "^0.11.1"
-    reg-suit-util "^0.11.0"
+    inquirer "8.2.1"
+    reg-suit-core "^0.12.1"
+    reg-suit-util "^0.12.1"
     yargs "17.0.1"
 
 regenerator-runtime@^0.11.0:
@@ -18447,13 +18502,6 @@ rxjs@^6.5.3, rxjs@^6.6.0:
   dependencies:
     tslib "^1.9.0"
 
-rxjs@^7.2.0:
-  version "7.5.2"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.2.tgz#11e4a3a1dfad85dbf7fb6e33cbba17668497490b"
-  integrity sha512-PwDt186XaL3QN5qXj/H9DGyHhP3/RYYgZZwqBv9Tv8rsAaiwFH1IsJJlcgD37J7UW5a6O67qX0KWKS3/pu0m4w==
-  dependencies:
-    tslib "^2.1.0"
-
 rxjs@^7.4.0:
   version "7.5.1"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.1.tgz#af73df343cbcab37628197f43ea0c8256f54b157"
@@ -18461,6 +18509,13 @@ rxjs@^7.4.0:
   dependencies:
     tslib "^2.1.0"
 
+rxjs@^7.5.5:
+  version "7.5.6"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.6.tgz#0446577557862afd6903517ce7cae79ecb9662bc"
+  integrity sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==
+  dependencies:
+    tslib "^2.1.0"
+
 sade@^1.7.3:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"