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

Merge branch 'imprv/improve-link-edit-modal' into fix/enable-switch-radio

yusuketk 5 лет назад
Родитель
Сommit
7239167959
62 измененных файлов с 681 добавлено и 614 удалено
  1. 2 0
      resource/locales/en_US/admin/admin.json
  2. 1 0
      resource/locales/en_US/translation.json
  3. 2 0
      resource/locales/ja_JP/admin/admin.json
  4. 1 0
      resource/locales/ja_JP/translation.json
  5. 2 2
      resource/locales/zh_CN/admin/admin.json
  6. 1 0
      resource/locales/zh_CN/translation.json
  7. 10 6
      src/client/js/app.jsx
  8. 3 5
      src/client/js/components/Admin/App/FileUploadSetting.jsx
  9. 1 1
      src/client/js/components/Admin/App/GcsSettings.jsx
  10. 3 3
      src/client/js/components/CustomNavigation.jsx
  11. 33 0
      src/client/js/components/ExpandOrContractButton.jsx
  12. 5 0
      src/client/js/components/InstallerForm.jsx
  13. 49 42
      src/client/js/components/Me/PersonalSettings.jsx
  14. 14 3
      src/client/js/components/Navbar/AuthorInfo.jsx
  15. 2 2
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  16. 1 1
      src/client/js/components/NotFoundPage.jsx
  17. 41 0
      src/client/js/components/PageAccessories.jsx
  18. 27 3
      src/client/js/components/PageAccessoriesModal.jsx
  19. 89 0
      src/client/js/components/PageAccessoriesModalControl.jsx
  20. 39 0
      src/client/js/components/PageContentFooter.jsx
  21. 7 18
      src/client/js/components/PageEditor/HandsontableModal.jsx
  22. 1 1
      src/client/js/components/PageHistory/RevisionDiff.jsx
  23. 7 1
      src/client/js/components/PageList.jsx
  24. 1 1
      src/client/js/components/SearchPage/SearchResult.jsx
  25. 28 53
      src/client/js/components/TableOfContents.jsx
  26. 0 106
      src/client/js/components/TopOfTableContents.jsx
  27. 1 1
      src/client/js/components/TrashPageList.jsx
  28. 6 6
      src/client/js/components/User/SeenUserInfo.jsx
  29. 54 0
      src/client/js/components/UserContentsLinks.jsx
  30. 26 47
      src/client/js/services/AdminAppContainer.js
  31. 5 4
      src/client/js/services/PageContainer.js
  32. 0 41
      src/client/styles/scss/_attachments.scss
  33. 1 4
      src/client/styles/scss/_handsontable.scss
  34. 2 23
      src/client/styles/scss/_layout.scss
  35. 4 0
      src/client/styles/scss/_modal.scss
  36. 18 7
      src/client/styles/scss/_navbar.scss
  37. 0 10
      src/client/styles/scss/_on-edit.scss
  38. 46 0
      src/client/styles/scss/_page-accessories-control.scss
  39. 0 20
      src/client/styles/scss/_page-accessories-modal.scss
  40. 6 0
      src/client/styles/scss/_page-content-footer.scss
  41. 0 1
      src/client/styles/scss/_page_list.scss
  42. 1 1
      src/client/styles/scss/_subnav.scss
  43. 4 28
      src/client/styles/scss/_toc.scss
  44. 4 1
      src/client/styles/scss/style-app.scss
  45. 27 41
      src/client/styles/scss/theme/_apply-colors.scss
  46. 10 0
      src/client/styles/scss/theme/_reboot-bootstrap-nav.scss
  47. 1 1
      src/client/styles/scss/theme/kibela.scss
  48. 60 88
      src/server/routes/apiv3/app-settings.js
  49. 5 2
      src/server/routes/attachment.js
  50. 1 2
      src/server/routes/page.js
  51. 8 2
      src/server/service/config-loader.js
  52. 5 3
      src/server/service/config-manager.js
  53. 1 1
      src/server/service/file-uploader/local.js
  54. 1 4
      src/server/views/layout-growi/page.html
  55. 1 3
      src/server/views/layout-growi/page_list.html
  56. 2 3
      src/server/views/layout-growi/user_page.html
  57. 0 7
      src/server/views/layout-growi/widget/liker-and-seenusers.html
  58. 1 1
      src/server/views/page_presentation.html
  59. 0 10
      src/server/views/widget/page_attachments.html
  60. 9 3
      src/server/views/widget/page_content.html
  61. 1 1
      src/test/models/share-link.test.js
  62. 0 0
      src/test/models/update-post.test.js

+ 2 - 0
resource/locales/en_US/admin/admin.json

@@ -46,6 +46,8 @@
     "fixed_by_env_var": "This is fixed by the env var <code>FILE_UPLOAD={{fileUploadType}}</code>.",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
+    "local_label": "Local",
+    "gridfs_label": "MongoDB(GridFS)",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "ses_settings":"SES settings",
     "test_connection": "Test connection to mail",

+ 1 - 0
resource/locales/en_US/translation.json

@@ -50,6 +50,7 @@
   "attachment_data": "Attachment Data",
   "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "Presentation",
+  "The end": "The end",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",

+ 2 - 0
resource/locales/ja_JP/admin/admin.json

@@ -45,6 +45,8 @@
     "file_upload_method":"ファイルアップロード方法",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
+    "local_label": "Local",
+    "gridfs_label": "MongoDB(GridFS)",
     "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "ses_settings":"SES設定",

+ 1 - 0
resource/locales/ja_JP/translation.json

@@ -51,6 +51,7 @@
   "attachment_data": "添付データ",
   "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "プレゼンテーション",
+  "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",

+ 2 - 2
resource/locales/zh_CN/admin/admin.json

@@ -45,8 +45,8 @@
     "file_upload_method":"文件上传方法",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
-		"fixed_by_env_var": "这是由env var<code>FILE_UPLOAD={{fileUploadType}}</code>修复的。",
-    "file_upload": "这是文件上传设定。完成了文件上传设定以后,文件上传功能、档案头像功能将会被开启。",
+    "local_label": "Local",
+    "gridfs_label": "MongoDB(GridFS)",
     "ses_settings":"SES设置",
     "test_connection": "测试邮件服务器连接",
 		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",

+ 1 - 0
resource/locales/zh_CN/translation.json

@@ -52,6 +52,7 @@
   "attachment_data": "Attachment Data",
   "No_attachments_yet": "暂无附件",
 	"Presentation Mode": "演示文稿",
+  "The end": "结束",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",

+ 10 - 6
src/client/js/app.jsx

@@ -12,6 +12,7 @@ import DisplaySwitcher from './components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
 import Page from './components/Page';
 import PageComments from './components/PageComments';
+import PageContentFooter from './components/PageContentFooter';
 import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
@@ -26,21 +27,22 @@ import RecentlyCreatedIcon from './components/Icons/RecentlyCreatedIcon';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import BookmarkIcon from './components/Icons/BookmarkIcon';
 import BookmarkList from './components/PageList/BookmarkList';
-import SeenUserList from './components/User/SeenUserList';
 import LikerList from './components/User/LikerList';
 import TableOfContents from './components/TableOfContents';
+import PageAccessories from './components/PageAccessories';
 import UserInfo from './components/User/UserInfo';
 import Fab from './components/Fab';
-
 import PersonalSettings from './components/Me/PersonalSettings';
+import UserContentsLinks from './components/UserContentsLinks';
+import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
+import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSwitcher';
+
 import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
 import PageHistoryContainer from './services/PageHistoryContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
-import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
-import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSwitcher';
 import PersonalContainer from './services/PersonalContainer';
 
 import { appContainer, componentMappings } from './base';
@@ -107,12 +109,14 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
-    'revision-toc': <TableOfContents isGuestUserMode={appContainer.currentUser == null} />,
-    'seen-user-list': <SeenUserList />,
+    'page-accessories': <PageAccessories />,
+    'revision-toc': <TableOfContents />,
     'liker-list': <LikerList />,
+    'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
+    'grw-user-contents-links': <UserContentsLinks />,
   });
 }
 if (pageContainer.state.creator != null) {

+ 3 - 5
src/client/js/components/Admin/App/FileUploadSetting.jsx

@@ -16,7 +16,7 @@ function FileUploadSetting(props) {
 
   const { t, adminAppContainer } = props;
   const { fileUploadType } = adminAppContainer.state;
-  const fileUploadTypes = ['aws', 'gcs'];
+  const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'];
 
   async function submitHandler() {
     const { t } = props;
@@ -42,7 +42,7 @@ function FileUploadSetting(props) {
         </span>
       </p>
 
-      <div className="row form-group mb-5">
+      <div className="row form-group mb-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.file_upload_method')}
         </label>
@@ -58,9 +58,7 @@ function FileUploadSetting(props) {
                     id={`file-upload-type-radio-${type}`}
                     checked={adminAppContainer.state.fileUploadType === type}
                     disabled={adminAppContainer.state.isFixedFileUploadByEnvVar}
-                    onChange={(e) => {
-                    adminAppContainer.changeFileUploadType(type);
-                  }}
+                    onChange={() => { adminAppContainer.changeFileUploadType(type) }}
                   />
                   <label className="custom-control-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
                 </div>

+ 1 - 1
src/client/js/components/Admin/App/GcsSettings.jsx

@@ -19,7 +19,7 @@ function GcsSetting(props) {
         <p
           className="alert alert-info"
           // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.note_for_the_only_env_option', { env: 'IS_GCS_ENV_PRIORITIZED' }) }}
+          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.note_for_the_only_env_option', { env: 'GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
         />
       )}
       <table className={`table settings-table ${gcsUseOnlyEnvVars && 'use-only-env-vars'}`}>

+ 3 - 3
src/client/js/components/CustomNavigation.jsx

@@ -64,9 +64,9 @@ export const CustomNav = (props) => {
   }, [activeTab, navTabRefs, navTabMapping]);
 
   return (
-    <>
+    <div className="grw-custom-nav">
       <div ref={navContainer}>
-        <Nav className="nav-title grw-custom-navbar" id="grw-custom-navbar">
+        <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
 
             const isActive = activeTab === key;
@@ -87,7 +87,7 @@ export const CustomNav = (props) => {
         </Nav>
       </div>
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
-    </>
+    </div>
   );
 
 };

+ 33 - 0
src/client/js/components/ExpandOrContractButton.jsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function ExpandOrContractButton(props) {
+  const { isWindowExpanded, contractWindow, expandWindow } = props;
+
+  const clickContractButtonHandler = () => {
+    if (contractWindow != null) {
+      contractWindow();
+    }
+  };
+
+  const clickExpandButtonHandler = () => {
+    if (expandWindow != null) {
+      expandWindow();
+    }
+  };
+
+  return (
+    <button type="button" className="close" onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}>
+      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
+    </button>
+  );
+}
+
+ExpandOrContractButton.propTypes = {
+  isWindowExpanded: PropTypes.bool,
+  contractWindow: PropTypes.func,
+  expandWindow: PropTypes.func,
+};
+
+
+export default ExpandOrContractButton;

+ 5 - 0
src/client/js/components/InstallerForm.jsx

@@ -72,6 +72,11 @@ class InstallerForm extends React.Component {
                     {this.state.selectedLang.displayName}
                   </span>
                 </button>
+                <input
+                  type="hidden"
+                  value={this.state.selectedLang.id}
+                  name="registerForm[app:globalLang]"
+                />
                 <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
                   {
                   localeMetadatas.map(meta => (

+ 49 - 42
src/client/js/components/Me/PersonalSettings.jsx

@@ -1,8 +1,8 @@
 
-import React, { Fragment } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-
+import CustomNavigation from '../CustomNavigation';
 import UserSettings from './UserSettings';
 import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
@@ -13,47 +13,54 @@ class PersonalSettings extends React.Component {
   render() {
     const { t } = this.props;
 
+    const UserIcon = () => {
+      return <i className="icon-fw icon-user"></i>;
+    };
+
+    const shereAltIcon = () => {
+      return <i className="icon-fw icon-share-alt"></i>;
+    };
+
+    const lockIcon = () => {
+      return <i className="icon-fw icon-lock"></i>;
+    };
+
+    const paperPlaneIcon = () => {
+      return <i className="icon-fw icon-paper-plane"></i>;
+    };
+
+    const navTabMapping = {
+      user_infomation: {
+        Icon: UserIcon,
+        Content: UserSettings,
+        i18n: t('User Information'),
+        index: 0,
+      },
+      external_accounts: {
+        Icon: shereAltIcon,
+        Content: ExternalAccountLinkedMe,
+        i18n: t('admin:user_management.external_accounts'),
+        index: 1,
+      },
+      password_settings: {
+        Icon: lockIcon,
+        Content: PasswordSettings,
+        i18n: t('Password Settings'),
+        index: 2,
+      },
+      api_settings: {
+        Icon: paperPlaneIcon,
+        Content: ApiSettings,
+        i18n: t('API Settings'),
+        index: 3,
+      },
+    };
+
+
     return (
-      <Fragment>
-        <div className="personal-settings">
-          <ul className="nav nav-tabs" role="tablist">
-            <li className="nav-item">
-              <a className="nav-link active" href="#user-settings" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-user"></i>{ t('User Information') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#external-accounts" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-share-alt"></i>{ t('admin:user_management.external_accounts') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#password-settings" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-lock"></i>{ t('Password Settings') }
-              </a>
-            </li>
-            <li className="nav-item">
-              <a className="nav-link" href="#apiToken" data-toggle="tab" role="tab">
-                <i className="icon-fw icon-paper-plane"></i>{ t('API Settings') }
-              </a>
-            </li>
-          </ul>
-          <div className="tab-content p-t-10">
-            <div id="user-settings" className="tab-pane active" role="tabpanel">
-              <UserSettings />
-            </div>
-            <div id="external-accounts" className="tab-pane" role="tabpanel">
-              <ExternalAccountLinkedMe />
-            </div>
-            <div id="password-settings" className="tab-pane" role="tabpanel">
-              <PasswordSettings />
-            </div>
-            <div id="apiToken" className="tab-pane" role="tabpanel">
-              <ApiSettings />
-            </div>
-          </div>
-        </div>
-      </Fragment>
+      <>
+        <CustomNavigation navTabMapping={navTabMapping} />
+      </>
     );
   }
 

+ 14 - 3
src/client/js/components/Navbar/AuthorInfo.jsx

@@ -6,22 +6,31 @@ import { userPageRoot } from '@commons/util/path-utils';
 import UserPicture from '../User/UserPicture';
 
 const AuthorInfo = (props) => {
-  const { mode, user, date } = props;
+  const {
+    mode, user, date, locate,
+  } = props;
 
-  const infoLabel = mode === 'create'
+  const infoLabelForSubNav = mode === 'create'
     ? 'Created by'
     : 'Updated by';
+  const infoLabelForFooter = mode === 'create'
+    ? 'Last revision posted at'
+    : 'Created at';
   const userLabel = user != null
     ? <a href={userPageRoot(user)}>{user.name}</a>
     : <i>Unknown</i>;
 
+  if (locate === 'footer') {
+    return <p>{infoLabelForFooter} {date} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
+  }
+
   return (
     <div className="d-flex align-items-center">
       <div className="mr-2">
         <UserPicture user={user} size="sm" />
       </div>
       <div>
-        <div>{infoLabel} {userLabel}</div>
+        <div>{infoLabelForSubNav} {userLabel}</div>
         <div className="text-muted text-date">{date}</div>
       </div>
     </div>
@@ -32,10 +41,12 @@ AuthorInfo.propTypes = {
   date: PropTypes.string.isRequired,
   user: PropTypes.object,
   mode: PropTypes.oneOf(['create', 'update']),
+  locate: PropTypes.oneOf(['subnav', 'footer']),
 };
 
 AuthorInfo.defaultProps = {
   mode: 'create',
+  locate: 'subnav',
 };
 
 

+ 2 - 2
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -155,10 +155,10 @@ const GrowiSubNavigation = (props) => {
         { (!isCompactMode && !isUserPage && !isPageNotFound && !isPageForbidden) && (
           <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
             <li className="pb-1">
-              <AuthorInfo user={creator} date={createdAt} />
+              <AuthorInfo user={creator} date={createdAt} locate="subnav" />
             </li>
             <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" />
+              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="subnav" />
             </li>
           </ul>
         ) }

+ 1 - 1
src/client/js/components/NotFoundPage.jsx

@@ -26,7 +26,7 @@ const NotFoundPage = (props) => {
   };
 
   return (
-    <div className="grw-custom-navigation mt-5 on-edit">
+    <div className="mt-5 d-edit-none">
       <CustomNavigation navTabMapping={navTabMapping} />
     </div>
   );

+ 41 - 0
src/client/js/components/PageAccessories.jsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import PageAccessoriesModalControl from './PageAccessoriesModalControl';
+import PageAccessoriesModal from './PageAccessoriesModal';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+
+const PageAccessories = (props) => {
+  const { appContainer, pageAccessoriesContainer } = props;
+  const isGuestUserMode = appContainer.currentUser == null;
+
+  // not render only when this page is shared and user is not login.
+  if (appContainer.isSharedUser && isGuestUserMode) {
+    return null;
+  }
+
+  return (
+    <>
+      <PageAccessoriesModalControl isGuestUserMode={isGuestUserMode} />
+      <PageAccessoriesModal
+        isGuestUserMode={isGuestUserMode}
+        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
+        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
+      />
+    </>
+  );
+};
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
+
+PageAccessories.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+};
+
+export default PageAccessoriesWrapper;

+ 27 - 3
src/client/js/components/PageAccessoriesModal.jsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
 import PropTypes from 'prop-types';
 
 import {
@@ -20,6 +20,7 @@ import PageList from './PageList';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
 import { CustomNav } from './CustomNavigation';
+import ExpandOrContractButton from './ExpandOrContractButton';
 
 const PageAccessoriesModal = (props) => {
   const {
@@ -27,6 +28,7 @@ const PageAccessoriesModal = (props) => {
   } = props;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
 
   const navTabMapping = useMemo(() => {
     return {
@@ -66,10 +68,32 @@ const PageAccessoriesModal = (props) => {
     onClose();
   }, [onClose]);
 
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+  };
+
+  const buttons = (
+    <span>
+      {/* change order because of `float: right` by '.close' class */}
+      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={expandWindow}
+        contractWindow={contractWindow}
+      />
+    </span>
+  );
+
   return (
     <React.Fragment>
-      <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className="grw-page-accessories-modal">
-        <ModalHeader className="p-0" toggle={closeModalHandler}>
+      <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className={`grw-page-accessories-modal ${isWindowExpanded && 'grw-modal-expanded'} `}>
+        <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
           <CustomNav activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} />
         </ModalHeader>
         <ModalBody className="overflow-auto grw-modal-body-style p-0">

+ 89 - 0
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -0,0 +1,89 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import { UncontrolledTooltip } from 'reactstrap';
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import HistoryIcon from './Icons/HistoryIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+import SeenUserInfo from './User/SeenUserInfo';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+const PageAccessoriesModalControl = (props) => {
+  const { t, pageAccessoriesContainer, isGuestUserMode } = props;
+
+  return (
+    <div className="grw-page-accessories-control d-flex align-items-center pb-1">
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pagelist')}
+      >
+        <PageListIcon />
+      </button>
+
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('timeline')}
+      >
+        <TimeLineIcon />
+      </button>
+
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
+      >
+        <HistoryIcon />
+      </button>
+
+      <button
+        type="button"
+        className="btn btn-link grw-btn-page-accessories"
+        onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
+      >
+        <AttachmentIcon />
+      </button>
+
+      <div id="shareLink-btn-wrapper-for-tooltip">
+        <button
+          type="button"
+          className={`btn btn-link grw-btn-page-accessories ${isGuestUserMode && 'disabled'}`}
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
+        >
+          <ShareLinkIcon />
+        </button>
+      </div>
+      {isGuestUserMode && (
+        <UncontrolledTooltip placement="top" target="shareLink-btn-wrapper-for-tooltip" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <span className="border-left grw-border-vr mx-1">&nbsp;</span>
+
+      <SeenUserInfo />
+    </div>
+  );
+};
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+
+PageAccessoriesModalControl.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+
+  isGuestUserMode: PropTypes.bool.isRequired,
+};
+
+export default withTranslation()(PageAccessoriesModalControlWrapper);

+ 39 - 0
src/client/js/components/PageContentFooter.jsx

@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import AuthorInfo from './Navbar/AuthorInfo';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+const PageContentFooter = (props) => {
+  const { pageContainer } = props;
+  const {
+    createdAt, creator, updatedAt, revisionAuthor,
+  } = pageContainer.state;
+
+  return (
+    <div className="page-content-footer mt-5 py-4 d-edit-none d-print-none">
+      <div className="container-lg">
+        <p className="page-meta">
+          <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="footer" />
+        </p>
+      </div>
+    </div>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageContentFooterWrapper = withUnstatedContainers(PageContentFooter, [AppContainer, PageContainer]);
+
+
+PageContentFooter.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+export default PageContentFooterWrapper;

+ 7 - 18
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -13,6 +13,7 @@ import { debounce } from 'throttle-debounce';
 
 import MarkdownTableDataImportForm from './MarkdownTableDataImportForm';
 import MarkdownTable from '../../models/MarkdownTable';
+import ExpandOrContractButton from '../ExpandOrContractButton';
 
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
@@ -397,15 +398,6 @@ export default class HandsontableModal extends React.PureComponent {
     }
   }
 
-  renderExpandOrContractButton() {
-    const iconClassName = this.state.isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen';
-    return (
-      <button type="button" className="close" onClick={this.state.isWindowExpanded ? this.contractWindow : this.expandWindow}>
-        <i className={iconClassName} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
-      </button>
-    );
-  }
-
   renderCloseButton() {
     return (
       <button type="button" className="close" onClick={this.cancel} aria-label="Close">
@@ -415,24 +407,21 @@ export default class HandsontableModal extends React.PureComponent {
   }
 
   render() {
-    const dialogClassNames = ['handsontable-modal'];
-    if (this.state.isWindowExpanded) {
-      dialogClassNames.push('handsontable-modal-expanded');
-    }
-
-    const dialogClassName = dialogClassNames.join(' ');
 
-    // eslint-disable-next-line no-unused-vars
     const buttons = (
       <span>
         {/* change order because of `float: right` by '.close' class */}
         {this.renderCloseButton()}
-        {this.renderExpandOrContractButton()}
+        <ExpandOrContractButton
+          isWindowExpanded={this.state.isWindowExpanded}
+          contractWindow={this.contractWindow}
+          expandWindow={this.expandWindow}
+        />
       </span>
     );
 
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg" className={dialogClassName}>
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg" className={`handsontable-modal ${this.state.isWindowExpanded && 'grw-modal-expanded'}`}>
         <ModalHeader tag="h4" toggle={this.cancel} close={buttons} className="bg-primary text-light">
           Edit Table
         </ModalHeader>

+ 1 - 1
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -39,7 +39,7 @@ export default class RevisionDiff extends React.Component {
 
     const diffView = { __html: diffViewHTML };
     // eslint-disable-next-line react/no-danger
-    return <div className="revision-history-diff d-table w-100" dangerouslySetInnerHTML={diffView} />;
+    return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
   }
 
 }

+ 7 - 1
src/client/js/components/PageList.jsx

@@ -50,8 +50,9 @@ const PageList = (props) => {
     );
   }
 
+  const liClasses = props.liClasses.join(' ');
   const pageList = pages.map(page => (
-    <li key={page._id} className="mb-3">
+    <li key={page._id} className={liClasses}>
       <Page page={page} />
     </li>
   ));
@@ -91,6 +92,11 @@ PageList.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer),
   pageContainer: PropTypes.instanceOf(PageContainer),
+
+  liClasses: PropTypes.arrayOf(PropTypes.string),
+};
+PageList.defaultProps = {
+  liClasses: ['mb-3'],
 };
 
 export default PageListTranslation;

+ 1 - 1
src/client/js/components/SearchPage/SearchResult.jsx

@@ -184,7 +184,7 @@ class SearchResult extends React.Component {
       // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
       const pageId = `#id_${page._id}`;
       return (
-        <li key={page._id} className="nav-item page-list-li w-100">
+        <li key={page._id} className="nav-item page-list-li w-100 m-1">
           <a className="nav-link page-list-link d-flex align-items-center" href={pageId}>
             <Page page={page} noLink />
             <div className="ml-auto d-flex">

+ 28 - 53
src/client/js/components/TableOfContents.jsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
@@ -8,14 +8,11 @@ import PageContainer from '../services/PageContainer';
 import NavigationContainer from '../services/NavigationContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
-import TopOfTableContents from './TopOfTableContents';
-import StickyStretchableScroller from './StickyStretchableScroller';
 
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import StickyStretchableScroller from './StickyStretchableScroller';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
-const WIKI_HEADER_LINK = 120;
 
 /**
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -23,20 +20,28 @@ const WIKI_HEADER_LINK = 120;
  */
 const TableOfContents = (props) => {
 
-  const { pageContainer, navigationContainer, isGuestUserMode } = props;
+  const { pageContainer, navigationContainer } = props;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
 
   const calcViewHeight = useCallback(() => {
     // calculate absolute top of '#revision-toc' element
+    const parentElem = document.querySelector('.grw-side-contents-container');
+    const parentBottom = parentElem.getBoundingClientRect().bottom;
     const containerElem = document.querySelector('#revision-toc');
     const containerTop = containerElem.getBoundingClientRect().top;
+    const containerComputedStyle = getComputedStyle(containerElem);
+    const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
+
+    // get smaller bottom line of window height - .system-version height) and containerTop
+    let bottom = Math.min(window.innerHeight - 20, parentBottom);
 
-    // window height - revisionToc top - .system-version - .grw-fab-container height - top-of-table-contents height
     if (isUserPage) {
-      return window.innerHeight - containerTop - 20 - 155 - 26 - 40;
+      // raise the bottom line by the height and margin-top of UserContentLinks
+      bottom -= 45;
     }
-    return window.innerHeight - containerTop - 20 - 155 - 26;
+    // bottom - revisionToc top
+    return bottom - (containerTop + containerPaddingTop);
   }, [isUserPage]);
 
   const { tocHtml } = pageContainer.state;
@@ -48,49 +53,21 @@ const TableOfContents = (props) => {
     navigationContainer.addSmoothScrollEvent(anchorsInToc);
   }, [tocHtml, navigationContainer]);
 
-  // get element for smoothScroll
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
   return (
-    <>
-      <TopOfTableContents isGuestUserMode={isGuestUserMode} />
-      <StickyStretchableScroller
-        contentsElemSelector=".revision-toc .markdownIt-TOC"
-        stickyElemSelector="#revision-toc"
-        calcViewHeightFunc={calcViewHeight}
-      >
-        <div
-          id="revision-toc-content"
-          className="revision-toc-content top-of-table-contents"
-         // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{
-          __html: tocHtml,
-        }}
-        />
-      </StickyStretchableScroller>
-
-      { isUserPage && (
-      <div className="mt-3 d-flex justify-content-around">
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm"
-          onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-        >
-          <i className="mr-2 icon-star"></i>
-          <span>Bookmarks</span>
-        </button>
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm"
-          onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-        >
-          <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-          <span>Recently Created</span>
-        </button>
-      </div>
-      )}
-    </>
+    <StickyStretchableScroller
+      contentsElemSelector=".revision-toc .markdownIt-TOC"
+      stickyElemSelector=".grw-side-contents-sticky-container"
+      calcViewHeightFunc={calcViewHeight}
+    >
+      <div
+        id="revision-toc-content"
+        className="revision-toc-content"
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{
+        __html: tocHtml,
+      }}
+      />
+    </StickyStretchableScroller>
   );
 
 };
@@ -103,8 +80,6 @@ const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageCont
 TableOfContents.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  isGuestUserMode: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(TableOfContentsWrapper);

+ 0 - 106
src/client/js/components/TopOfTableContents.jsx

@@ -1,106 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { UncontrolledTooltip } from 'reactstrap';
-import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
-
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-
-import PageAccessoriesModal from './PageAccessoriesModal';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-const TopOfTableContents = (props) => {
-  const { t, pageAccessoriesContainer, isGuestUserMode } = props;
-
-  function renderModal() {
-    return (
-      <PageAccessoriesModal
-        isGuestUserMode={isGuestUserMode}
-        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-      />
-    );
-  }
-
-  return (
-    <>
-      <div className="top-of-table-contents d-flex align-items-end pb-1">
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pagelist')}
-        >
-          <PageListIcon />
-        </button>
-
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('timeline')}
-        >
-          <TimeLineIcon />
-        </button>
-
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
-        >
-          <HistoryIcon />
-        </button>
-
-        <button
-          type="button"
-          className="btn btn-link grw-btn-top-of-table"
-          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
-        >
-          <AttachmentIcon />
-        </button>
-
-        <div id="shareLink-btn-wrapper-for-tooltip">
-          <button
-            type="button"
-            className={`btn btn-link grw-btn-top-of-table ${isGuestUserMode && 'disabled'}`}
-            onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
-          >
-            <ShareLinkIcon />
-          </button>
-        </div>
-        {isGuestUserMode && (
-          <UncontrolledTooltip placement="top" target="shareLink-btn-wrapper-for-tooltip" fade={false}>
-            {t('Not available for guest')}
-          </UncontrolledTooltip>
-        )}
-        <div
-          id="seen-user-list"
-          data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
-          data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
-          className="grw-seen-user-list ml-1 pl-1"
-        >
-        </div>
-      </div>
-      {renderModal()}
-    </>
-  );
-};
-/**
- * Wrapper component for using unstated
- */
-const TopOfTableContentsWrapper = withUnstatedContainers(TopOfTableContents, [PageAccessoriesContainer]);
-
-TopOfTableContents.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isGuestUserMode: PropTypes.bool.isRequired,
-};
-
-export default withTranslation()(TopOfTableContentsWrapper);

+ 1 - 1
src/client/js/components/TrashPageList.jsx

@@ -19,7 +19,7 @@ const TrashPageList = (props) => {
   };
 
   return (
-    <div className="grw-custom-navigation mt-5">
+    <div className="mt-5 d-edit-none">
       <CustomNavigation navTabMapping={navTabMapping} />
     </div>
   );

+ 6 - 6
src/client/js/components/User/SeenUserList.jsx → src/client/js/components/User/SeenUserInfo.jsx

@@ -15,12 +15,12 @@ import FootstampIcon from '../FootstampIcon';
 
 /* eslint react/no-multi-comp: 0, react/prop-types: 0 */
 
-const SeenUserList = (props) => {
+const SeenUserInfo = (props) => {
   const [popoverOpen, setPopoverOpen] = useState(false);
   const toggle = () => setPopoverOpen(!popoverOpen);
   const { pageContainer } = props;
   return (
-    <>
+    <div className="grw-seen-user-info">
       <Button id="po-seen-user" color="link" className="px-2">
         <span className="mr-1 footstamp-icon"><FootstampIcon /></span>
         <span className="seen-user-count">{pageContainer.state.countOfSeenUsers}</span>
@@ -32,17 +32,17 @@ const SeenUserList = (props) => {
           </div>
         </PopoverBody>
       </Popover>
-    </>
+    </div>
   );
 };
 
-SeenUserList.propTypes = {
+SeenUserInfo.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const SeenUserListWrapper = withUnstatedContainers(SeenUserList, [PageContainer]);
+const SeenUserInfoWrapper = withUnstatedContainers(SeenUserInfo, [PageContainer]);
 
-export default (SeenUserListWrapper);
+export default (SeenUserInfoWrapper);

+ 54 - 0
src/client/js/components/UserContentsLinks.jsx

@@ -0,0 +1,54 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import NavigationContainer from '../services/NavigationContainer';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:cli:UserContentsLinks');
+const WIKI_HEADER_LINK = 120;
+
+/**
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ */
+const UserContentsLinks = (props) => {
+
+  const { navigationContainer } = props;
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+  return (
+    <div className="mt-3 d-flex justify-content-around">
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm"
+        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="mr-2 icon-star"></i>
+        <span>Bookmarks</span>
+      </button>
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm"
+        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    </div>
+  );
+
+};
+
+UserContentsLinks.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(UserContentsLinks, [NavigationContainer]);

+ 26 - 47
src/client/js/services/AdminAppContainer.js

@@ -92,6 +92,7 @@ export default class AdminAppContainer extends Container {
 
       fileUploadType: appSettingsParams.fileUploadType,
       envFileUploadType: appSettingsParams.envFileUploadType,
+      useOnlyEnvVarForFileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType,
 
       s3Region: appSettingsParams.s3Region,
       s3CustomEndpoint: appSettingsParams.s3CustomEndpoint,
@@ -108,22 +109,15 @@ export default class AdminAppContainer extends Container {
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
     });
 
-    // check is file upload type forced
-    if (this.isFixedFileUploadByEnvVar(appSettingsParams.envFileUploadType)) {
+    // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
+    // and if env var 'FILE_UPLOAD' is null, envFileUploadType is 'aws' that is default value of 'FILE_UPLOAD'.
+    if (appSettingsParams.useOnlyEnvVarForFileUploadType) {
       this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
       this.setState({ isFixedFileUploadByEnvVar: true });
     }
 
   }
 
-  /**
-   * get isFixedFileUploadByEnvVar
-   * @return {bool} isFixedFileUploadByEnvVar
-   */
-  isFixedFileUploadByEnvVar(envFileUploadType) {
-    return envFileUploadType != null;
-  }
-
   /**
    * Change title
    */
@@ -359,48 +353,33 @@ export default class AdminAppContainer extends Container {
   }
 
   /**
-   * Update file upload setting
+   * Update updateFileUploadSettingHandler
    * @memberOf AdminAppContainer
    */
-  updateFileUploadSettingHandler() {
-    if (this.state.fileUploadType === 'aws') {
-      return this.updateAwsSettingHandler();
+  async updateFileUploadSettingHandler() {
+    const { fileUploadType } = this.state;
+
+    const requestParams = {
+      fileUploadType,
+    };
+
+    if (fileUploadType === 'gcs') {
+      requestParams.gcsApiKeyJsonPath = this.state.gcsApiKeyJsonPath;
+      requestParams.gcsBucket = this.state.gcsBucket;
+      requestParams.gcsUploadNamespace = this.state.gcsUploadNamespace;
     }
-    return this.updateGcsSettingHandler();
-  }
 
-  /**
-   * Update AWS setting
-   * @memberOf AdminAppContainer
-   * @return {Array} Appearance
-   */
-  async updateAwsSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/aws-setting', {
-      fileUploadType: this.state.fileUploadType,
-      s3Region: this.state.s3Region,
-      s3CustomEndpoint: this.state.s3CustomEndpoint,
-      s3Bucket: this.state.s3Bucket,
-      s3AccessKeyId: this.state.s3AccessKeyId,
-      s3SecretAccessKey: this.state.s3SecretAccessKey,
-    });
-    const { awsSettingParams } = response.data;
-    return awsSettingParams;
-  }
+    if (fileUploadType === 'aws') {
+      requestParams.s3Region = this.state.s3Region;
+      requestParams.s3CustomEndpoint = this.state.s3CustomEndpoint;
+      requestParams.s3Bucket = this.state.s3Bucket;
+      requestParams.s3AccessKeyId = this.state.s3AccessKeyId;
+      requestParams.s3SecretAccessKey = this.state.s3SecretAccessKey;
+    }
 
-  /**
-   * Update GCS setting
-   * @memberOf AdminAppContainer
-   * @return {Array} Appearance
-   */
-  async updateGcsSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/gcs-setting', {
-      fileUploadType: this.state.fileUploadType,
-      gcsApiKeyJsonPath: this.state.gcsApiKeyJsonPath,
-      gcsBucket: this.state.gcsBucket,
-      gcsUploadNamespace: this.state.gcsUploadNamespace,
-    });
-    const { awsSettingParams } = response.data;
-    return awsSettingParams;
+    const response = await this.appContainer.apiv3.put('/app-settings/file-upload-setting', requestParams);
+    const { responseParams } = response.data;
+    return this.setState(responseParams);
   }
 
   /**

+ 5 - 4
src/client/js/services/PageContainer.js

@@ -52,6 +52,7 @@ export default class PageContainer extends Container {
       isLiked: false,
       isBookmarked: false,
       seenUsers: [],
+      seenUserIds: mainContent.getAttribute('data-page-ids-of-seen-users'),
       countOfSeenUsers: mainContent.getAttribute('data-page-count-of-seen-users'),
 
       likerUsers: [],
@@ -61,15 +62,15 @@ export default class PageContainer extends Container {
       updatedAt: mainContent.getAttribute('data-page-updated-at'),
       isTrashPage: isTrashPage(path),
       isForbidden: JSON.parse(mainContent.getAttribute('data-page-is-forbidden')),
-      isDeleted:  JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
-      isDeletable:  JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
+      isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
+      isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
-      isAbleToDeleteCompletely:  JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
+      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
       tags: null,
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
-      shareLinksNumber:  mainContent.getAttribute('data-share-links-number'),
+      shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
       shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
 
       // latest(on remote) information

+ 0 - 41
src/client/styles/scss/_attachments.scss

@@ -1,44 +1,3 @@
-.page-attachments-row {
-  border-top: solid 1px transparent;
-}
-
-.page-attachments {
-  li.attachment {
-    list-style: none;
-  }
-
-  .attachment-userpicture {
-    line-height: 1.7em;
-    vertical-align: bottom;
-  }
-}
-
-.page-attachments,
-.page-meta {
-  font-size: 0.95em;
-
-  .attachment-in-use {
-    padding: 1px 5px;
-    margin: 0 0 0 4px;
-  }
-
-  .attachment-filetype {
-    padding: 1px 5px;
-    margin: 0 0 0 4px;
-    font-weight: normal;
-  }
-
-  .attachment-download {
-    margin: 0 0 0 4px;
-    cursor: pointer;
-  }
-
-  .attachment-delete {
-    margin: 0 0 0 4px;
-    cursor: pointer;
-  }
-}
-
 .attachment-delete-modal {
   .attachment-delete-image {
     text-align: center;

+ 1 - 4
src/client/styles/scss/_handsontable.scss

@@ -8,10 +8,7 @@
   }
 }
 
-// expanded window layout
-.handsontable-modal.handsontable-modal-expanded {
-  @include expand-modal-fullscreen(true, true);
-
+.handsontable-modal.grw-modal-expanded {
   // expand .hot-table-container (with flexbox)
   .hot-table-container {
     flex: 1;

+ 2 - 23
src/client/styles/scss/_layout.scss

@@ -36,34 +36,13 @@ body {
   }
 }
 
-.top-of-table-contents {
-  line-height: 1.25;
-  border-bottom: 1px solid transparent;
-
-  .user-list-content {
-    direction: rtl;
-
-    .liker-user-count,
-    .seen-user-count {
-      font-size: 12px;
-      font-weight: bolder;
-    }
-  }
-  .cls-1 {
-    isolation: isolate;
-  }
-}
-
-.revision-toc {
+.grw-side-contents-sticky-container {
   position: sticky;
   // growisubnavigation + grw-navbar-boder
   top: calc(100px + 4px);
   width: 250px;
+  min-width: 250px;
   margin-top: 5px;
-
-  .revision-toc-content {
-    padding: 0;
-  }
 }
 
 .grw-fab {

+ 4 - 0
src/client/styles/scss/_modal.scss

@@ -0,0 +1,4 @@
+// expanded window layout
+.modal-dialog.grw-modal-expanded {
+  @include expand-modal-fullscreen(true, true);
+}

+ 18 - 7
src/client/styles/scss/_navbar.scss

@@ -76,14 +76,25 @@
   }
 }
 
-.grw-custom-navigation {
+.grw-custom-nav {
+  .nav-title {
+    flex-wrap: nowrap;
+  }
+
+  .nav-link {
+    padding: 1rem 1.5rem;
+
+    svg {
+      width: 17px;
+      height: 17px;
+      margin-right: 5px;
+      vertical-align: text-bottom;
+    }
+  }
+
   .grw-nav-slide-hr {
-    border-bottom: 2px solid;
+    border-top: 0rem;
+    border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
   }
-  .nav-link svg {
-    width: 17px;
-    height: 17px;
-    margin-right: 5px;
-  }
 }

+ 0 - 10
src/client/styles/scss/_on-edit.scss

@@ -56,11 +56,6 @@ body.on-edit {
     }
   }
 
-  // hide when Editor
-  .grw-custom-navigation {
-    display: none;
-  }
-
   // hide unnecessary elements
   .d-edit-none {
     display: none !important;
@@ -71,11 +66,6 @@ body.on-edit {
     display: none;
   }
 
-  // hide unnecessary elements for growi layout
-  .revision-toc-container {
-    display: none !important;
-  }
-
   // show only either Edit button or HackMD button
   &.hackmd .nav-tab-edit {
     display: none;

+ 46 - 0
src/client/styles/scss/_page-accessories-control.scss

@@ -0,0 +1,46 @@
+.grw-page-accessories-control {
+  flex-wrap: wrap;
+  line-height: 1.25;
+  border-bottom: 1px solid transparent;
+
+  .grw-btn-page-accessories {
+    padding: 0.375rem 0.5rem;
+    margin: 0 0.2rem;
+
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+  .grw-border-vr {
+    height: 25px;
+  }
+
+  .seen-user-count {
+    font-size: 12px;
+    font-weight: bolder;
+  }
+  .grw-seen-user-info {
+    .btn {
+      white-space: nowrap;
+    }
+  }
+
+  .seen-user-popover {
+    max-width: 200px;
+
+    .user-list-content {
+      direction: rtl;
+
+      .liker-user-count,
+      .seen-user-count {
+        font-size: 12px;
+        font-weight: bolder;
+      }
+    }
+    .cls-1 {
+      isolation: isolate;
+    }
+  }
+}

+ 0 - 20
src/client/styles/scss/_page_accessaries_modal.scss → src/client/styles/scss/_page-accessories-modal.scss

@@ -1,30 +1,10 @@
 .grw-page-accessories-modal {
-  .nav-title {
-    flex-wrap: nowrap;
-
-    li {
-      a.nav-link {
-        padding: 1rem 1.5rem;
-      }
-    }
-  }
   .modal-header {
     button.close {
       margin: auto 0rem auto auto;
     }
   }
 
-  .grw-nav-slide-hr {
-    border-top: 0rem;
-    border-bottom: 3px solid;
-    transition: 0.3s ease-in-out;
-  }
-  .nav-link svg {
-    width: 17px;
-    height: 17px;
-    margin-right: 5px;
-  }
-
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
   }

+ 6 - 0
src/client/styles/scss/_page-content-footer.scss

@@ -0,0 +1,6 @@
+.page-content-footer {
+  border-top: solid 1px transparent;
+  .page-meta {
+    font-size: 0.95em;
+  }
+}

+ 0 - 1
src/client/styles/scss/_page_list.scss

@@ -9,7 +9,6 @@ body .page-list {
     margin: 0;
 
     > li {
-      margin: 0.5rem;
       list-style: none;
 
       > a {

+ 1 - 1
src/client/styles/scss/_subnav.scss

@@ -106,7 +106,7 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
   z-index: $zindex-sticky - 5;
 
   .grw-subnav {
-    box-shadow: 0px 6px 6px -3px rgba(black, 0.15);
+    box-shadow: 0px 6px 6px 3px rgba(black, 0.15);
   }
 }
 

+ 4 - 28
src/client/styles/scss/_toc.scss

@@ -1,37 +1,13 @@
-.top-of-table-contents {
-  flex-wrap: wrap;
-
-  .grw-btn-top-of-table {
-    width: 35px;
-    height: 35px;
-    svg {
-      width: 16px;
-      height: 16px;
-    }
-  }
-
-  .seen-user-count {
-    font-size: 12px;
-    font-weight: bolder;
-  }
-  .grw-seen-user-list {
-    .btn {
-      white-space: nowrap;
-    }
-  }
-
-  .seen-user-popover {
-    max-width: 200px;
-  }
-}
-
 .revision-toc {
   // to get on the Attachment row
   z-index: 1;
+  padding: 5px;
   font-size: 0.9em;
 
+  border-top: 1px solid transparent;
+  border-bottom: 1px solid transparent;
+
   .revision-toc-content {
-    padding: 10px;
     li {
       margin: 6px;
     }

+ 4 - 1
src/client/styles/scss/style-app.scss

@@ -35,14 +35,18 @@
 @import 'draft';
 @import 'editor-attachment';
 @import 'editor-navbar';
+@import 'page-content-footer';
 @import 'handsontable';
 @import 'layout';
 @import 'login';
 @import 'me';
 @import 'mirror_mode';
+@import 'modal';
 @import 'navbar';
 @import 'on-edit';
 @import 'page_list';
+@import 'page-accessories-control';
+@import 'page-accessories-modal';
 @import 'page-path';
 @import 'page';
 @import 'page-presentation';
@@ -57,7 +61,6 @@
 @import 'staff_credit';
 @import 'waves';
 @import 'wiki';
-@import 'page_accessaries_modal';
 @import 'sharelink';
 @import 'linkedit-preview';
 

+ 27 - 41
src/client/styles/scss/theme/_apply-colors.scss

@@ -306,15 +306,11 @@ ul.pagination {
   }
 }
 
-.top-of-table-contents {
-  border-color: $bordercolor-toc;
-
-  .grw-btn-top-of-table {
+.grw-page-accessories-control {
+  .grw-btn-page-accessories {
     fill: $color-link;
   }
-  .grw-seen-user-list {
-    @include border-vertical('before', $bordercolor-toc, 70%);
-
+  .grw-seen-user-info {
     .btn {
       color: $color-seen-user;
       &:active {
@@ -327,49 +323,39 @@ ul.pagination {
   }
 }
 
-.grw-custom-navigation {
-  .nav-title {
-    color: $color-link;
-  }
-  .nav-link svg {
-    fill: $color-link;
+.revision-toc {
+  border-color: $bordercolor-toc;
+}
+
+.grw-custom-nav {
+  .nav-item {
+    .nav-link {
+      color: $color-link;
+      svg {
+        fill: $color-link;
+      }
+
+      // Disabled state lightens text
+      &.disabled {
+        color: $nav-link-disabled-color;
+        svg {
+          fill: $nav-link-disabled-color;
+        }
+      }
+    }
   }
+
   .grw-nav-slide-hr {
     border-color: $color-link;
   }
 }
 
 .grw-page-accessories-modal {
-  .nav-title {
-    color: $color-link;
-  }
   .modal-header {
-    button.close {
+    .close {
       color: $secondary;
     }
   }
-
-  .modal-title {
-    position: relative;
-  }
-
-  .nav-link {
-    &:hover {
-      background-color: rgba($link-color, 0.08);
-    }
-  }
-  .nav-link svg {
-    fill: $color-link;
-  }
-  .modal-split-hr {
-    background-color: $bordercolor-nav-tabs;
-  }
-
-  .grw-nav-slide-hr {
-    position: absolute;
-    bottom: 0px;
-    border-color: $color-link;
-  }
 }
 
 /*
@@ -523,9 +509,9 @@ mark.rbt-highlight-text {
 }
 
 /*
- * GROWI page attachments
+ * GROWI page content footer
  */
-.page-attachments-row {
+.page-content-footer {
   background-color: darken($bgcolor-global, 2%);
   border-top-color: $border-color-theme;
 }

+ 10 - 0
src/client/styles/scss/theme/_reboot-bootstrap-nav.scss

@@ -5,6 +5,16 @@
 //
 //
 
+.nav-link {
+  // Disabled state lightens text
+  &.disabled {
+    color: $nav-link-disabled-color;
+    svg {
+      fill: $nav-link-disabled-color;
+    }
+  }
+}
+
 //
 // Tabs
 //

+ 1 - 1
src/client/styles/scss/theme/kibela.scss

@@ -32,7 +32,7 @@ $lightthemecolor: rgba(181, 203, 247, 0.61);
   border-radius: 0.35em;
 }
 
-.page-attachments-row {
+.page-content-footer {
   margin-top: 30px;
 }
 

+ 60 - 88
src/server/routes/apiv3/app-settings.js

@@ -86,10 +86,13 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          secretAccessKey:
  *            type: string
  *            description: secret key for authentification of AWS
- *      AwsSettingParams:
- *        description: AwsSettingParams
+ *      FileUploadSettingParams:
+ *        description: FileUploadTypeParams
  *        type: object
  *        properties:
+ *          fileUploadType:
+ *            type: string
+ *            description: fileUploadType
  *          region:
  *            type: string
  *            description: region of AWS S3
@@ -105,10 +108,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          secretAccessKey:
  *            type: string
  *            description: secret key for authentification of AWS
- *      GcsSettingParams:
- *        description: GcsSettingParams
- *        type: object
- *        properties:
  *          gcsApiKeyJsonPath:
  *            type: string
  *            description: apiKeyJsonPath of gcp
@@ -167,17 +166,18 @@ module.exports = (crowi) => {
       body('sesAccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('sesSecretAccessKey').trim(),
     ],
-    awsSetting: [
-      body('s3Region').trim().matches(/^[a-z]+-[a-z]+-\d+$/).withMessage((value, { req }) => req.t('validation.aws_region')),
-      body('s3CustomEndpoint').trim().matches(/^(https?:\/\/[^/]+|)$/).withMessage((value, { req }) => req.t('validation.aws_custom_endpoint')),
-      body('s3Bucket').trim(),
-      body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
-      body('s3SecretAccessKey').trim(),
-    ],
-    gcsSetting: [
+    fileUploadSetting: [
+      body('fileUploadType').isIn(['aws', 'gcs', 'local', 'gridfs']),
       body('gcsApiKeyJsonPath').trim(),
       body('gcsBucket').trim(),
       body('gcsUploadNamespace').trim(),
+      body('s3Region').trim().if(value => value !== '').matches(/^[a-z]+-[a-z]+-\d+$/)
+        .withMessage((value, { req }) => req.t('validation.aws_region')),
+      body('s3CustomEndpoint').trim().if(value => value !== '').matches(/^(https?:\/\/[^/]+|)$/)
+        .withMessage((value, { req }) => req.t('validation.aws_custom_endpoint')),
+      body('s3Bucket').trim(),
+      body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
+      body('s3SecretAccessKey').trim(),
     ],
     pluginSetting: [
       body('isEnabledPlugins').isBoolean(),
@@ -225,13 +225,14 @@ module.exports = (crowi) => {
 
       fileUploadType: crowi.configManager.getConfig('crowi', 'app:fileUploadType'),
       envFileUploadType: crowi.configManager.getConfigFromEnvVars('crowi', 'app:fileUploadType'),
+      useOnlyEnvVarForFileUploadType: crowi.configManager.getConfig('crowi', 'app:useOnlyEnvVarForFileUploadType'),
 
       s3Region: crowi.configManager.getConfig('crowi', 'aws:s3Region'),
       s3CustomEndpoint: crowi.configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
       s3Bucket: crowi.configManager.getConfig('crowi', 'aws:s3Bucket'),
       s3AccessKeyId: crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
       s3SecretAccessKey: crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
-      gcsUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'gcs:isGcsEnvPrioritizes'),
+      gcsUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'gcs:useOnlyEnvVarsForSomeOptions'),
       gcsApiKeyJsonPath: crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath'),
       gcsBucket: crowi.configManager.getConfig('crowi', 'gcs:bucket'),
       gcsUploadNamespace: crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace'),
@@ -549,105 +550,76 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /app-settings/aws-setting:
+   *    /app-settings/file-upload-settings:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingAwsSetting
-   *        summary: /app-settings/aws-setting
-   *        description: Update aws setting
+   *        operationId: updateAppSettingFileUploadSetting
+   *        summary: /app-settings/file-upload-setting
+   *        description: Update fileUploadSetting
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/AwsSettingParams'
+   *                $ref: '#/components/schemas/FileUploadSettingParams'
    *        responses:
    *          200:
-   *            description: Succeeded to update aws setting
+   *            description: Succeeded to update fileUploadSetting
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/AwsSettingParams'
+   *                  $ref: '#/components/schemas/FileUploadSettingParams'
    */
-  router.put('/aws-setting', loginRequiredStrictly, adminRequired, csrf, validator.awsSetting, apiV3FormValidator, async(req, res) => {
-    const requestAwsSettingParams = {
-      'app:fileUploadType': req.body.fileUploadType,
-      'aws:s3Region': req.body.s3Region,
-      'aws:s3CustomEndpoint': req.body.s3CustomEndpoint,
-      'aws:s3Bucket': req.body.s3Bucket,
-      'aws:s3AccessKeyId': req.body.s3AccessKeyId,
-      'aws:s3SecretAccessKey': req.body.s3SecretAccessKey,
-    };
+  router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, csrf, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+    const { fileUploadType } = req.body;
 
-    try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams, true);
-      await crowi.setUpFileUpload(true);
-      crowi.fileUploaderSwitchService.publishUpdatedMessage();
+    const requestParams = {
+      'app:fileUploadType': fileUploadType,
+    };
 
-      const awsSettingParams = {
-        s3Region: crowi.configManager.getConfig('crowi', 'aws:s3Region'),
-        s3CustomEndpoint: crowi.configManager.getConfig('crowi', 'aws:s3CustomEndpoint'),
-        s3Bucket: crowi.configManager.getConfig('crowi', 'aws:s3Bucket'),
-        s3AccessKeyId: crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId'),
-        s3SecretAccessKey: crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey'),
-      };
-      return res.apiv3({ awsSettingParams });
+    if (fileUploadType === 'gcs') {
+      requestParams['gcs:apiKeyJsonPath'] = req.body.gcsApiKeyJsonPath;
+      requestParams['gcs:bucket'] = req.body.gcsBucket;
+      requestParams['gcs:uploadNamespace'] = req.body.gcsUploadNamespace;
     }
-    catch (err) {
-      const msg = 'Error occurred in updating aws setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-awsSetting-failed'));
-    }
-
-  });
 
-  /**
-   * @swagger
-   *
-   *    /app-settings/gcs-setting:
-   *      put:
-   *        tags: [AppSettings]
-   *        operationId: updateAppSettingGcsSetting
-   *        summary: /app-settings/gcs-setting
-   *        description: Update gcs setting
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/GcsSettingParams'
-   *        responses:
-   *          200:
-   *            description: Succeeded to update gcs setting
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/GcsSettingParams'
-   */
-  router.put('/gcs-setting', loginRequiredStrictly, adminRequired, csrf, validator.gcsSetting, apiV3FormValidator, async(req, res) => {
-    const requestGcsSettingParams = {
-      'app:fileUploadType': req.body.fileUploadType,
-      'gcs:apiKeyJsonPath': req.body.gcsApiKeyJsonPath,
-      'gcs:bucket': req.body.gcsBucket,
-      'gcs:uploadNamespace': req.body.gcsUploadNamespace,
-    };
+    if (fileUploadType === 'aws') {
+      requestParams['aws:s3Region'] = req.body.s3Region;
+      requestParams['aws:s3CustomEndpoint'] = req.body.s3CustomEndpoint;
+      requestParams['aws:s3Bucket'] = req.body.s3Bucket;
+      requestParams['aws:s3AccessKeyId'] = req.body.s3AccessKeyId;
+      requestParams['aws:s3SecretAccessKey'] = req.body.s3SecretAccessKey;
+    }
 
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestGcsSettingParams, true);
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams, true);
       await crowi.setUpFileUpload(true);
       crowi.fileUploaderSwitchService.publishUpdatedMessage();
 
-      const gcsSettingParams = {
-        gcsApiKeyJsonPath: crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath'),
-        gcsBucket: crowi.configManager.getConfig('crowi', 'gcs:bucket'),
-        gcsUploadNamespace: crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+      const responseParams = {
+        fileUploadType: crowi.configManager.getConfig('crowi', 'app:fileUploadType'),
       };
-      return res.apiv3({ gcsSettingParams });
+
+      if (fileUploadType === 'gcs') {
+        responseParams.gcsApiKeyJsonPath = crowi.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
+        responseParams.gcsBucket = crowi.configManager.getConfig('crowi', 'gcs:bucket');
+        responseParams.gcsUploadNamespace = crowi.configManager.getConfig('crowi', 'gcs:uploadNamespace');
+      }
+
+      if (fileUploadType === 'aws') {
+        responseParams.s3Region = crowi.configManager.getConfig('crowi', 'aws:s3Region');
+        responseParams.s3CustomEndpoint = crowi.configManager.getConfig('crowi', 'aws:s3CustomEndpoint');
+        responseParams.s3Bucket = crowi.configManager.getConfig('crowi', 'aws:s3Bucket');
+        responseParams.s3AccessKeyId = crowi.configManager.getConfig('crowi', 'aws:s3AccessKeyId');
+        responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
+      }
+
+      return res.apiv3({ responseParams });
     }
     catch (err) {
-      const msg = 'Error occurred in updating aws setting';
+      const msg = 'Error occurred in updating fileUploadType';
       logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-awsSetting-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
     }
 
   });

+ 5 - 2
src/server/routes/attachment.js

@@ -129,7 +129,7 @@ module.exports = function(crowi, app) {
   const Attachment = crowi.model('Attachment');
   const Page = crowi.model('Page');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
-  const { fileUploadService, attachmentService, globalNotificationService } = crowi;
+  const { attachmentService, globalNotificationService } = crowi;
 
   /**
    * Check the user is accessible to the related page
@@ -176,6 +176,8 @@ module.exports = function(crowi, app) {
    * @param {boolean} forceDownload
    */
   async function responseForAttachment(req, res, attachment, forceDownload) {
+    const { fileUploadService } = crowi;
+
     if (attachment == null) {
       return res.json(ApiResponse.error('attachment not found'));
     }
@@ -286,7 +288,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} pageId, fileName
    */
   api.obsoletedGetForMongoDB = async function(req, res) {
-    if (process.env.FILE_UPLOAD !== 'mongodb') {
+    if (crowi.configManager.getConfig('crowi', 'app:fileUploadType') !== 'mongodb') {
       return res.status(400);
     }
 
@@ -341,6 +343,7 @@ module.exports = function(crowi, app) {
    * @apiGroup Attachment
    */
   api.limit = async function(req, res) {
+    const { fileUploadService } = crowi;
     const fileSize = Number(req.query.fileSize);
     return res.json(ApiResponse.success(await fileUploadService.checkLimit(fileSize)));
   };

+ 1 - 2
src/server/routes/page.js

@@ -433,7 +433,6 @@ module.exports = function(crowi, app) {
     const revisionId = req.query.revision;
 
     const layoutName = configManager.getConfig('crowi', 'customize:layout');
-    const view = `layout-${layoutName}/shared_page`;
 
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
 
@@ -471,7 +470,7 @@ module.exports = function(crowi, app) {
     addRenderVarsForScope(renderVars, page);
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
-    return res.render(view, renderVars);
+    return res.render(`layout-${layoutName}/shared_page`, renderVars);
   };
 
   /**

+ 8 - 2
src/server/service/config-loader.js

@@ -29,6 +29,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: 'aws',
   },
+  FILE_UPLOAD_USES_ONLY_ENV_VAR_FOR_FILE_UPLOAD_TYPE: {
+    ns:      'crowi',
+    key:     'app:useOnlyEnvVarForFileUploadType',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   // HACKMD_URI: {
   //   ns:      ,
   //   key:     ,
@@ -344,9 +350,9 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
-  IS_GCS_ENV_PRIORITIZED: {
+  GCS_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
-    key:     'gcs:isGcsEnvPrioritizes',
+    key:     'gcs:useOnlyEnvVarsForSomeOptions',
     type:    TYPES.BOOLEAN,
     default: false,
   },

+ 5 - 3
src/server/service/config-manager.js

@@ -229,12 +229,14 @@ class ConfigManager extends S2sMessageHandlable {
         && this.defaultSearch('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions')
       )
       // file upload option
-      // [TODO GW-4173] control with the env var gcs:isFileUploadEnvPrioritizes
-      || KEYS_FOR_FIEL_UPLOAD_USE_ONLY_ENV_OPTION.includes(key)
+      || (
+        KEYS_FOR_FIEL_UPLOAD_USE_ONLY_ENV_OPTION.includes(key)
+        && this.searchOnlyFromEnvVarConfigs('crowi', 'app:useOnlyEnvVarForFileUploadType')
+      )
       // gcs option
       || (
         KEYS_FOR_GCS_USE_ONLY_ENV_OPTION.includes(key)
-        && this.searchOnlyFromEnvVarConfigs('crowi', 'gcs:isGcsEnvPrioritizes')
+        && this.searchOnlyFromEnvVarConfigs('crowi', 'gcs:useOnlyEnvVarsForSomeOptions')
       )
     ));
   }

+ 1 - 1
src/server/service/file-uploader/local.js

@@ -99,7 +99,7 @@ module.exports = function(crowi) {
    */
   lib.canRespond = () => {
     // Check whether to use internal redirect of nginx or Apache.
-    return process.env.FILE_UPLOAD === 'local' && lib.configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
+    return lib.configManager.getConfig('crowi', 'fileUpload:local:useInternalRedirect');
   };
 
   /**

+ 1 - 4
src/server/views/layout-growi/page.html

@@ -16,10 +16,7 @@
 
 {% block content_main_after %}
   {% include 'widget/comments.html' %}
-
-  {% if page %}
-    {% include '../widget/page_attachments.html' %}
-  {% endif %}
+  <div id="page-content-footer"></div>
 {% endblock %}
 
 

+ 1 - 3
src/server/views/layout-growi/page_list.html

@@ -18,9 +18,7 @@
       <div id="trash-page-list"></div>
     </div>
   {% endif %}
-  {% if page %}
-    {% include '../widget/page_attachments.html' %}
-  {% endif %}
+  <div id="page-content-footer"></div>
 {% endblock %}
 
 

+ 2 - 3
src/server/views/layout-growi/user_page.html

@@ -45,7 +45,6 @@
     </div>
   {% endif %}
 
-  {% if page %}
-    {% include '../widget/page_attachments.html' %}
-  {% endif %}
+  <div id="page-content-footer"></div>
+
 {% endblock %}

+ 0 - 7
src/server/views/layout-growi/widget/liker-and-seenusers.html

@@ -1,7 +0,0 @@
-<div class="liker-and-seenusers">
-  <div
-    id="seen-user-list"
-    data-user-ids-str="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"
-    data-sum-of-seen-users="{{ page.seenUsers.length|default(0) }}"
-  ></div>
-</div>

+ 1 - 1
src/server/views/page_presentation.html

@@ -56,7 +56,7 @@
 {{ revision.body|presentation|safe }}
           </script>
         </section>
-        <section  data-markdown># おしまい</section>
+        <section  data-markdown># {{ t('The end') }}</section>
       </div>
     </div>
 

+ 0 - 10
src/server/views/widget/page_attachments.html

@@ -1,10 +0,0 @@
-<div class="page-attachments-row mt-5 py-4 d-edit-none d-print-none">
-  <div class="container-lg">
-    <div class="page-attachments" id="page-attachment"></div>
-
-    <p class="page-meta">
-      <p>Last revision posted at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-sm rounded-circle"> {{ page.revision.author.name }}</a></p>
-      <p>Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <a href="/user/{{ page.creator.username }}"><img src="{{ page.creator|default(page.creator)|picture }}" class="picture picture-sm rounded-circle"> {{ page.creator.name }}</a></p>
-    </p>
-  </div>
-</div>

+ 9 - 3
src/server/views/widget/page_content.html

@@ -54,9 +54,15 @@
   <div id="page-editor-navbar-bottom-container" class="d-none d-edit-block"></div>
 </div>
 
-<div class="d-none d-lg-block revision-toc-container ml-4">
-  <div id="revision-toc" class="revision-toc sps sps--abv" data-sps-offset="123">
-    <div id="revision-toc-content" class="revision-toc-content"></div>
+<div class="d-none d-lg-block d-editor-none grw-side-contents-container ml-4">
+  <div class="grw-side-contents-sticky-container">
+    <div id="page-accessories" class="page-accessories"></div>
+    <div id="revision-toc" class="revision-toc sps sps--abv" data-sps-offset="123">
+      <div id="revision-toc-content" class="revision-toc-content"></div>
+    </div>
+    {% if pageUser %}
+      <div id="grw-user-contents-links"></div>
+    {% endif %}
   </div>
 </div>
 

+ 1 - 1
src/test/models/shareLink.test.js → src/test/models/share-link.test.js

@@ -98,7 +98,7 @@ describe('ShareLink', () => {
       expect(findOneResult.populate).toHaveBeenCalled();
       expect(res.render).toHaveBeenCalled();
       expect(response.page).toEqual('layout-growi/expired_shared_page');
-      expect(response.renderVars).toEqual(null);
+      expect(response.renderVars).not.toEqual(null);
     });
 
     test('share link is found, and it has the page you can see', async() => {

+ 0 - 0
src/test/models/updatePost.test.js → src/test/models/update-post.test.js