فهرست منبع

Merge branch 'support/apply-nextjs-2' of https://github.com/weseek/growi into feat/103609-rendering-all-in-app-notifications

Shun Miyazawa 3 سال پیش
والد
کامیت
38d2b7b8a4
45فایلهای تغییر یافته به همراه445 افزوده شده و 200 حذف شده
  1. 0 1
      packages/app/_obsolete/src/client/app.jsx
  2. 6 4
      packages/app/public/static/locales/en_US/admin.json
  3. 1 3
      packages/app/public/static/locales/en_US/translation.json
  4. 21 5
      packages/app/public/static/locales/ja_JP/admin.json
  5. 0 9
      packages/app/public/static/locales/ja_JP/translation.json
  6. 3 3
      packages/app/public/static/locales/zh_CN/admin.json
  7. 1 1
      packages/app/public/static/locales/zh_CN/translation.json
  8. 7 3
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  9. 14 19
      packages/app/src/components/Admin/App/MaintenanceMode.tsx
  10. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  11. 4 4
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  12. 6 6
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  13. 2 2
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  14. 5 5
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  15. 1 1
      packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx
  16. 6 6
      packages/app/src/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx
  17. 2 2
      packages/app/src/components/Admin/Notification/TriggerEventCheckBox.jsx
  18. 1 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  19. 1 1
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  20. 2 2
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  21. 4 4
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  22. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  23. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  24. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  25. 4 2
      packages/app/src/components/Fab.jsx
  26. 31 0
      packages/app/src/components/Fab.module.scss
  27. 4 0
      packages/app/src/components/Layout/BasicLayout.tsx
  28. 0 55
      packages/app/src/components/MaintenanceModeContent.tsx
  29. 3 2
      packages/app/src/components/PageEditor.tsx
  30. 6 5
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  31. 5 6
      packages/app/src/components/PageEditor/Preview.tsx
  32. 25 1
      packages/app/src/components/UncontrolledCodeMirror.tsx
  33. 21 19
      packages/app/src/pages/[[...path]].page.tsx
  34. 4 2
      packages/app/src/pages/admin/[[...path]].page.tsx
  35. 119 0
      packages/app/src/pages/maintenance.page.tsx
  36. 9 0
      packages/app/src/pages/utils/commons.ts
  37. 13 2
      packages/app/src/server/middlewares/unavailable-when-maintenance-mode.ts
  38. 4 3
      packages/app/src/server/service/export.js
  39. 17 0
      packages/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts
  40. 39 6
      packages/app/src/services/renderer/renderer.tsx
  41. 31 0
      packages/app/src/stores/maintenanceMode.tsx
  42. 15 2
      packages/app/src/stores/renderer.tsx
  43. 2 5
      packages/app/src/styles/_on-edit.scss
  44. 0 3
      packages/app/src/styles/bootstrap/_override.scss
  45. 1 0
      packages/app/src/styles/theme/_apply-colors.scss

+ 0 - 1
packages/app/_obsolete/src/client/app.jsx

@@ -20,7 +20,6 @@ import Fab from '../components/Fab';
 import ForbiddenPage from '../components/ForbiddenPage';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
-import MaintenanceModeContent from '../components/MaintenanceModeContent';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';

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

@@ -268,9 +268,6 @@
     "delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
     "toggle_notification": "Updated setting of {{path}}"
   },
-  "customize": "Customize",
-  "import_data": "Import Data",
-  "export_archive_data": "Export Archive Data",
   "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
     "management_wiki": "Management Wiki",
@@ -421,7 +418,8 @@
       "import_recommended": "Import recommended {{target}}"
     }
   },
-  "customize_setting": {
+  "customize_settings": {
+    "customize_settings": "Customize",
     "default_sidebar_mode": {
       "title": "Default sidebar mode",
       "desc": "You can set the sidebar mode for new users and guests visiting the page.",
@@ -489,6 +487,7 @@
     "delete_logo": "Delete Logo"
   },
   "importer_management": {
+    "import_data": "Import Data",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
@@ -560,6 +559,7 @@
     "Directory_hierarchy_tag": "Directory hierarchy tag"
   },
   "export_management": {
+    "export_archive_data": "Export Archive Data",
     "exporting_collection_list": "Exporting Collection List",
     "exported_data_list": "Exported Archive Data List",
     "export_collections": "Export Collections",
@@ -706,6 +706,7 @@
     "user_management": "User Management",
     "invite_users": "Temporarily issue a new user",
     "click_twice_same_checkbox": "You should check at least one checkbox.",
+    "status": "Status",
     "invite_modal": {
       "emails": "Emails (Possible to issue multiple people with new lines)",
       "description1":"Temporarily issue new users by email addresses.",
@@ -768,6 +769,7 @@
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
+    "child_user_group": "Child User Group",
     "parent_group": "Parent Group",
     "select_parent_group": "Select Parent Group",
     "release_parent_group": "Release parent group",

+ 1 - 3
packages/app/public/static/locales/en_US/translation.json

@@ -40,7 +40,6 @@
   "Page Path": "Page path",
   "Category": "Category",
   "User": "User",
-  "status": "Status",
   "account_id": "Account Id",
   "Update": "Update",
   "Update Page": "Update Page",
@@ -79,7 +78,7 @@
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
-  "Last_Login": "Last login",
+  "last_login": "Last login",
   "Share": "Share",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
@@ -120,7 +119,6 @@
   "Site URL settings": "Site URL settings",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
-  "ChildUserGroup": "ChildUserGroup",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
   "Register limitation": "Register limitation",

+ 21 - 5
packages/app/public/static/locales/ja_JP/admin.json

@@ -1,4 +1,13 @@
 {
+  "Update": "更新",
+  "Delete": "削除",
+  "User": "ユーザー",
+  "Name": "名前",
+  "Page": "ページ",
+  "Created": "作成日",
+  "Edit": "編集",
+  "Description": "説明",
+  "last_login": "最終ログイン",
   "wiki_management_home_page": "Wiki管理トップ",
   "app_settings": "アプリ設定",
   "public": "公開",
@@ -268,9 +277,6 @@
     "delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
     "toggle_notification": "{{path}}の通知設定を変更しました"
   },
-  "customize": "カスタマイズ",
-  "import_data": "データインポート",
-  "export_archive_data": "データアーカイブ",
   "full_text_search_management": {
     "full_text_search_management": "全文検索管理",
     "elasticsearch_management": "Elasticsearch 管理",
@@ -400,7 +406,7 @@
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
     "note_for_the_only_env_option": "現在GCS設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
   },
-  "markdown_setting": {
+  "markdown_settings": {
     "markdown_settings": "マークダウン設定",
     "lineBreak_header": "Line Break設定",
     "lineBreak_desc": "Line Breakの設定を変更できます。",
@@ -444,7 +450,8 @@
       "import_recommended": "{{target}} のおすすめをインポート"
     }
   },
-  "customize_setting": {
+  "customize_settings": {
+    "customize_settings": "カスタマイズ",
     "default_sidebar_mode": {
       "title": "デフォルトのサイドバーモード",
       "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
@@ -512,6 +519,7 @@
     "delete_logo": "ロゴを削除"
   },
   "export_management": {
+    "export_archive_data": "データアーカイブ",
     "exporting_collection_list": "エクスポート中のコレクション",
     "exported_data_list": "エクスポートされたアーカイブリスト",
     "export_collections": "コレクションのエクスポート",
@@ -530,6 +538,12 @@
     "delete": "削除"
   },
   "importer_management": {
+    "import_data": "データインポート",
+    "article": "記事",
+    "category": "カテゴリー",
+    "tag": "タグ",
+    "page": "ページ",
+    "page_path": "ページパス",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
@@ -727,6 +741,7 @@
     "user_management": "ユーザー管理",
     "invite_users": "新規ユーザーの仮発行",
     "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
+    "status": "ステータス",
     "invite_modal": {
       "emails": "メールアドレス (複数行入力で複数人発行可能)",
       "description1": "メールアドレスを使用して新規ユーザーを仮発行します。",
@@ -789,6 +804,7 @@
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
+    "child_user_group": "子グループ",
     "parent_group": "親グループ",
     "select_parent_group": "親グループを選択",
     "release_parent_group": "親グループの解除",

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

@@ -35,12 +35,6 @@
   "eg": "例:",
   "add": "追加",
   "Undo": "元に戻す",
-  "Article": "記事",
-  "Page": "ページ",
-  "Page Path": "ページパス",
-  "Category": "カテゴリー",
-  "User": "ユーザー",
-  "status": "ステータス",
   "account_id": "アカウントID",
   "Initialize": "初期化",
   "Update": "更新",
@@ -79,7 +73,6 @@
   "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
-  "Last_Login": "最終ログイン",
   "Share": "共有",
   "Markdown Link": "Markdown形式のリンク",
   "Create/Edit Template": "テンプレートページの作成/編集",
@@ -120,9 +113,7 @@
   "Site URL settings": "サイトURL設定",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
-  "ChildUserGroup": "子グループ",
   "Basic Settings": "基本設定",
-  "Register limitation": "登録の制限",
   "The contents entered here will be shown in the header etc": "ここに入力した内容は、ヘッダー等に表示されます。",
   "Public": "公開",
   "Anyone with the link": "リンクを知っている人のみ",

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

@@ -404,9 +404,8 @@
       "import_recommended": "导入建议 {{target}}"
     }
   },
-  "export_archive_data": "导出主题数据",
-  "customize_setting": {
-    "customize_setting": "页面定制",
+  "customize_settings": {
+    "customize_settings": "页面定制",
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
       "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
@@ -562,6 +561,7 @@
     "Directory_hierarchy_tag": "Directory hierarchy tag"
   },
   "export_management": {
+    "export_archive_data": "导出主题数据",
     "exporting_collection_list": "正在导出集合列表",
     "exported_data_list": "导出的存档数据列表",
     "export_collections": "导出集合",

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

@@ -73,7 +73,7 @@
   "username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
-  "Last_Login": "上次登录",
+  "last_login": "上次登录",
 	"Share": "分享",
   "Share Link": "分享链接",
 	"Markdown Link": "Markdown链接",

+ 7 - 3
packages/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -4,20 +4,21 @@ import { useTranslation } from 'next-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/apiNotification';
+import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
-
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import AppSetting from './AppSetting';
 import FileUploadSetting from './FileUploadSetting';
 import MailSetting from './MailSetting';
-import MaintenanceMode from './MaintenanceMode';
+import { MaintenanceMode } from './MaintenanceMode';
 import PluginSetting from './PluginSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import V5PageMigration from './V5PageMigration';
 
+
 const logger = loggerFactory('growi:appSettings');
 
 type Props = {
@@ -27,6 +28,9 @@ type Props = {
 const AppSettingsPageContents = (props: Props) => {
   const { t } = useTranslation('admin');
   const { adminAppContainer } = props;
+
+  const { data: isMaintenanceMode } = useIsMaintenanceMode();
+
   const { isV5Compatible } = adminAppContainer.state;
 
   useEffect(() => {
@@ -48,7 +52,7 @@ const AppSettingsPageContents = (props: Props) => {
     <div data-testid="admin-app-settings">
       {
         // Alert message will be displayed in case that the GROWI is under maintenance
-        adminAppContainer.state.isMaintenanceMode && (
+        isMaintenanceMode && (
           <div className="alert alert-danger alert-link" role="alert">
             <h3 className="alert-heading">
               {t('admin:maintenance_mode.maintenance_mode')}

+ 14 - 19
packages/app/src/components/Admin/App/MaintenanceMode.tsx

@@ -1,41 +1,38 @@
 import React, { FC, useState, useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
 
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { ConfirmModal } from './ConfirmModal';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 const logger = loggerFactory('growi:maintenanceMode');
 
-type Props = {
-  adminAppContainer: AdminAppContainer,
-};
 
-const MaintenanceMode: FC<Props> = (props: Props) => {
+export const MaintenanceMode: FC = () => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
+
+  const {
+    data: isMaintenanceMode, start: startMaintenanceMode, end: endMaintenanceMode,
+  } = useIsMaintenanceMode();
 
   const [isModalOpen, setModalOpen] = useState<boolean>(false);
-  const [isMaintenanceMode, setMaintenanceMode] = useState<boolean | undefined>(adminAppContainer.state.isMaintenanceMode);
 
-  const openModal = () => { setModalOpen(true) };
-  const closeModal = () => { setModalOpen(false) };
+  const openModal = useCallback(() => { setModalOpen(true) }, []);
+
+  const closeModal = useCallback(() => { setModalOpen(false) }, []);
 
   const onConfirmHandler = useCallback(async() => {
     closeModal();
 
     try {
       if (isMaintenanceMode) {
-        await adminAppContainer.endMaintenanceMode();
-        setMaintenanceMode(false);
+        endMaintenanceMode();
       }
       else {
-        await adminAppContainer.startMaintenanceMode();
-        setMaintenanceMode(true);
+        startMaintenanceMode();
       }
     }
     catch (err) {
@@ -44,7 +41,7 @@ const MaintenanceMode: FC<Props> = (props: Props) => {
 
     // eslint-disable-next-line max-len
     toastSuccess(isMaintenanceMode ? t('admin:maintenance_mode.successfully_ended_maintenance_mode') : t('admin:maintenance_mode.successfully_started_maintenance_mode'));
-  }, [isMaintenanceMode, adminAppContainer, closeModal]);
+  }, [isMaintenanceMode, closeModal, startMaintenanceMode, endMaintenanceMode, t]);
 
   return (
     <div className="mb-5">
@@ -76,5 +73,3 @@ const MaintenanceMode: FC<Props> = (props: Props) => {
     </div>
   );
 };
-
-export default withUnstatedContainers(MaintenanceMode, [AdminAppContainer]);

+ 1 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -28,7 +28,7 @@ const AdminNavigation = (props) => {
       case 'app':                      return <><i className="icon-fw icon-settings"></i>        { t('app_settings') }</>;
       case 'security':                 return <><i className="icon-fw icon-shield"></i>          { t('security_settings.security_settings') }</>;
       case 'markdown':                 return <><i className="icon-fw icon-note"></i>            { t('markdown_settings.markdown_settings') }</>;
-      case 'customize':                return <><i className="icon-fw icon-wrench"></i>          { t('customize') }</>;
+      case 'customize':                return <><i className="icon-fw icon-wrench"></i>          { t('customize_settings.customize_settings') }</>;
       case 'importer':                 return <><i className="icon-fw icon-cloud-upload"></i>    { t('importer_management.import_data') }</>;
       case 'export':                   return <><i className="icon-fw icon-cloud-download"></i>  { t('export_archive_data') }</>;
       case 'notification':             return <><i className="icon-fw icon-bell"></i>            { t('external_notification.external_notification')}</>;

+ 4 - 4
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -32,7 +32,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
   const onClickSubmit = async() => {
     try {
       await apiv3Put('/customize-setting/layout', { isContainerFluid });
-      toastSuccess(t('toaster.update_successed', { target: t('customize_setting.layout') }));
+      toastSuccess(t('toaster.update_successed', { target: t('customize_settings.layout') }));
       retrieveData();
     }
     catch (err) {
@@ -44,7 +44,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('customize_setting.layout')}</h2>
+          <h2 className="admin-setting-header">{t('customize_settings.layout')}</h2>
 
           <div className="d-flex justify-content-around mt-5">
             <div id="layoutOptions" className="card-deck">
@@ -55,7 +55,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
               >
                 <img src={`/images/customize-settings/default-${resolvedTheme}.svg`} />
                 <div className="card-body text-center">
-                  {t('customize_setting.layout_options.default')}
+                  {t('customize_settings.layout_options.default')}
                 </div>
               </div>
               <div
@@ -65,7 +65,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
               >
                 <img src={`/images/customize-settings/fluid-${resolvedTheme}.svg`} />
                 <div className="card-body  text-center">
-                  {t('customize_setting.layout_options.expanded')}
+                  {t('customize_settings.layout_options.expanded')}
                 </div>
               </div>
             </div>

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

@@ -20,7 +20,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
   const onClickSubmit = useCallback(async() => {
     try {
       await update();
-      toastSuccess(t('toaster.update_successed', { target: t('customize_setting.default_sidebar_mode.title') }));
+      toastSuccess(t('toaster.update_successed', { target: t('customize_settings.default_sidebar_mode.title') }));
     }
     catch (err) {
       toastError(err);
@@ -32,11 +32,11 @@ const CustomizeSidebarsetting = (): JSX.Element => {
       <div className="row">
         <div className="col-12">
 
-          <h2 className="admin-setting-header">{t('customize_setting.default_sidebar_mode.title')}</h2>
+          <h2 className="admin-setting-header">{t('customize_settings.default_sidebar_mode.title')}</h2>
 
           <Card className="card well my-3">
             <CardBody className="px-0 py-2">
-              {t('customize_setting.default_sidebar_mode.desc')}
+              {t('customize_settings.default_sidebar_mode.desc')}
             </CardBody>
           </Card>
 
@@ -67,7 +67,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
 
           <Card className="card well my-5">
             <CardBody className="px-0 py-2">
-              {t('customize_setting.default_sidebar_mode.dock_mode_default_desc')}
+              {t('customize_settings.default_sidebar_mode.dock_mode_default_desc')}
             </CardBody>
           </Card>
 
@@ -83,7 +83,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 onChange={() => setIsSidebarClosedAtDockMode(false)}
               />
               <label className="custom-control-label" htmlFor="is-open">
-                {t('customize_setting.default_sidebar_mode.dock_mode_default_open')}
+                {t('customize_settings.default_sidebar_mode.dock_mode_default_open')}
               </label>
             </div>
             <div className="custom-control custom-radio my-3">
@@ -97,7 +97,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 onChange={() => setIsSidebarClosedAtDockMode(true)}
               />
               <label className="custom-control-label" htmlFor="is-closed">
-                {t('customize_setting.default_sidebar_mode.dock_mode_default_close')}
+                {t('customize_settings.default_sidebar_mode.dock_mode_default_close')}
               </label>
             </div>
           </div>

+ 2 - 2
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -59,7 +59,7 @@ const CustomizeThemeOptions = (props) => {
     <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
       {/* Light and Dark Themes */}
       <div>
-        <h3>{t('customize_setting.theme_desc.light_and_dark')}</h3>
+        <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
         <div className="d-flex flex-wrap">
           {lightNDarkTheme.map((theme) => {
             return (
@@ -75,7 +75,7 @@ const CustomizeThemeOptions = (props) => {
       </div>
       {/* Unique Theme */}
       <div className="mt-3">
-        <h3>{t('customize_setting.theme_desc.unique')}</h3>
+        <h3>{t('customize_settings.theme_desc.unique')}</h3>
         <div className="d-flex flex-wrap">
           {uniqueTheme.map((theme) => {
             return (

+ 5 - 5
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -212,22 +212,22 @@ class ExportArchiveDataPage extends React.Component {
 
     return (
       <div data-testid="admin-export-archive-data">
-        <h2>{t('export_archive_data')}</h2>
+        <h2>{t('export_management.export_archive_data')}</h2>
 
         <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
-          {t('admin:export_management.create_new_archive_data')}
+          {t('export_management.create_new_archive_data')}
         </button>
 
         { showExportingData && (
           <div className="mt-5">
-            <h3>{t('admin:export_management.exporting_collection_list')}</h3>
+            <h3>{t('export_management.exporting_collection_list')}</h3>
             { this.renderProgressBarsForCollections() }
             { this.renderProgressBarForZipping() }
           </div>
         ) }
 
         <div className="mt-5">
-          <h3>{t('admin:export_management.exported_data_list')}</h3>
+          <h3>{t('export_management.exported_data_list')}</h3>
           <ArchiveFilesTable
             zipFileStats={this.state.zipFileStats}
             onZipFileStatRemove={this.onZipFileStatRemove}
@@ -252,7 +252,7 @@ ExportArchiveDataPage.propTypes = {
 };
 
 const ExportArchiveDataPageWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const { data: socket } = useAdminSocket();
 
   return <ExportArchiveDataPage t={t} socket={socket} {...props} />;

+ 1 - 1
packages/app/src/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -144,7 +144,7 @@ class ImportDataPageContents extends React.Component {
                 <tr>
                   <th>{t('importer_management.article')}</th>
                   <th><i className="icon-arrow-right-circle text-success"></i></th>
-                  <th>{t('page')}</th>
+                  <th>{t('importer_management.page')}</th>
                 </tr>
                 <tr>
                   <th>{t('importer_management.tag')}</th>

+ 6 - 6
packages/app/src/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx

@@ -62,7 +62,7 @@ class SlackConfiguration extends React.Component {
         </div>
         {adminSlackIntegrationLegacyContainer.state.selectSlackOption === 'Incoming Webhooks' ? (
           <React.Fragment>
-            <h2 className="border-bottom mb-5">{t('admin:notification_settings.slack_incoming_configuration')}</h2>
+            <h2 className="border-bottom mb-5">{t('notification_settings.slack_incoming_configuration')}</h2>
 
             <div className="row mb-3">
               <label className="col-md-3 text-left text-md-right">Webhook URL</label>
@@ -87,11 +87,11 @@ class SlackConfiguration extends React.Component {
                     onChange={() => { adminSlackIntegrationLegacyContainer.switchIsIncomingWebhookPrioritized() }}
                   />
                   <label className="custom-control-label" htmlFor="cbPrioritizeIWH">
-                    {t('admin:notification_settings.prioritize_webhook')}
+                    {t('notification_settings.prioritize_webhook')}
                   </label>
                 </div>
                 <p className="form-text text-muted">
-                  {t('admin:notification_settings.prioritize_webhook_desc')}
+                  {t('notification_settings.prioritize_webhook_desc')}
                 </p>
               </div>
             </div>
@@ -112,7 +112,7 @@ class SlackConfiguration extends React.Component {
                   data-toggle="tab"
                   onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('Incoming Webhooks')}
                 >
-                  {t('admin:notification_settings.use_instead')}
+                  {t('notification_settings.use_instead')}
                 </a>
               </div>
 
@@ -141,7 +141,7 @@ class SlackConfiguration extends React.Component {
 
         <h3>
           <i className="icon-question" aria-hidden="true"></i>{' '}
-          <a href="#collapseHelpForIwh" data-toggle="collapse">{t('admin:notification_settings.how_to.header')}</a>
+          <a href="#collapseHelpForIwh" data-toggle="collapse">{t('notification_settings.how_to.header')}</a>
         </h3>
 
         <ol id="collapseHelpForIwh" className="collapse">
@@ -177,7 +177,7 @@ SlackConfiguration.propTypes = {
 };
 
 const SlackConfigurationWrapperFc = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <SlackConfiguration t={t} {...props} />;
 };

+ 2 - 2
packages/app/src/components/Admin/Notification/TriggerEventCheckBox.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 const TriggerEventCheckBox = (props) => {
   const { t } = props;
@@ -36,7 +36,7 @@ TriggerEventCheckBox.propTypes = {
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const TriggerEventCheckBoxWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <TriggerEventCheckBox t={t} {...props} />;
 };

+ 1 - 1
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -152,7 +152,7 @@ class LocalSecuritySettingContents extends React.Component {
             </div>
             <div className="row">
               <div className="col-12 col-md-3 text-left text-md-right">
-                <strong dangerouslySetInnerHTML={{ __html: t('The whitelist of registration permission E-mail address') }} />
+                <strong dangerouslySetInnerHTML={{ __html: t('security_settings.The whitelist of registration permission E-mail address') }} />
               </div>
               <div className="col-12 col-md-6">
                 <textarea

+ 1 - 1
packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -115,7 +115,7 @@ class SamlSecurityManagementContents extends React.Component {
                 <ul>
                   {adminSamlSecurityContainer.state.missingMandatoryConfigKeys.map((configKey) => {
                     const key = configKey.replace('security:passport-saml:', '');
-                    return <li key={configKey}>{t(`security_setting.form_item_name.${key}`)}</li>;
+                    return <li key={configKey}>{t(`security_settings.form_item_name.${key}`)}</li>;
                   })}
                 </ul>
               </div>

+ 2 - 2
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -238,7 +238,7 @@ class SecuritySetting extends React.Component {
           </button>
         </div>
         <p className="form-text text-muted small">
-          {t(`security_setting.${getDeletionTypeForT(deletionType)}_explain`)}
+          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explain`)}
         </p>
       </div>
     );
@@ -453,7 +453,7 @@ class SecuritySetting extends React.Component {
           ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
         }
 
-        <h4>{t('security_settings.session')}</h4>
+        <h4>{t('security_settings.session')}aa</h4>
         <div className="form-group row">
           <label className="text-left text-md-right col-md-3 col-form-label">{t('security_settings.max_age')}</label>
           <div className="col-md-6">

+ 4 - 4
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -41,7 +41,7 @@ type Props = {
 }
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const router = useRouter();
   const xss = useMemo(() => new Xss(), []);
   const { userGroupId: currentUserGroupId } = props;
@@ -323,7 +323,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     <div>
       <nav aria-label="breadcrumb">
         <ol className="breadcrumb">
-          <li className="breadcrumb-item"><a href="/admin/user-groups">{t('admin:user_group_management.group_list')}</a></li>
+          <li className="breadcrumb-item"><a href="/admin/user-groups">{t('user_group_management.group_list')}</a></li>
           {
             ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
               ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
@@ -349,7 +349,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           onSubmit={onClickSubmitForm}
         />
       </div>
-      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
+      <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
       <UserGroupUserTable
         userGroup={currentUserGroup}
         userGroupRelations={childUserGroupRelations}
@@ -370,7 +370,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onToggleIsAlsoNameSearched={toggleAlsoNameSearched}
       />
 
-      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
+      <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
         selectableUserGroups={selectableChildUserGroups}
         onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}

+ 2 - 2
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -17,7 +17,7 @@ type Props = {
 }
 
 const UserGroupPageList = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
   const { userGroupId, relatedPages } = props;
 
   const [currentPages, setCurrentPages] = useState<IPageHasId[]>([]);
@@ -52,7 +52,7 @@ const UserGroupPageList = (props: Props): JSX.Element => {
       <ul className="page-list-ul page-list-ul-flat mb-3">
         {currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
       </ul>
-      {relatedPages != null && relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
+      {relatedPages != null && relatedPages.length === 0 ? <p>{t('user_group_management.no_pages')}</p> : (
         <PaginationWrapper
           activePage={activePage}
           changePage={handlePageChange}

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -33,7 +33,7 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           </th>
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
-          <th style={{ width: '160px' }}>{t('Last_Login')}</th>
+          <th style={{ width: '160px' }}>{t('last_login')}</th>
           <th style={{ width: '70px' }}></th>
         </tr>
       </thead>

+ 1 - 1
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -169,7 +169,7 @@ class UserTable extends React.Component {
                 <th width="150px">
                   <div className="d-flex align-items-center">
                     <div className="mr-3">
-                      {t('Last_Login')}
+                      {t('last_login')}
                     </div>
                     <SortIcons
                       isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}

+ 4 - 2
packages/app/src/components/Fab.jsx

@@ -13,6 +13,8 @@ import loggerFactory from '~/utils/logger';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
 
+import styles from './Fab.module.scss';
+
 const logger = loggerFactory('growi:cli:Fab');
 
 const Fab = () => {
@@ -71,9 +73,9 @@ const Fab = () => {
   }
 
   return (
-    <div className="grw-fab d-none d-md-block d-edit-none" data-testid="grw-fab">
+    <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab">
       {currentUser != null && renderPageCreateButton()}
-      <div data-testid="grw-fab-return-to-top" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
+      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }} data-testid="grw-fab-return-to-top">
         <button
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}

+ 31 - 0
packages/app/src/components/Fab.module.scss

@@ -0,0 +1,31 @@
+.grw-fab :global {
+  // workaround
+  // https://stackoverflow.com/a/57667536
+  .fadeInUp {
+    & :local {
+      animation: fab-fadeinup 1s ease 0s;
+    }
+  }
+  .fadeOut {
+    & :local {
+      animation: fab-fadeout 0.5s ease 0s forwards;
+    }
+  }
+}
+
+@keyframes fab-fadeinup {
+  0% {
+    opacity: 0;
+    transform: translateY(100px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0px);
+  }
+}
+
+@keyframes fab-fadeout {
+  100% {
+    opacity: 0
+  }
+}

+ 4 - 0
packages/app/src/components/Layout/BasicLayout.tsx

@@ -19,6 +19,8 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+// Fab
+const Fab = dynamic(() => import('../Fab'), { ssr: false });
 
 
 type Props = {
@@ -58,6 +60,8 @@ export const BasicLayout = ({
       <PageAccessoriesModal />
       {/* <HotkeysManager /> */}
 
+      <Fab />
+
       <ShortcutsModal />
       <SystemVersion showShortcutsButton />
     </RawLayout>

+ 0 - 55
packages/app/src/components/MaintenanceModeContent.tsx

@@ -1,55 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-import { toastError } from '~/client/util/apiNotification';
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { useCurrentUser } from '~/stores/context';
-
-
-const MaintenanceModeContent = () => {
-  const { t } = useTranslation();
-
-  const { data: currentUser } = useCurrentUser();
-
-  const logoutHandler = async() => {
-    try {
-      await apiv3Post('/logout');
-      window.location.reload();
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  };
-
-  return (
-    <div className="text-left">
-      {currentUser?.admin
-      && (
-        <p>
-          <i className="icon-arrow-right"></i>
-          <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
-        </p>
-      )}
-      {currentUser != null
-        ? (
-          <p>
-            <i className="icon-arrow-right"></i>
-            <a className="btn btn-link" onClick={logoutHandler} id="maintanounse-mode-logout">{ t('maintenance_mode.logout') }</a>
-          </p>
-        )
-        : (
-          <p>
-            <i className="icon-arrow-right"></i>
-            <a className="btn btn-link" href="/login">{ t('maintenance_mode.login') }</a>
-          </p>
-        )
-      }
-    </div>
-  );
-
-};
-
-
-export default MaintenanceModeContent;

+ 3 - 2
packages/app/src/components/PageEditor.tsx

@@ -462,9 +462,10 @@ const PageEditor = React.memo((props: Props): JSX.Element => {
       </div>
       <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
         <Preview
-          markdown={markdown}
-          rendererOptions={rendererOptions}
           ref={previewRef}
+          rendererOptions={rendererOptions}
+          markdown={markdown}
+          pagePath={currentPagePath}
           renderMathJaxOnInit={false}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
         />

+ 6 - 5
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -996,11 +996,12 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.cm}
           className={additionalClasses}
           placeholder="search"
-          editorDidMount={(editor) => {
-          // add event handlers
-            editor.on('paste', this.pasteHandler);
-            editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
-          }}
+          // == temporary deactivate editorDidMount to use https://github.com/scniro/react-codemirror2/issues/284#issuecomment-1155928554
+          // editorDidMount={(editor) => {
+          // // add event handlers
+          //   editor.on('paste', this.pasteHandler);
+          //   editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
+          // }}
           // temporary set props.value
           // value={this.state.value}
           value={this.props.value}

+ 5 - 6
packages/app/src/components/PageEditor/Preview.tsx

@@ -2,19 +2,16 @@ import React, {
   SyntheticEvent, RefObject,
 } from 'react';
 
-import ReactMarkdown from 'react-markdown';
-
-
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useEditorSettings } from '~/stores/editor';
 
-import RevisionBody from '../Page/RevisionBody';
+import RevisionRenderer from '../Page/RevisionRenderer';
 
 
 type Props = {
   rendererOptions: RendererOptions,
   markdown?: string,
-  pagePath?: string,
+  pagePath?: string | null,
   renderMathJaxOnInit?: boolean,
   onScroll?: (scrollTop: number) => void,
 }
@@ -39,7 +36,9 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
         }
       }}
     >
-      <ReactMarkdown {...rendererOptions} className='wiki'>{markdown || ''}</ReactMarkdown>
+      { markdown != null && pagePath != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} pagePath={pagePath}></RevisionRenderer>
+      ) }
     </div>
   );
 

+ 25 - 1
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -1,5 +1,8 @@
-import React, { forwardRef, ReactNode, Ref } from 'react';
+import React, {
+  forwardRef, ReactNode, Ref,
+} from 'react';
 
+import { Editor } from 'codemirror';
 import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
 
 import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
@@ -21,6 +24,25 @@ interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
 
 export class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
 
+  editor: Editor;
+
+  // wrapperRef: RefObject<any>;
+
+  constructor(props: UncontrolledCodeMirrorCoreProps) {
+    super(props);
+    this.editorDidMount = this.editorDidMount.bind(this);
+    this.editorWillUnmount = this.editorWillUnmount.bind(this);
+  }
+
+  editorDidMount(e: Editor): void {
+    this.editor = e;
+  }
+
+  editorWillUnmount(): void {
+    // workaround to fix editor duplicating by https://github.com/scniro/react-codemirror2/issues/284#issuecomment-1155928554
+    (this.editor as any).display.wrapper.remove();
+  }
+
   override render(): ReactNode {
 
     const {
@@ -38,6 +60,8 @@ export class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeM
           tabSize: 4,
           ...options,
         }}
+        editorDidMount={this.editorDidMount}
+        editorWillUnmount={this.editorWillUnmount}
         {...rest}
       />
     );

+ 21 - 19
packages/app/src/pages/[[...path]].page.tsx

@@ -305,24 +305,18 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           <div className="flex-grow-1">
             <div id="main" className={`main ${isUsersHomePage(props.currentPathname) && 'user-page'}`}>
               <div id="content-main" className="content-main grw-container-convertible">
-                <div className="row">
-                  <div className="col">
-                    { props.isIdenticalPathPage && <IdenticalPathPage /> }
-
-                    { !props.isIdenticalPathPage && (
-                      <>
-                        <PageAlerts />
-                        { props.isForbidden && <ForbiddenPage /> }
-                        { props.IsNotCreatable && <NotCreatablePage />}
-                        { !props.isForbidden && !props.IsNotCreatable && <DisplaySwitcher />}
-                        {/* <DisplaySwitcher /> */}
-                        <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
-                        {/* <PageStatusAlert /> */}
-                      </>
-                    ) }
-
-                  </div>
-                </div>
+                { props.isIdenticalPathPage && <IdenticalPathPage /> }
+
+                { !props.isIdenticalPathPage && (
+                  <>
+                    <PageAlerts />
+                    { props.isForbidden && <ForbiddenPage /> }
+                    { props.IsNotCreatable && <NotCreatablePage />}
+                    { !props.isForbidden && !props.IsNotCreatable && <DisplaySwitcher />}
+                    {/* <DisplaySwitcher /> */}
+                    {/* <PageStatusAlert /> */}
+                  </>
+                ) }
 
                 {/* <div className="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
                   <div id="revision-toc" className="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
@@ -583,7 +577,6 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   const result = await getServerSideCommonProps(context);
 
-
   // check for presence
   // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
   if (!('props' in result)) {
@@ -592,6 +585,15 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   const props: Props = result.props as Props;
 
+  if (props.redirectDestination != null) {
+    return {
+      redirect: {
+        permanent: false,
+        destination: props.redirectDestination,
+      },
+    };
+  }
+
   if (user != null) {
     props.currentUser = user.toObject();
   }

+ 4 - 2
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -36,6 +36,7 @@ import {
   useCurrentUser, /* useSearchServiceConfigured, */ useIsAclEnabled, useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
   useAuditLogEnabled, useAuditLogAvailableActions,
 } from '~/stores/context';
+import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
 
 import {
   CommonProps, getServerSideCommonProps, getNextI18NextConfig,
@@ -125,7 +126,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       component: <MarkDownSettingContents />,
     },
     customize: {
-      title: t('customize_setting.customize_setting'),
+      title: t('customize_settings.customize_settings'),
       component: <CustomizeSettingContents />,
     },
     importer: {
@@ -158,7 +159,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       title: t('user_management.user_management'),
       component: <UserManagement />,
       'external-accounts': {
-        title: t('external_account_management'),
+        title: t('user_management.external_account'),
         component: <ManageExternalAccount />,
       },
     },
@@ -192,6 +193,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
 
   useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
   useIsMailerSetup(props.isMailerSetup);
+  useIsMaintenanceMode(props.isMaintenanceMode);
 
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);

+ 119 - 0
packages/app/src/pages/maintenance.page.tsx

@@ -0,0 +1,119 @@
+import {
+  IUser, IUserHasId,
+} from '@growi/core';
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { useCurrentUser } from '~/stores/context';
+
+import {
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig,
+} from './utils/commons';
+
+
+type Props = CommonProps & {
+  currentUser: IUser,
+};
+
+const MaintenancePage: NextPage<CommonProps> = (props: Props) => {
+  const { t } = useTranslation();
+
+  useCurrentUser(props.currentUser ?? null);
+
+  const logoutHandler = async() => {
+    try {
+      await apiv3Post('/logout');
+      window.location.reload();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <div id="content-main" className="content-main container-lg">
+      <div className="container">
+        <div className="row justify-content-md-center">
+          <div className="col-md-6 mt-5">
+            <div className="text-center">
+              <h1><i className="icon-exclamation large"></i></h1>
+              <h1 className="text-center">{ t('maintenance_mode.maintenance_mode') }</h1>
+              <h3>{ t('maintenance_mode.growi_is_under_maintenance') }</h3>
+              <hr />
+              <div className="text-left">
+                {props.currentUser?.admin
+              && (
+                <p>
+                  <i className="icon-arrow-right"></i>
+                  <a className="btn btn-link" href="/admin/home">{ t('maintenance_mode.admin_page') }</a>
+                </p>
+              )}
+                {props.currentUser != null
+                  ? (
+                    <p>
+                      <i className="icon-arrow-right"></i>
+                      <a className="btn btn-link" onClick={logoutHandler} id="maintanounse-mode-logout">{ t('maintenance_mode.logout') }</a>
+                    </p>
+                  )
+                  : (
+                    <p>
+                      <i className="icon-arrow-right"></i>
+                      <a className="btn btn-link" href="/login">{ t('maintenance_mode.login') }</a>
+                    </p>
+                  )
+                }
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+
+  const result = await getServerSideCommonProps(context);
+
+  if ('redirect' in result) {
+    return { redirect: result.redirect };
+  }
+
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  if (props.redirectDestination != null) {
+    return {
+      redirect: {
+        permanent: false,
+        destination: props.redirectDestination,
+      },
+    };
+  }
+
+  const { user } = req;
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default MaintenancePage;

+ 9 - 0
packages/app/src/pages/utils/commons.ts

@@ -19,6 +19,8 @@ export type CommonProps = {
   csrfToken: string,
   isContainerFluid: boolean,
   growiVersion: string,
+  isMaintenanceMode: boolean,
+  redirectDestination: string | null,
 } & Partial<SSRConfig>;
 
 // eslint-disable-next-line max-len
@@ -33,6 +35,11 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   const url = new URL(context.resolvedUrl, 'http://example.com');
   const currentPathname = decodeURI(url.pathname);
 
+  const isMaintenanceMode = appService.isMaintenanceMode();
+
+  // eslint-disable-next-line max-len, no-nested-ternary
+  const redirectDestination = !isMaintenanceMode && currentPathname === '/maintenance' ? '/' : isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance') ? '/maintenance' : null;
+
   const props: CommonProps = {
     namespacesRequired: ['translation'],
     currentPathname,
@@ -44,6 +51,8 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     csrfToken: req.csrfToken(),
     isContainerFluid: configManager.getConfig('crowi', 'customize:isContainerFluid') ?? false,
     growiVersion: crowi.version,
+    isMaintenanceMode,
+    redirectDestination,
   };
 
   return { props };

+ 13 - 2
packages/app/src/server/middlewares/unavailable-when-maintenance-mode.ts

@@ -4,7 +4,16 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middlewares:unavailable-when-maintenance-mode');
 
-export const generateUnavailableWhenMaintenanceModeMiddleware = crowi => async(req: Request, res: Response, next: NextFunction): Promise<void> => {
+type Crowi = {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  nextApp: any,
+}
+
+type CrowiReq = Request & {
+  crowi: Crowi,
+}
+
+export const generateUnavailableWhenMaintenanceModeMiddleware = crowi => async(req: CrowiReq, res: Response, next: NextFunction): Promise<void> => {
   const isMaintenanceMode = crowi.appService.isMaintenanceMode();
 
   if (!isMaintenanceMode) {
@@ -12,7 +21,9 @@ export const generateUnavailableWhenMaintenanceModeMiddleware = crowi => async(r
     return;
   }
 
-  res.render('maintenance-mode');
+  const { nextApp } = crowi;
+  req.crowi = crowi;
+  nextApp.render(req, res, '/maintenance');
 };
 
 export const generateUnavailableWhenMaintenanceModeMiddlewareForApi = crowi => async(req: Request, res: Response, next: NextFunction): Promise<void> => {

+ 4 - 3
packages/app/src/server/service/export.js

@@ -1,5 +1,5 @@
-import loggerFactory from '~/utils/logger';
 import { toArrayIfNot } from '~/utils/array-utils';
+import loggerFactory from '~/utils/logger';
 
 import ConfigLoader from './config-loader';
 
@@ -7,10 +7,11 @@ const logger = loggerFactory('growi:services:ExportService'); // eslint-disable-
 
 const fs = require('fs');
 const path = require('path');
-const mongoose = require('mongoose');
 const { Transform } = require('stream');
-const streamToPromise = require('stream-to-promise');
+
 const archiver = require('archiver');
+const mongoose = require('mongoose');
+const streamToPromise = require('stream-to-promise');
 
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 

+ 17 - 0
packages/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts

@@ -0,0 +1,17 @@
+import { Element } from 'hast-util-select';
+import { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+const REGEXP_TARGET_TAGNAMES = new RegExp(/h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul/);
+
+export const addLineNumberAttribute: Plugin = () => {
+  return (tree) => {
+    visit(tree, 'element', (node: Element) => {
+      if (REGEXP_TARGET_TAGNAMES.test(node.tagName as string)) {
+        if (node.properties != null) {
+          node.properties['data-line'] = node.position?.start.line;
+        }
+      }
+    });
+  };
+};

+ 39 - 6
packages/app/src/services/renderer/renderer.tsx

@@ -28,6 +28,7 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import loggerFactory from '~/utils/logger';
 
 import { addClass } from './rehype-plugins/add-class';
+import { addLineNumberAttribute } from './rehype-plugins/add-line-number-attribute';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
@@ -286,7 +287,6 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
       growiPlugin,
     ],
     rehypePlugins: [
-      slug,
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
@@ -323,6 +323,7 @@ export const generateViewOptions = (
 
   // add rehype plugins
   rehypePlugins.push(
+    slug,
     katex,
     [toc, {
       nav: false,
@@ -409,10 +410,7 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   return options;
 };
 
-export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
-  const options = generateCommonOptions(undefined, config);
-  const { rehypePlugins } = options;
-
+export const generatePreviewOptions = (pagePath: string, config: RendererConfig): RendererOptions => {
   // // Add configurers for preview
   // renderer.addConfigurers([
   //   new FooternoteConfigurer(),
@@ -423,11 +421,46 @@ export const generatePreviewOptions = (config: RendererConfig): RendererOptions
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.configure();
 
+  const options = generateCommonOptions(pagePath, config);
+
+  const { remarkPlugins, rehypePlugins, components } = options;
+
+  // add remark plugins
+  remarkPlugins.push(
+    emoji,
+    math,
+    lsxGrowiPlugin.remarkPlugin,
+  );
+  if (config.isEnabledLinebreaks) {
+    remarkPlugins.push(breaks);
+  }
+
   // add rehype plugins
   rehypePlugins.push(
-    [sanitize, commonSanitizeOption],
+    katex,
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    addLineNumberAttribute,
+    // [autoLinkHeadings, {
+    //   behavior: 'append',
+    // }]
   );
 
+  const sanitizeOption = deepmerge(
+    commonSanitizeOption,
+    lsxGrowiPlugin.sanitizeOption,
+    {
+      attributes: {
+        '*': ['data-line'],
+      },
+    },
+  );
+  rehypePlugins.push([sanitize, sanitizeOption]);
+
+  // add components
+  if (components != null) {
+    components.lsx = props => <Lsx {...props} />;
+  }
+
   verifySanitizePlugin(options);
   return options;
 };

+ 31 - 0
packages/app/src/stores/maintenanceMode.tsx

@@ -0,0 +1,31 @@
+import { withUtils, SWRResponseWithUtils } from '@growi/core/src/utils/with-utils';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import { useStaticSWR } from './use-static-swr';
+
+
+type maintenanceModeUtils = {
+  start(): Promise<void>,
+  end(): Promise<void>,
+}
+
+export const useIsMaintenanceMode = (initialData?: boolean): SWRResponseWithUtils<maintenanceModeUtils, boolean> => {
+  const swrResult = useStaticSWR<boolean, Error>('isMaintenanceMode', initialData, { fallbackData: false });
+
+  const utils = {
+    start: async() => {
+      const { mutate } = swrResult;
+      await apiv3Post('/app-settings/maintenance-mode', { flag: true });
+      mutate(true);
+    },
+
+    end: async() => {
+      const { mutate } = swrResult;
+      await apiv3Post('/app-settings/maintenance-mode', { flag: false });
+      mutate(false);
+    },
+  };
+
+  return withUtils(swrResult, utils);
+};

+ 15 - 2
packages/app/src/stores/renderer.tsx

@@ -77,9 +77,22 @@ export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
 };
 
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'previewOptions';
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
+
+  const isAllDataValid = currentPagePath != null && rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['previewOptions', currentPagePath, rendererConfig]
+    : null;
 
-  return _useOptionsBase(key, generatePreviewOptions);
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, currentPagePath, rendererConfig) => generatePreviewOptions(currentPagePath, rendererConfig),
+    {
+      fallbackData: isAllDataValid ? generatePreviewOptions(currentPagePath, rendererConfig) : undefined,
+    },
+  );
 };
 
 export const useCommentPreviewOptions = (): SWRResponse<RendererOptions, Error> => {

+ 2 - 5
packages/app/src/styles/_on-edit.scss

@@ -7,11 +7,6 @@
 body.on-edit {
   overflow-y: hidden !important;
 
-  .container-fluid {
-    padding-right: 15px;
-    padding-left: 15px;
-  }
-
   .grw-navbar {
     position: fixed !important;
     width: 100vw;
@@ -22,6 +17,8 @@ body.on-edit {
     height: $grw-subnav-height-on-edit;
     min-height: unset;
     padding-top: 0;
+    padding-right: 15px;
+    padding-left: 15px;
 
     @include media-breakpoint-up(lg) {
       height: $grw-subnav-height-lg-on-edit;

+ 0 - 3
packages/app/src/styles/bootstrap/_override.scss

@@ -12,9 +12,6 @@ body {
 .container-lg,
 .container-xl,
 .container-fluid {
-  // default: 15px
-  // padding-right: 15px;
-  // padding-left: 15px;
   @include media-breakpoint-down(xs) {
     padding-right: 10px;
     padding-left: 10px;

+ 1 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -37,6 +37,7 @@ $nav-tabs-link-hover-border-color: $bordercolor-nav-tabs-hover;
 $nav-tabs-link-active-color: $color-nav-tabs-link-active;
 $nav-tabs-link-active-bg: $bgcolor-global;
 $nav-tabs-link-active-border-color: $bordercolor-nav-tabs-active;
+$theme-colors: map-merge($theme-colors, ( primary: $primary ));
 
 @import 'reboot-bootstrap-buttons';
 @import 'reboot-bootstrap-colors';