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

Merge branch 'master' into feat/3176-grid-edit-modal-for-master-merge

itizawa 5 лет назад
Родитель
Сommit
85537da7b1
35 измененных файлов с 375 добавлено и 335 удалено
  1. 2 0
      resource/locales/en_US/admin/admin.json
  2. 2 0
      resource/locales/ja_JP/admin/admin.json
  3. 2 2
      resource/locales/zh_CN/admin/admin.json
  4. 6 6
      src/client/js/app.jsx
  5. 3 5
      src/client/js/components/Admin/App/FileUploadSetting.jsx
  6. 1 1
      src/client/js/components/Admin/App/GcsSettings.jsx
  7. 33 0
      src/client/js/components/ExpandOrContractButton.jsx
  8. 10 4
      src/client/js/components/PageAccessories.jsx
  9. 27 3
      src/client/js/components/PageAccessoriesModal.jsx
  10. 8 13
      src/client/js/components/PageAccessoriesModalControl.jsx
  11. 7 18
      src/client/js/components/PageEditor/HandsontableModal.jsx
  12. 7 1
      src/client/js/components/PageList.jsx
  13. 1 1
      src/client/js/components/SearchPage/SearchResult.jsx
  14. 26 48
      src/client/js/components/TableOfContents.jsx
  15. 6 6
      src/client/js/components/User/SeenUserInfo.jsx
  16. 54 0
      src/client/js/components/UserContentsLinks.jsx
  17. 26 47
      src/client/js/services/AdminAppContainer.js
  18. 5 4
      src/client/js/services/PageContainer.js
  19. 1 4
      src/client/styles/scss/_handsontable.scss
  20. 2 23
      src/client/styles/scss/_layout.scss
  21. 4 0
      src/client/styles/scss/_modal.scss
  22. 0 5
      src/client/styles/scss/_on-edit.scss
  23. 40 0
      src/client/styles/scss/_page-accessories-control.scss
  24. 0 1
      src/client/styles/scss/_page_list.scss
  25. 1 1
      src/client/styles/scss/_subnav.scss
  26. 4 28
      src/client/styles/scss/_toc.scss
  27. 2 0
      src/client/styles/scss/style-app.scss
  28. 7 7
      src/client/styles/scss/theme/_apply-colors.scss
  29. 60 88
      src/server/routes/apiv3/app-settings.js
  30. 5 2
      src/server/routes/attachment.js
  31. 8 2
      src/server/service/config-loader.js
  32. 5 3
      src/server/service/config-manager.js
  33. 1 1
      src/server/service/file-uploader/local.js
  34. 0 7
      src/server/views/layout-growi/widget/liker-and-seenusers.html
  35. 9 4
      src/server/views/widget/page_content.html

+ 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",

+ 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設定",

+ 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发送。您需要从电子邮件地址和生产设置进行验证。",

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

@@ -26,22 +26,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';
@@ -108,13 +108,13 @@ if (pageContainer.state.pageId != null) {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-management': <PageManagement />,
-    'page-accessories': <PageAccessories isGuestUserMode={appContainer.currentUser == null} />,
+    'page-accessories': <PageAccessories />,
     'revision-toc': <TableOfContents />,
-    'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
 
     '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'}`}>

+ 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;

+ 10 - 4
src/client/js/components/PageAccessories.jsx

@@ -5,10 +5,17 @@ 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 { pageAccessoriesContainer, isGuestUserMode } = 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 (
     <>
@@ -24,12 +31,11 @@ const PageAccessories = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [PageAccessoriesContainer]);
+const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
 
 PageAccessories.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isGuestUserMode: PropTypes.bool.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">

+ 8 - 13
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -11,6 +11,7 @@ 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';
 
@@ -18,10 +19,10 @@ const PageAccessoriesModalControl = (props) => {
   const { t, pageAccessoriesContainer, isGuestUserMode } = props;
 
   return (
-    <div className="top-of-table-contents d-flex align-items-end pb-1">
+    <div className="grw-page-accessories-control d-flex align-items-end pb-1">
       <button
         type="button"
-        className="btn btn-link grw-btn-top-of-table"
+        className="btn btn-link grw-btn-page-accessories"
         onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pagelist')}
       >
         <PageListIcon />
@@ -29,7 +30,7 @@ const PageAccessoriesModalControl = (props) => {
 
       <button
         type="button"
-        className="btn btn-link grw-btn-top-of-table"
+        className="btn btn-link grw-btn-page-accessories"
         onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('timeline')}
       >
         <TimeLineIcon />
@@ -37,7 +38,7 @@ const PageAccessoriesModalControl = (props) => {
 
       <button
         type="button"
-        className="btn btn-link grw-btn-top-of-table"
+        className="btn btn-link grw-btn-page-accessories"
         onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('pageHistory')}
       >
         <HistoryIcon />
@@ -45,7 +46,7 @@ const PageAccessoriesModalControl = (props) => {
 
       <button
         type="button"
-        className="btn btn-link grw-btn-top-of-table"
+        className="btn btn-link grw-btn-page-accessories"
         onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
       >
         <AttachmentIcon />
@@ -54,7 +55,7 @@ const PageAccessoriesModalControl = (props) => {
       <div id="shareLink-btn-wrapper-for-tooltip">
         <button
           type="button"
-          className={`btn btn-link grw-btn-top-of-table ${isGuestUserMode && 'disabled'}`}
+          className={`btn btn-link grw-btn-page-accessories ${isGuestUserMode && 'disabled'}`}
           onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
         >
           <ShareLinkIcon />
@@ -65,13 +66,7 @@ const PageAccessoriesModalControl = (props) => {
           {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>
+      <SeenUserInfo />
     </div>
   );
 };

+ 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>

+ 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">

+ 26 - 48
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';
 
@@ -11,11 +11,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 import StickyStretchableScroller from './StickyStretchableScroller';
 
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
-
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
-const WIKI_HEADER_LINK = 120;
 
 /**
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -29,14 +26,22 @@ const TableOfContents = (props) => {
 
   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,48 +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 (
-    <>
-      <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>
   );
 
 };

+ 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 ml-1 pl-1">
       <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

+ 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);
+}

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

@@ -71,11 +71,6 @@ body.on-edit {
     display: none;
   }
 
-  // hide unnecessary elements for growi layout
-  .side-contents-container {
-    display: none !important;
-  }
-
   // show only either Edit button or HackMD button
   &.hackmd .nav-tab-edit {
     display: none;

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

@@ -0,0 +1,40 @@
+.grw-page-accessories-control {
+  flex-wrap: wrap;
+  line-height: 1.25;
+
+  .grw-btn-page-accessories {
+    width: 35px;
+    height: 35px;
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+  .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 - 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;
     }

+ 2 - 0
src/client/styles/scss/style-app.scss

@@ -40,9 +40,11 @@
 @import 'login';
 @import 'me';
 @import 'mirror_mode';
+@import 'modal';
 @import 'navbar';
 @import 'on-edit';
 @import 'page_list';
+@import 'page-accessories-control';
 @import 'page-path';
 @import 'page';
 @import 'page-presentation';

+ 7 - 7
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,6 +323,10 @@ ul.pagination {
   }
 }
 
+.revision-toc {
+  border-color: $bordercolor-toc;
+}
+
 .grw-custom-navigation {
   .nav-title {
     color: $color-link;

+ 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)));
   };

+ 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');
   };
 
   /**

+ 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>

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

@@ -54,10 +54,15 @@
   <div id="page-editor-navbar-bottom-container" class="d-none d-edit-block"></div>
 </div>
 
-<div class="d-none d-lg-block side-contents-container ml-4">
-  <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 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>