Преглед изворни кода

Merge branch 'feat/article-area-renovation' into feat/refactor-like-bookmark

# Conflicts:
#	src/client/js/components/LikeButton.jsx
#	src/client/js/services/PageContainer.js
#	src/server/views/layout-growi/widget/liker-and-seenusers.html
takeru0001 пре 5 година
родитељ
комит
217122038e
100 измењених фајлова са 2052 додато и 1795 уклоњено
  1. 1 1
      .devcontainer/Dockerfile
  2. 1 1
      .devcontainer/docker-compose.yml
  3. 0 35
      .github/workflows/prerelease.yml
  4. 0 12
      .github/workflows/release.yml
  5. 39 20
      .gitignore
  6. 35 2
      CHANGES.md
  7. 1 0
      bin/github-actions/list-branches.js
  8. 1 1
      package.json
  9. BIN
      public/images/admin/customize/layout-classic-thumb.gif
  10. BIN
      public/images/admin/customize/layout-classic.gif
  11. BIN
      public/images/admin/customize/layout-crowi-plus-thumb.gif
  12. BIN
      public/images/admin/customize/layout-crowi-plus.gif
  13. BIN
      public/images/admin/customize/layout-kibela-thumb.gif
  14. BIN
      public/images/admin/customize/layout-kibela.gif
  15. 13 19
      resource/locales/en_US/admin/admin.json
  16. 7 2
      resource/locales/en_US/translation.json
  17. 13 19
      resource/locales/ja_JP/admin/admin.json
  18. 7 2
      resource/locales/ja_JP/translation.json
  19. 17 26
      resource/locales/zh_CN/admin/admin.json
  20. 23 18
      resource/locales/zh_CN/translation.json
  21. 5 31
      src/client/js/app.jsx
  22. 0 2
      src/client/js/base.jsx
  23. 0 3
      src/client/js/components/Admin/App/AwsSetting.jsx
  24. 70 169
      src/client/js/components/Admin/App/MailSetting.jsx
  25. 68 0
      src/client/js/components/Admin/App/SesSetting.jsx
  26. 89 0
      src/client/js/components/Admin/App/SmtpSetting.jsx
  27. 1 1
      src/client/js/components/Admin/Customize/Customize.jsx
  28. 0 17
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  29. 0 48
      src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx
  30. 0 67
      src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx
  31. 2 0
      src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx
  32. 6 13
      src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx
  33. 10 1
      src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  34. 18 4
      src/client/js/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  35. 50 8
      src/client/js/components/Admin/ImportData/GrowiArchiveSection.jsx
  36. 9 8
      src/client/js/components/Admin/ManageExternalAccount.jsx
  37. 34 29
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  38. 2 1
      src/client/js/components/Admin/UserManagement.jsx
  39. 1 1
      src/client/js/components/BookmarkButton.jsx
  40. 21 11
      src/client/js/components/Fab.jsx
  41. 31 0
      src/client/js/components/FootstampIcon.jsx
  42. 27 0
      src/client/js/components/Icons/AttachmentIcon.jsx
  43. 0 0
      src/client/js/components/Icons/GrowiLogo.jsx
  44. 17 0
      src/client/js/components/Icons/PageListIcon.jsx
  45. 16 0
      src/client/js/components/Icons/PagePreviewIcon.jsx
  46. 22 0
      src/client/js/components/Icons/PresentationIcon.jsx
  47. 21 0
      src/client/js/components/Icons/RecentChangesIcon.jsx
  48. 35 0
      src/client/js/components/Icons/ShareLinkIcon.jsx
  49. 19 0
      src/client/js/components/Icons/TimeLineIcon.jsx
  50. 1 1
      src/client/js/components/Navbar/GrowiNavbar.jsx
  51. 7 3
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  52. 15 4
      src/client/js/components/Navbar/ThreeStrandedButton.jsx
  53. 2 2
      src/client/js/components/Page.jsx
  54. 1 0
      src/client/js/components/Page/CopyDropdown.jsx
  55. 43 0
      src/client/js/components/Page/DisplaySwitcher.jsx
  56. 57 0
      src/client/js/components/Page/PageManagement.jsx
  57. 0 159
      src/client/js/components/Page/PageShareManagement.jsx
  58. 7 18
      src/client/js/components/Page/TagLabels.jsx
  59. 160 0
      src/client/js/components/PageAccessoriesModal.jsx
  60. 55 19
      src/client/js/components/PageAttachment.jsx
  61. 2 1
      src/client/js/components/PageEditor.jsx
  62. 1 1
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  63. 1 1
      src/client/js/components/PageEditor/DrawioModal.jsx
  64. 270 150
      src/client/js/components/PageEditor/LinkEditModal.jsx
  65. 3 4
      src/client/js/components/PageEditor/MarkdownLinkUtil.js
  66. 0 48
      src/client/js/components/PageEditor/PagePathNavForEditor.jsx
  67. 8 10
      src/client/js/components/PageHistory.jsx
  68. 86 0
      src/client/js/components/PageList.jsx
  69. 31 0
      src/client/js/components/PagePresentationModal.jsx
  70. 61 72
      src/client/js/components/PageTimeline.jsx
  71. 19 1
      src/client/js/components/PaginationWrapper.jsx
  72. 4 26
      src/client/js/components/SavePageControls/GrantSelector.jsx
  73. 29 43
      src/client/js/components/ShareLink/ShareLink.jsx
  74. 4 4
      src/client/js/components/ShareLink/ShareLinkForm.jsx
  75. 3 3
      src/client/js/components/ShareLink/ShareLinkList.jsx
  76. 4 3
      src/client/js/components/TableOfContents.jsx
  77. 95 0
      src/client/js/components/TopOfTableContents.jsx
  78. 29 19
      src/client/js/components/User/SeenUserList.jsx
  79. 1 23
      src/client/js/legacy/crowi.js
  80. 7 26
      src/client/js/models/Linker.js
  81. 58 38
      src/client/js/services/AdminAppContainer.js
  82. 4 19
      src/client/js/services/AdminCustomizeContainer.js
  83. 19 2
      src/client/js/services/NavigationContainer.js
  84. 54 0
      src/client/js/services/PageAccessoriesContainer.js
  85. 9 16
      src/client/js/services/PageContainer.js
  86. 0 169
      src/client/styles/scss/_comment_kibela.scss
  87. 2 24
      src/client/styles/scss/_layout.scss
  88. 6 4
      src/client/styles/scss/_layout_growi.scss
  89. 0 170
      src/client/styles/scss/_layout_kibela.scss
  90. 13 5
      src/client/styles/scss/_linkedit-preview.scss
  91. 16 7
      src/client/styles/scss/_mixins.scss
  92. 0 36
      src/client/styles/scss/_navbar_kibela.scss
  93. 20 41
      src/client/styles/scss/_on-edit.scss
  94. 13 0
      src/client/styles/scss/_page-presentation.scss
  95. 0 45
      src/client/styles/scss/_page.scss
  96. 40 0
      src/client/styles/scss/_page_accessaries_modal.scss
  97. 3 2
      src/client/styles/scss/_subnav.scss
  98. 49 0
      src/client/styles/scss/_toc.scss
  99. 5 0
      src/client/styles/scss/_variables.scss
  100. 3 4
      src/client/styles/scss/style-app.scss

+ 1 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:14
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-14
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update

+ 1 - 1
.devcontainer/docker-compose.yml

@@ -37,7 +37,7 @@ services:
       - hackmd
 
   mongo:
-    image: mongo:3.6
+    image: mongo:4.4
     restart: unless-stopped
     ports:
       - 27017:27017

+ 0 - 35
.github/workflows/prerelease.yml

@@ -1,35 +0,0 @@
-name: Pre Release
-
-on:
-  push:
-    branches:
-      - master
-
-jobs:
-  build-image-for-cache:
-
-    runs-on: ubuntu-latest
-
-    steps:
-    - uses: actions/checkout@v2
-
-    - name: Set up Docker Buildx
-      uses: crazy-max/ghaction-docker-buildx@v3
-
-    - name: Cache Docker layers
-      uses: actions/cache@v2
-      id: cache
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-default
-        restore-keys: |
-          ${{ runner.os }}-buildx-
-
-    - name: Build Docker Image
-      run: |
-        docker buildx build \
-          --cache-from "type=local,src=/tmp/.buildx-cache" \
-          --cache-to "type=local,dest=/tmp/.buildx-cache" \
-          --platform linux/amd64 \
-          --load \
-          --file ./docker/Dockerfile .

+ 0 - 12
.github/workflows/release.yml

@@ -78,21 +78,9 @@ jobs:
       run: |
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
-    - name: Cache Docker layers
-      uses: actions/cache@v2
-      id: cache
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-${{ matrix.flavor }}
-        restore-keys: |
-          ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-
-          ${{ runner.OS }}-buildx-
-
     - name: Build Docker Image
       run: |
         docker buildx build \
-          --cache-from "type=local,src=/tmp/.buildx-cache" \
-          --cache-to "type=local,dest=/tmp/.buildx-cache" \
           --tag growi${{ env.SUFFIX }} \
           --build-arg flavor=${{ matrix.flavor }} \
           --platform linux/amd64 \

+ 39 - 20
.gitignore

@@ -1,23 +1,29 @@
-# Logs
-logs
-*.log
-npm-debug.log.*
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 
-# OS generated files #
-.DS_Store
-.Trash-*
-ehthumbs.db
-Thumbs.db
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
 
-# Node Files #
-/node_modules/
-/bower_components/
-npm-debug.log
-/npm-debug.log.*
-package-lock.json
+# next.js
+/.next/
+/out/
 
-# Dist #
+# production
+/build
+
+# dist
+/dist/
 /report/
+/public/static/js
+/public/static/styles
+/public/uploads
+/tmp/
+
+# dist (for GROWI v4.x and below)
 /public/*.chunk.js
 /public/*.chunk.js.LICENSE
 /public/*.bundle.js
@@ -25,17 +31,30 @@ package-lock.json
 /public/dll
 /public/js
 /public/styles
-/public/uploads
 /src/*/__build__/
 /__build__/**
 /src/*/dist/
 /.awcache
 .webpack.json
 /compiled/
-/tmp/
 
-# Doc #
-/doc/
+# misc
+.DS_Store
+*.pem
+.Trash-*
+ehthumbs.db
+Thumbs.db
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
 
 # IDE, dev #
 .idea

+ 35 - 2
CHANGES.md

@@ -1,6 +1,39 @@
 # CHANGES
 
-## v4.1.3-RC
+## v4.2.0-RC
+
+### BREAKING CHANGES
+
+* GROWI v4.2.x no longer support Kibela layout
+    * Kibela theme is newly added and the configuration will migrate to it automatically
+
+### Updates
+
+* Improvement: Basic layout of page
+* Support: Support MongoDB 4.0, 4.2 and 4.4
+
+## v4.1.7
+
+* Improvement: Fire global notification when a new page is created by uploading file
+* Fix: Change default `DRAWIO_URI` to embed.diagrams.net
+* Fix: An unhandled rejection occures when a user who does not send referer accesses
+
+## v4.1.6
+
+* Improvement: Hide Fab at admin pages
+* Fix: Presentation does not work
+* Fix: Update GrantSelector status when uploading a file to a new page
+* Fix: CopyDropdown origin refs draw.io host wrongly
+
+## v4.1.5
+
+* Feature: Independent S3 configuration and SES configuration for AWS
+* Fix: Author name does not displayed in page history
+* Fix: Hide unnecessary component when pringing
+
+## v4.1.4 (Missing number)
+
+## v4.1.3
 
 * Feature: Create/edit linker with GUI
 * Improvement: Paging page histories
@@ -93,7 +126,7 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
 * Fix: Unable to create page with original path after emptying trash
 * I18n: Support zh-CN
 
-## v4.0.8  (Missing number)
+## v4.0.8 (Missing number)
 
 ## v4.0.7
 

+ 1 - 0
bin/github-actions/list-branches.js

@@ -17,6 +17,7 @@ const EXCLUDE_PATTERNS = [
   /^feat\/custom-sidebar-2$/,
   // https://regex101.com/r/Lnx7Pz/3
   /^dev\/[\d.x]*$/,
+  /^release\/.+$/,
 ];
 const LEGAL_PATTERNS = [
   /^master$/,

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.3-RC",
+  "version": "4.2.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

BIN
public/images/admin/customize/layout-classic-thumb.gif


BIN
public/images/admin/customize/layout-classic.gif


BIN
public/images/admin/customize/layout-crowi-plus-thumb.gif


BIN
public/images/admin/customize/layout-crowi-plus.gif


BIN
public/images/admin/customize/layout-kibela-thumb.gif


BIN
public/images/admin/customize/layout-kibela.gif


+ 13 - 19
resource/locales/en_US/admin/admin.json

@@ -27,20 +27,21 @@
     "attach_enable": "You can attach files other than image files if you enable this option.",
     "update": "Update",
     "mail_settings": "E-mail Settings",
-    "smtp_used": "If you have SMTP settings, it will be used.",
-    "smtp_but_aws": "If you do not have SMTP settings but AWS settings,  e-mails will be sent by SES.",
-    "neihter_of": "If neither is selected, then no email will be sent.",
+    "mailer_is_not_set_up": "E-mail setting is not set up.",
     "from_e-mail_address": "From e-mail address",
+    "transmission_method":"Transmission Method",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+    "send_test_email": "Send a test-email",
+    "success_to_send_test_email": "Success to send a test-email",
     "smtp_settings": "SMTP settings",
     "host": "Host",
     "port": "Port",
     "user": "User",
-    "initialize_mail_settings": "initialize e-mail settings",
-    "initialize_mail_modal_header": "Initialize e-mail settings",
-    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
+    "ses_settings":"SES settings",
+    "test_connection": "Test connection to mail",
     "aws_settings": "AWS settings",
     "aws_access": "This is for AWS settings. If you complete AWS settings, file upload function, profile picture function etc will be enabled.",
-    "no_smtp_setting": "If you do not have SMTP settings, e-mails will be sent via SES. You need to verify from e-mail address and production settings.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "bucket_name": "Bucket name",
@@ -89,19 +90,7 @@
     }
   },
   "customize_setting": {
-    "recommended": "Recommended",
-    "layout": "Layout",
     "theme": "Theme",
-    "layout_desc": {
-      "growi_title": "Simple and clear",
-      "growi_text1": "Full screen layout and thin margins/paddings",
-      "growi_text2": "Show and post comments at the bottom of the page",
-      "growi_text3": "Affix table-of-contents",
-      "kibela_title": "Easy viewing structure",
-      "kibela_text1": "Center aligned contents",
-      "kibela_text2": "Show and post comments at the bottom of the page",
-      "kibela_text3": "Affix Table-of-contents"
-    },
     "theme_desc": {
       "light_and_dark": "Light and dark modes",
       "unique": "Only one mode"
@@ -159,6 +148,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
+        "different_versions": "this growi and the uploarded data versions are not met",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -201,6 +191,10 @@
       "test_connection": "Test connection to qiita:team"
     },
     "import": "Import",
+    "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
+    "prepare_new_account_for_migration":"Prepare new account for migration",
+    "archive_data_import_detail":"More Details? Ckick here.",
+    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
     "page_skip": "Pages with a name that already exists on GROWI are not imported",
     "Directory_hierarchy_tag": "Directory hierarchy tag"
   },

+ 7 - 2
resource/locales/en_US/translation.json

@@ -47,6 +47,8 @@
   "List View": "List",
   "Timeline View": "Timeline",
   "History": "History",
+  "attachment_data": "Attachment Data",
+  "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "Presentation",
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "Create Archive Page",
@@ -118,6 +120,7 @@
   "Specified users only": "Specified users only",
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
+  "page_list": "Page List",
   "page_list_and_search_results": "Page list / Search results",
   "scope_of_page_disclosure": "Scope of page disclosure",
   "set_point": "Set point",
@@ -137,6 +140,7 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
+  "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "personal_dropdown": {
@@ -656,7 +660,7 @@
     "how_to": {
       "header": "How to configure Incoming Webhooks?",
       "workspace": "(At Workspace) Add a hook",
-      "workspace_desc1": "Go to <a href='https: //slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
+      "workspace_desc1": "Go to <a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
       "workspace_desc2": "Choose the default channel to post.",
       "workspace_desc3": "Add.",
       "at_growi": "(At GROWI admin page) Set Webhook URL",
@@ -754,6 +758,7 @@
   },
   "validation":{
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
-    "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required."
+    "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",
+    "failed_to_send_a_test_email":"Failed to send a test email using SMTP. Please check your settings."
   }
 }

+ 13 - 19
resource/locales/ja_JP/admin/admin.json

@@ -27,20 +27,21 @@
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "update": "更新",
     "mail_settings": "メールの設定",
-    "smtp_used": "SMTPの設定がされている場合、それが利用されます。",
-    "smtp_but_aws": "SMTP設定がなく、AWSの設定がある場合、SESでの送信を試みます。",
-    "neihter_of": "どちらの設定もない場合、メールは送信されません。",
+    "mailer_is_not_set_up": "メール設定がセットアップされていません。",
     "from_e-mail_address": "Fromアドレス",
+    "transmission_method":"送信方法",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+    "send_test_email": "テストメールを送信",
+    "success_to_send_test_email": "テストメールを送信しました。",
     "smtp_settings": "SMTP設定",
     "host": "ホスト",
     "port": "ポート",
     "user": "ユーザー",
-    "initialize_mail_settings": "設定を初期化",
-    "initialize_mail_modal_header": "メール設定の初期化",
-    "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
+    "ses_settings":"SES設定",
+    "test_connection": "接続テスト",
     "aws_settings": "AWS設定",
     "aws_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
-    "no_smtp_setting": "また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
     "bucket_name": "バケット名",
@@ -89,19 +90,7 @@
     }
   },
   "customize_setting": {
-    "recommended": "おすすめ",
-    "layout": "レイアウト",
     "theme": "テーマ",
-    "layout_desc": {
-      "growi_title": "シンプル・明瞭",
-      "growi_text1": "全画面レイアウトで、余白は少なくなります。",
-      "growi_text2": "コメントはページの下部に表示されます。",
-      "growi_text3": "ページ情報は下部に表示されます。",
-      "kibela_title": "閲覧重視の構造",
-      "kibela_text1": "コンテンツが中心に表示されます。",
-      "kibela_text2": "コメントはページの下部に表示されます。",
-      "kibela_text3": "ページ情報は下部に表示されます。"
-    },
     "theme_desc" : {
       "light_and_dark": "Light/Dark モード選択あり",
       "unique": "モード選択なし"
@@ -177,6 +166,7 @@
       "upload": "アップロード",
       "discard": "アップロードしたデータを破棄する",
       "errors": {
+        "different_versions": "現在のGROWIとアップロードしたデータのバージョンが違います",
         "at_least_one": "コレクションが選択されていません",
         "page_and_revision": "'Pages' と 'Revisions' はセットでインポートする必要があります",
         "depends": "'{{condition}}' をインポートする場合は、'{{target}}' を一緒に選択する必要があります"
@@ -219,6 +209,10 @@
       "test_connection": "接続テスト"
     },
     "import": "インポート",
+    "skip_username_and_email_when_overlapped": "ユーザー名またはメールアドレスが同じ場合、その部分がスキップされます。",
+    "prepare_new_account_for_migration":"移行用のアカウントを新環境で用意してください。",
+    "archive_data_import_detail":"参考: GROWI Docs - データのインポート",
+    "admin_archive_data_import_guide_url":"https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },

+ 7 - 2
resource/locales/ja_JP/translation.json

@@ -46,8 +46,10 @@
   "Example": "例",
   "Taro Yamada": "山田 太郎",
   "List View": "リスト表示",
-  "Timeline View": "タイムライン表示",
+  "Timeline View": "タイムライン",
   "History": "更新履歴",
+  "attachment_data": "添付データ",
+  "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Create Archive Page": "アーカイブページの作成",
@@ -118,6 +120,7 @@
   "Specified users": "特定ユーザーのみ",
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
+  "page_list": "ページリスト",
   "page_list_and_search_results": "ページリスト・検索結果",
   "scope_of_page_disclosure": "ページの公開範囲",
   "set_point": "設定値",
@@ -140,6 +143,7 @@
   "Color mode": "カラーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
+  "No bookmarks yet": "No bookmarks yet",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "personal_dropdown": {
@@ -747,6 +751,7 @@
   },
   "validation":{
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
-    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。"
+    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
+    "failed_to_send_a_test_email":"SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。"
   }
 }

+ 17 - 26
resource/locales/zh_CN/admin/admin.json

@@ -27,20 +27,22 @@
 		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
 		"update": "更新",
 		"mail_settings": "邮件设置",
-		"smtp_used": "如果您有SMTP设置,将使用它。",
-		"smtp_but_aws": "如果您没有SMTP设置,但有AWS设置,则电子邮件将由SES发送。",
-		"neihter_of": "如果两者都未选中,则不会发送电子邮件。",
-		"from_e-mail_address": "From e-mail address",
-		"smtp_settings": "SMTP 设置",
+    "mailer_is_not_set_up": "邮件设置尚未完成。",
+    "transmission_method":"传送方法",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+		"from_e-mail_address": "邮件发出地址",
+    "send_test_email": "发送测试邮件",
+    "success_to_send_test_email": "成功发送了一封测试邮件",
+    "smtp_settings": "SMTP 设置",
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
-    "initialize_mail_settings": "重置邮件设置",
-    "initialize_mail_modal_header": "重置邮件设置",
-    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
+    "ses_settings":"SES设置",
+    "test_connection": "测试邮件服务器连接",
 		"aws_settings": "AWS设置",
 		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
-		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
+		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
 		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
 		"region": "Region",
 		"bucket_name": "Bucket name",
@@ -89,23 +91,7 @@
 		}
 	},
 	"customize_setting": {
-		"recommended": "推荐",
-		"layout": "布局",
 		"theme": "主体",
-		"layout_desc": {
-			"growi_title": "简约",
-			"growi_text1": "全屏布局 窄边距/填充",
-			"growi_text2": "页面底部显示和发布评论",
-			"growi_text3": "附目录",
-			"kibela_title": "清晰",
-			"kibela_text1": "内容居中对齐",
-			"kibela_text2": "在页面底部显示和发布评论",
-			"kibela_text3": "附目录",
-			"crowi_title": "分栏",
-			"crowi_text1": "可折叠边栏",
-			"crowi_text2": "在侧边栏中显示和发布评论",
-			"crowi_text3": "可折叠目录"
-		},
 		"behavior": "行为",
 		"behavior_desc": {
 			"growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
@@ -174,6 +160,7 @@
 			"upload": "Upload",
 			"discard": "Discard uploaded data",
 			"errors": {
+        "versions_not_met": "this growi and the uploarded data versions are not met",
 				"at_least_one": "Select one or more collections.",
 				"page_and_revision": "'Pages' and 'Revisions' must be imported both.",
 				"depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -215,7 +202,11 @@
 			"access_token": "Access token",
 			"test_connection": "Test connection to qiita:team"
 		},
-		"import": "Import",
+    "import": "Import",
+    "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
+    "prepare_new_account_for_migration":"Prepare new account for migration",
+    "archive_data_import_detail":"More details? Click here.",
+    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
 		"page_skip": "Pages with a name that already exists on GROWI are not imported",
 		"Directory_hierarchy_tag": "Directory hierarchy tag"
 	},

+ 23 - 18
resource/locales/zh_CN/translation.json

@@ -48,18 +48,20 @@
 	"Taro Yamada": "John Doe",
 	"List View": "列表",
 	"Timeline View": "时间线",
-	"History": "历史",
+  "History": "历史",
+  "attachment_data": "Attachment Data",
+  "No_attachments_yet": "暂无附件",
 	"Presentation Mode": "演示文稿",
   "Not available for guest": "Not available for guest",
-  "Create Archive Page": "Create Archive Page",
-  "File type": "File type",
-  "Target page": "Target page",
-  "Include Attachment File": "Include Attachment File",
-  "Include Comment": "Include Comment",
-  "Include Subordinated Page": "Include Subordinated Page",
-  "All Subordinated Page": "All Subordinated Page",
-  "Specify Hierarchy": "Specify Hierarchy",
-  "Submitted the request to create the archive": "Submitted the request to create the archive",
+  "Create Archive Page": "创建归档页",
+  "File type": "文件类型",
+  "Target page": "目标页面",
+  "Include Attachment File": "包含附件",
+  "Include Comment": "包含评论",
+  "Include Subordinated Page": "包括子页面",
+  "All Subordinated Page": "所有子页面",
+  "Specify Hierarchy": "指定层级",
+  "Submitted the request to create the archive": "提交创建归档请求",
   "username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
@@ -125,7 +127,8 @@
 	"Anyone with the link": "任何人",
 	"Specified users only": "仅指定用户",
 	"Only me": "只有我",
-	"Only inside the group": "仅组内",
+  "Only inside the group": "仅组内",
+  "page_list": "Page List",
 	"page_list_and_search_results": "页面列表/搜索结果",
 	"scope_of_page_disclosure": "页面公开范围",
 	"set_point": "设定值",
@@ -144,7 +147,8 @@
 	"List Drafts": "草稿",
 	"Deleted Pages": "已删除页",
 	"Sign out": "退出",
-	"Disassociate": "解除关联",
+  "Disassociate": "解除关联",
+  "No bookmarks yet": "暂无书签",
 	"Recent Created": "最新创建",
 	"Recent Changes": "最新修改",
 	"form_validation": {
@@ -643,7 +647,7 @@
 		"how_to": {
 			"header": "How to configure Incoming Webhooks?",
 			"workspace": "(At Workspace) Add a hook",
-			"workspace_desc1": "Go to <a href='https: //slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
+			"workspace_desc1": "Go to <a href='https://slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
 			"workspace_desc2": "Choose the default channel to post.",
 			"workspace_desc3": "Add.",
 			"at_growi": "(At GROWI admin page) Set Webhook URL",
@@ -727,10 +731,10 @@
 		"Setup": "安装程序"
 	},
   "export_bulk": {
-    "failed_to_export": "Failed to export",
-    "failed_to_count_pages": "Failed to count pages",
-    "export_page_markdown": "Export page as Markdown",
-    "export_page_pdf": "Export page as PDF"
+    "failed_to_export": "导出失败",
+    "failed_to_count_pages": "页面计数失败",
+    "export_page_markdown": "以Markdown格式导出页面",
+    "export_page_pdf": "以PDF格式导出页面"
   },
 	"message": {
 		"successfully_connected": "连接成功!",
@@ -758,6 +762,7 @@
   },
   "validation":{
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
-    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”"
+    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
+    "failed_to_send_a_test_email":"SMTP方式测试邮件发送失败,请检查相关设定。"
   }
 }

+ 5 - 31
src/client/js/app.jsx

@@ -8,26 +8,21 @@ import loggerFactory from '@alias/logger';
 import ErrorBoundary from './components/ErrorBoudary';
 import SearchPage from './components/SearchPage';
 import TagsList from './components/TagsList';
-import PageEditor from './components/PageEditor';
-import PagePathNavForEditor from './components/PageEditor/PagePathNavForEditor';
-import EditorNavbarBottom from './components/PageEditor/EditorNavbarBottom';
+import DisplaySwitcher from './components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
-import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
-import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
 import PageTimeline from './components/PageTimeline';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageManagement from './components/Page/PageManagement';
-import PageShareManagement from './components/Page/PageShareManagement';
 import TrashPageAlert from './components/Page/TrashPageAlert';
-import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import SeenUserList from './components/User/SeenUserList';
 import LikerList from './components/User/LikerList';
 import TableOfContents from './components/TableOfContents';
+import Fab from './components/Fab';
 
 import PersonalSettings from './components/Me/PersonalSettings';
 import NavigationContainer from './services/NavigationContainer';
@@ -81,6 +76,8 @@ Object.assign(componentMappings, {
   'page-timeline': <PageTimeline />,
 
   'personal-setting': <PersonalSettings crowi={personalContainer} />,
+
+  'grw-fab-container': <Fab />,
 });
 
 // additional definitions if data exists
@@ -88,10 +85,7 @@ if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
     'page-comments-list': <PageComments />,
     'page-comment-write': <CommentEditorLazyRenderer />,
-    'page-attachment': <PageAttachment />,
     'page-management': <PageManagement />,
-    'page-share-management': <PageShareManagement />,
-
     'revision-toc': <TableOfContents />,
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
@@ -111,15 +105,8 @@ if (pageContainer.state.path != null) {
 // additional definitions if user is logged in
 if (appContainer.currentUser != null) {
   Object.assign(componentMappings, {
-    'page-editor': <PageEditor />,
-    'page-editor-path-nav': <PagePathNavForEditor />,
-    'page-editor-navbar-bottom-container': <EditorNavbarBottom />,
+    'display-switcher': <DisplaySwitcher />,
   });
-  if (pageContainer.state.pageId != null) {
-    Object.assign(componentMappings, {
-      'page-editor-with-hackmd': <PageEditorByHackmd />,
-    });
-  }
 }
 
 Object.keys(componentMappings).forEach((key) => {
@@ -138,18 +125,5 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
-// うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
-$('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <ErrorBoundary>
-        <Provider inject={injectableContainers}>
-          <PageHistory />
-        </Provider>
-      </ErrorBoundary>
-    </I18nextProvider>, document.getElementById('revision-history'),
-  );
-});
-
 // initialize scrollpos-styler
 ScrollPosStyler.init();

+ 0 - 2
src/client/js/base.jsx

@@ -8,7 +8,6 @@ import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
 import ShareLinkAlert from './components/Page/ShareLinkAlert';
 import HotkeysManager from './components/Hotkeys/HotkeysManager';
-import Fab from './components/Fab';
 
 import AppContainer from './services/AppContainer';
 import SocketIoContainer from './services/SocketIoContainer';
@@ -46,7 +45,6 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'grw-fab-container': <Fab />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
   'share-link-alert': <ShareLinkAlert />,

+ 0 - 3
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -41,9 +41,6 @@ class AwsSetting extends React.Component {
         <p className="card well">
           {t('admin:app_setting.aws_access')}
           <br />
-          {t('admin:app_setting.no_smtp_setting')}
-          <br />
-          <br />
           <span className="text-danger">
             <i className="ti-unlink"></i>
             {t('admin:app_setting.change_setting')}

+ 70 - 169
src/client/js/components/Admin/App/MailSetting.jsx

@@ -1,204 +1,105 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '@alias/logger';
 
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
+import SmtpSetting from './SmtpSetting';
+import SesSetting from './SesSetting';
 
-const logger = loggerFactory('growi:appSettings');
-
-class MailSetting extends React.Component {
 
-  constructor(props) {
-    super(props);
+function MailSetting(props) {
+  const { t, adminAppContainer } = props;
 
-    this.state = {
-      isInitializeValueModalOpen: false,
-    };
+  const transmissionMethods = ['smtp', 'ses'];
 
-    this.emailInput = React.createRef();
-    this.hostInput = React.createRef();
-    this.portInput = React.createRef();
-    this.userInput = React.createRef();
-    this.passwordInput = React.createRef();
-
-    this.openInitializeValueModal = this.openInitializeValueModal.bind(this);
-    this.closeInitializeValueModal = this.closeInitializeValueModal.bind(this);
-    this.submitFromAdressHandler = this.submitFromAdressHandler.bind(this);
-    this.submitHandler = this.submitHandler.bind(this);
-    this.initialize = this.initialize.bind(this);
-  }
-
-  openInitializeValueModal() {
-    this.setState({ isInitializeValueModalOpen: true });
-  }
-
-  closeInitializeValueModal() {
-    this.setState({ isInitializeValueModalOpen: false });
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
+  async function submitHandler() {
+    const { t } = props;
 
     try {
       await adminAppContainer.updateMailSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.ses_settings') }));
     }
     catch (err) {
       toastError(err);
-      logger.error(err);
     }
   }
 
-  async submitFromAdressHandler() {
-    const { t, adminAppContainer } = this.props;
-
+  async function sendTestEmailHandler() {
+    const { adminAppContainer } = props;
     try {
-      await adminAppContainer.updateFromAdressHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+      await adminAppContainer.sendTestEmail();
+      toastSuccess(t('admin:app_setting.success_to_send_test_email'));
     }
     catch (err) {
       toastError(err);
-      logger.error(err);
     }
   }
 
-  async initialize() {
-    const { t, adminAppContainer } = this.props;
 
-    try {
-      const mailSettingParams = await adminAppContainer.initializeMailSettingHandler();
-      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.smtp_settings') }));
-      // convert values to '' if value is null for overwriting values of inputs with refs
-      this.hostInput.current.value = mailSettingParams.smtpHost || '';
-      this.portInput.current.value = mailSettingParams.smtpPort || '';
-      this.userInput.current.value = mailSettingParams.smtpUser || '';
-      this.passwordInput.current.value = mailSettingParams.smtpPassword || '';
-      this.closeInitializeValueModal();
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <p className="card well">{t('admin:app_setting.smtp_used')} {t('admin:app_setting.smtp_but_aws')}<br />{t('admin:app_setting.neihter_of')}</p>
-        <div className="row form-group mb-5">
-          <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.from_e-mail_address')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              ref={this.emailInput}
-              placeholder={`${t('eg')} mail@growi.org`}
-              defaultValue={adminAppContainer.state.fromAddress || ''}
-              onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
-            />
-          </div>
+  return (
+    <React.Fragment>
+      {!adminAppContainer.state.isMailerSetup && (
+        <div className="alert alert-danger"><i className="icon-exclamation"></i> {t('admin:app_setting.mailer_is_not_set_up')}</div>
+      )}
+      <div className="row form-group mb-5">
+        <label className="col-md-3 col-form-label text-right">{t('admin:app_setting.from_e-mail_address')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            placeholder={`${t('eg')} mail@growi.org`}
+            defaultValue={adminAppContainer.state.fromAddress || ''}
+            onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
+          />
         </div>
-        <div className="row my-3">
-          <div className="mx-auto">
-            <button type="button" className="btn btn-primary" onClick={this.submitFromAdressHandler}>{ t('Update') }</button>
-          </div>
+      </div>
+
+      <div className="row form-group mb-5">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.transmission_method')}
+        </label>
+        <div className="col-md-6">
+          {transmissionMethods.map((method) => {
+              return (
+                <div key={method} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    name="transmission-method"
+                    id={`transmission-nethod-radio-${method}`}
+                    checked={adminAppContainer.state.transmissionMethod === method}
+                    onChange={(e) => {
+                    adminAppContainer.changeTransmissionMethod(method);
+                  }}
+                  />
+                  <label className="custom-control-label" htmlFor={`transmission-nethod-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
+                </div>
+              );
+            })}
         </div>
-        <div id="mail-smtp" className="tab-pane active mt-5">
-          <div className="row form-group mb-5">
-            <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.smtp_settings')}</label>
-            <div className="col-md-4">
-              <label>{t('admin:app_setting.host')}</label>
-              <input
-                className="form-control"
-                type="text"
-                ref={this.hostInput}
-                defaultValue={adminAppContainer.state.smtpHost || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
-              />
-            </div>
-            <div className="col-md-2">
-              <label>{t('admin:app_setting.port')}</label>
-              <input
-                className="form-control"
-                ref={this.portInput}
-                defaultValue={adminAppContainer.state.smtpPort || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
-              />
-            </div>
-          </div>
-
-          <div className="row form-group mb-5">
-            <div className="col-md-3 offset-md-3">
-              <label>{t('admin:app_setting.user')}</label>
-              <input
-                className="form-control"
-                type="text"
-                ref={this.userInput}
-                defaultValue={adminAppContainer.state.smtpUser || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
-              />
-            </div>
-            <div className="col-md-3">
-              <label>{t('Password')}</label>
-              <input
-                className="form-control"
-                type="password"
-                ref={this.passwordInput}
-                defaultValue={adminAppContainer.state.smtpPassword || ''}
-                onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
-              />
-            </div>
-          </div>
-
-          <div className="row my-3">
-            <div className="offset-5">
-              <button type="button" className="btn btn-primary" onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
-                { t('Update') }
-              </button>
-            </div>
-            <div className="offset-1">
-              <button
-                type="button"
-                className="btn btn-secondary"
-                onClick={this.openInitializeValueModal}
-                disabled={adminAppContainer.state.retrieveError != null}
-              >
-                {t('admin:app_setting.initialize_mail_settings')}
-              </button>
-            </div>
-          </div>
+      </div>
+
+      {adminAppContainer.state.transmissionMethod === 'smtp' && <SmtpSetting />}
+      {adminAppContainer.state.transmissionMethod === 'ses' && <SesSetting />}
+
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-primary" onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+            { t('Update') }
+          </button>
+          {adminAppContainer.state.transmissionMethod === 'smtp' && (
+          <button type="button" className="btn btn-secondary ml-4" onClick={sendTestEmailHandler}>
+            {t('admin:app_setting.send_test_email')}
+          </button>
+          )}
         </div>
-
-
-        <Modal isOpen={this.state.isInitializeValueModalOpen} toggle={this.closeInitializeValueModal} className="initialize-mail-settings">
-          <ModalHeader tag="h4" toggle={this.closeInitializeValueModal} className="bg-danger text-light">
-            {t('admin:app_setting.initialize_mail_modal_header')}
-          </ModalHeader>
-          <ModalBody>
-            <div className="text-center mb-4">
-              {t('admin:app_setting.confirm_to_initialize_mail_settings')}
-            </div>
-            <div className="text-center my-2">
-              <button type="button" className="btn btn-outline-secondary mr-4" onClick={this.closeInitializeValueModal}>
-                {t('Cancel')}
-              </button>
-              <button type="button" className="btn btn-danger" onClick={this.initialize}>
-                {t('Reset')}
-              </button>
-            </div>
-          </ModalBody>
-        </Modal>
-      </React.Fragment>
-    );
-  }
+      </div>
+    </React.Fragment>
+  );
 
 }
 

+ 68 - 0
src/client/js/components/Admin/App/SesSetting.jsx

@@ -0,0 +1,68 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+function SmtpSetting(props) {
+  const { adminAppContainer } = props;
+
+  return (
+    <React.Fragment>
+      <div id="mail-smtp" className="tab-pane active mt-5">
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            Access key ID
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.sesAccessKeyId || ''}
+              onChange={(e) => {
+                adminAppContainer.changeSesAccessKeyId(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            Secret access key
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.sesSecretAccessKey || ''}
+              onChange={(e) => {
+                adminAppContainer.changeSesSecretAccessKey(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+      </div>
+
+    </React.Fragment>
+  );
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
+
+SmtpSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SmtpSettingWrapper);

+ 89 - 0
src/client/js/components/Admin/App/SmtpSetting.jsx

@@ -0,0 +1,89 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+function SmtpSetting(props) {
+  const { adminAppContainer, t } = props;
+
+  return (
+    <React.Fragment>
+      <div id="mail-smtp" className="tab-pane active mt-5">
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.host')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpHost || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.port')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              defaultValue={adminAppContainer.state.smtpPort || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.user')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpUser || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('Password')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="password"
+              defaultValue={adminAppContainer.state.smtpPassword || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+            />
+          </div>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
+
+SmtpSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SmtpSettingWrapper);

+ 1 - 1
src/client/js/components/Admin/Customize/Customize.jsx

@@ -10,7 +10,7 @@ import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
 import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeLayoutSetting from './CustomizeThemeSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
 import CustomizeCssSetting from './CustomizeCssSetting';

+ 0 - 17
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -59,23 +59,6 @@ class CustomizeFunctionSetting extends React.Component {
             </Card>
 
 
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isEnabledTimeline"
-                  label={t('admin:customize_setting.function_options.timeline')}
-                  isChecked={adminCustomizeContainer.state.isEnabledTimeline}
-                  onChecked={() => { adminCustomizeContainer.switchEnableTimeline() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.timeline_desc1')}<br />
-                    {t('admin:customize_setting.function_options.timeline_desc2')}<br />
-                    {t('admin:customize_setting.function_options.timeline_desc3')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-
             <div className="form-group row">
               <div className="offset-md-3 col-md-6 text-left">
                 <CustomizeFunctionOption

+ 0 - 48
src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx

@@ -1,48 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-class CustomizeLayoutOption extends React.Component {
-
-  render() {
-    const { layoutType } = this.props;
-
-    return (
-      <React.Fragment>
-        <h4>
-          <div className="custom-control custom-radio">
-            <input
-              type="radio"
-              className="custom-control-input"
-              id={`radio-layout-${layoutType}`}
-              checked={this.props.isSelected}
-              onChange={this.props.onSelected}
-            />
-            <label className="custom-control-label" htmlFor={`radio-layout-${layoutType}`}>
-              {/* eslint-disable-next-line react/no-danger */}
-              <span dangerouslySetInnerHTML={{ __html: this.props.labelHtml }} />
-            </label>
-          </div>
-        </h4>
-        <a href={`/images/admin/customize/layout-${layoutType}.gif`} className="ss-container">
-          <img src={`/images/admin/customize/layout-${layoutType}-thumb.gif`} width="240px" />
-        </a>
-        {/* render layout description */}
-        {this.props.children}
-      </React.Fragment>
-    );
-  }
-
-}
-
-CustomizeLayoutOption.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  layoutType: PropTypes.string.isRequired,
-  labelHtml: PropTypes.string.isRequired,
-  isSelected: PropTypes.bool.isRequired,
-  onSelected: PropTypes.func.isRequired,
-  children: PropTypes.array.isRequired,
-};
-
-export default withTranslation()(CustomizeLayoutOption);

+ 0 - 67
src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx

@@ -1,67 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
-import AppContainer from '../../../services/AppContainer';
-
-import CustomizeLayoutOption from './CustomizeLayoutOption';
-
-class CustomizeLayoutOptions extends React.Component {
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    return (
-      <div className="row row-cols-1 row-cols-md-2">
-        <div className="col text-center">
-          <CustomizeLayoutOption
-            layoutType="crowi-plus"
-            isSelected={adminCustomizeContainer.state.currentLayout === 'growi'}
-            onSelected={() => adminCustomizeContainer.switchLayoutType('growi')}
-            labelHtml={`GROWI enhanced layout <small class="text-success">${t('admin:customize_setting.recommended')}</small>`}
-          >
-            <h4>{t('admin:customize_setting.layout_desc.growi_title')}</h4>
-            <div className="text-justify d-inline-block">
-              <ul>
-                <li>{t('admin:customize_setting.layout_desc.growi_text1')}</li>
-                <li>{t('admin:customize_setting.layout_desc.growi_text2')}</li>
-                <li>{t('admin:customize_setting.layout_desc.growi_text3')}</li>
-              </ul>
-            </div>
-          </CustomizeLayoutOption>
-        </div>
-
-        <div className="col text-center">
-          <CustomizeLayoutOption
-            layoutType="kibela"
-            isSelected={adminCustomizeContainer.state.currentLayout === 'kibela'}
-            onSelected={() => adminCustomizeContainer.switchLayoutType('kibela')}
-            labelHtml="Kibela like layout"
-          >
-            <h4>{t('admin:customize_setting.layout_desc.kibela_title')}</h4>
-            <div className="text-justify d-inline-block">
-              <ul>
-                <li>{t('admin:customize_setting.layout_desc.kibela_text1')}</li>
-                <li>{t('admin:customize_setting.layout_desc.kibela_text2')}</li>
-                <li>{t('admin:customize_setting.layout_desc.kibela_text3')}</li>
-              </ul>
-            </div>
-          </CustomizeLayoutOption>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-const CustomizeLayoutOptionsWrapper = withUnstatedContainers(CustomizeLayoutOptions, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeLayoutOptions.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeLayoutOptionsWrapper);

+ 2 - 0
src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -38,6 +38,8 @@ class CustomizeThemeOptions extends React.Component {
       name: 'future',     bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
     }, {
       name: 'halloween',  bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
+    }, {
+      name: 'kibela',  bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
     }];
     /* eslint-enable no-multi-spaces */
 

+ 6 - 13
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx → src/client/js/components/Admin/Customize/CustomizeThemeSetting.jsx

@@ -7,12 +7,11 @@ import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
 
-import CustomizeLayoutOptions from './CustomizeLayoutOptions';
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-class CustomizeLayoutSetting extends React.Component {
+class CustomizeThemeSetting extends React.Component {
 
   constructor(props) {
     super(props);
@@ -24,8 +23,8 @@ class CustomizeLayoutSetting extends React.Component {
     const { t, adminCustomizeContainer } = this.props;
 
     try {
-      await adminCustomizeContainer.updateCustomizeLayoutAndTheme();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.layout') }));
+      await adminCustomizeContainer.updateCustomizeTheme();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
     }
     catch (err) {
       toastError(err);
@@ -48,12 +47,6 @@ class CustomizeLayoutSetting extends React.Component {
 
     return (
       <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.layout')}</h2>
-            <CustomizeLayoutOptions />
-          </div>
-        </div>
         <div className="row">
           <div className="col-12">
             <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
@@ -68,12 +61,12 @@ class CustomizeLayoutSetting extends React.Component {
 
 }
 
-const CustomizeLayoutSettingWrapper = withUnstatedContainers(CustomizeLayoutSetting, [AppContainer, AdminCustomizeContainer]);
+const CustomizeThemeSettingWrapper = withUnstatedContainers(CustomizeThemeSetting, [AppContainer, AdminCustomizeContainer]);
 
-CustomizeLayoutSetting.propTypes = {
+CustomizeThemeSetting.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 
-export default withTranslation()(CustomizeLayoutSettingWrapper);
+export default withTranslation()(CustomizeThemeSettingWrapper);

+ 10 - 1
src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -58,7 +58,16 @@ class RebuildIndexControls extends React.Component {
       return null;
     }
 
-    const header = isRebuildingCompleted ? 'Completed' : `Processing.. (${skip} skips)`;
+    function getCompletedLabel() {
+      const completedLabel = skip === 0 ? 'Completed' : `Done (${skip} skips)`;
+      return completedLabel;
+    }
+
+    function getSkipLabel() {
+      return `Processing.. (${skip} skips)`;
+    }
+
+    const header = isRebuildingCompleted ? getCompletedLabel() : getSkipLabel();
 
     return (
       <ProgressBar

+ 18 - 4
src/client/js/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../../UnstatedUtils';
 import AppContainer from '../../../../services/AppContainer';
-// import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastError } from '../../../../util/apiNotification';
 
 class UploadForm extends React.Component {
 
@@ -31,9 +31,21 @@ class UploadForm extends React.Component {
     formData.append('_csrf', this.props.appContainer.csrfToken);
     formData.append('file', this.inputRef.current.files[0]);
 
-    const { data } = await this.props.appContainer.apiv3Post('/import/upload', formData);
-    this.props.onUpload(data);
-    // TODO: toastSuccess, toastError
+    try {
+      const { data } = await this.props.appContainer.apiv3Post('/import/upload', formData);
+      // TODO: toastSuccess, toastError
+      this.props.onUpload(data);
+    }
+    catch (err) {
+      if (err[0].code === 'versions-are-not-met') {
+        if (this.props.onVersionMismatch !== null) {
+          this.props.onVersionMismatch(err[0].code);
+        }
+      }
+      else {
+        toastError(err);
+      }
+    }
   }
 
   validateForm() {
@@ -83,6 +95,8 @@ UploadForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   onUpload: PropTypes.func.isRequired,
+  isTheSameVersion: PropTypes.bool,
+  onVersionMismatch: PropTypes.func,
 };
 
 /**

+ 50 - 8
src/client/js/components/Admin/ImportData/GrowiArchiveSection.jsx

@@ -18,6 +18,7 @@ class GrowiArchiveSection extends React.Component {
     this.initialState = {
       fileName: null,
       innerFileStats: null,
+      isTheSameVersion: null,
     };
 
     this.state = this.initialState;
@@ -25,6 +26,8 @@ class GrowiArchiveSection extends React.Component {
     this.handleUpload = this.handleUpload.bind(this);
     this.discardData = this.discardData.bind(this);
     this.resetState = this.resetState.bind(this);
+    this.handleMismatchedVersions = this.handleMismatchedVersions.bind(this);
+    this.renderDefferentVersionAlert = this.renderDefferentVersionAlert.bind(this);
   }
 
   async componentWillMount() {
@@ -33,14 +36,19 @@ class GrowiArchiveSection extends React.Component {
 
     if (res.data.zipFileStat != null) {
       const { fileName, innerFileStats } = res.data.zipFileStat;
-      this.setState({ fileName, innerFileStats });
+      const { isTheSameVersion } = res.data;
+
+      this.setState({ fileName, innerFileStats, isTheSameVersion });
     }
   }
 
-  handleUpload({ meta, fileName, innerFileStats }) {
+  handleUpload({
+    meta, fileName, innerFileStats,
+  }) {
     this.setState({
       fileName,
       innerFileStats,
+      isTheSameVersion: true,
     });
   }
 
@@ -74,18 +82,51 @@ class GrowiArchiveSection extends React.Component {
     }
   }
 
+
+  handleMismatchedVersions(err) {
+    this.setState({
+      isTheSameVersion: false,
+    });
+
+  }
+
+  renderDefferentVersionAlert() {
+    const { t } = this.props;
+    return (
+      <div className="alert alert-warning mt-3">
+        {t('admin:importer_management.growi_settings.errors.different_versions')}
+      </div>
+    );
+  }
+
   resetState() {
     this.setState(this.initialState);
   }
 
   render() {
     const { t } = this.props;
+    const { isTheSameVersion } = this.state;
 
     return (
       <Fragment>
         <h2>{t('admin:importer_management.import_growi_archive')}</h2>
-
-        {this.state.fileName != null ? (
+        <div className="card well mb-4 small">
+          <ul>
+            <li>{t('admin:importer_management.skip_username_and_email_when_overlapped')}</li>
+            <li>{t('admin:importer_management.prepare_new_account_for_migration')}</li>
+            <li>
+              <a
+                href={`${t('admin:importer_management.admin_archive_data_import_guide_url')}`}
+                target="_blank"
+                rel="noopener noreferrer"
+              >{t('admin:importer_management.archive_data_import_detail')}
+              </a>
+            </li>
+          </ul>
+        </div>
+
+        {isTheSameVersion === false && this.renderDefferentVersionAlert()}
+        {this.state.fileName != null && isTheSameVersion === true ? (
           <div className="px-4">
             <ImportForm
               fileName={this.state.fileName}
@@ -94,10 +135,11 @@ class GrowiArchiveSection extends React.Component {
             />
           </div>
         )
-          : (
-            <UploadForm
-              onUpload={this.handleUpload}
-            />
+        : (
+          <UploadForm
+            onUpload={this.handleUpload}
+            onVersionMismatch={this.handleMismatchedVersions}
+          />
           )}
       </Fragment>
     );

+ 9 - 8
src/client/js/components/Admin/ManageExternalAccount.jsx

@@ -36,14 +36,15 @@ class ManageExternalAccount extends React.Component {
     const { t, adminExternalAccountsContainer } = this.props;
 
     const pager = (
-      <div className="pull-right">
-        <PaginationWrapper
-          activePage={adminExternalAccountsContainer.state.activePage}
-          changePage={this.handleExternalAccountPage}
-          totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
-          pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
-        />
-      </div>
+
+      <PaginationWrapper
+        activePage={adminExternalAccountsContainer.state.activePage}
+        changePage={this.handleExternalAccountPage}
+        totalItemsCount={adminExternalAccountsContainer.state.totalAccounts}
+        pagingLimit={adminExternalAccountsContainer.state.pagingLimit}
+        align="right"
+      />
+
     );
     return (
       <Fragment>

+ 34 - 29
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -11,7 +11,7 @@ import AppContainer from '../../../services/AppContainer';
 import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
-import ShareLinkList from '../../ShareLinkList';
+import ShareLinkList from '../../ShareLink/ShareLinkList';
 
 class ShareLinkSetting extends React.Component {
 
@@ -66,6 +66,7 @@ class ShareLinkSetting extends React.Component {
 
   async deleteLinkById(shareLinkId) {
     const { t, appContainer, adminGeneralSecurityContainer } = this.props;
+    const { shareLinksActivePage } = adminGeneralSecurityContainer.state;
 
     try {
       const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
@@ -76,53 +77,57 @@ class ShareLinkSetting extends React.Component {
       toastError(err);
     }
 
-    this.getShareLinkList(adminGeneralSecurityContainer.state.shareLinksActivePage);
+    this.getShareLinkList(shareLinksActivePage);
   }
 
 
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
-
-    const pager = (
-      <div className="pull-right my-3">
+    const {
+      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+    } = adminGeneralSecurityContainer.state;
+
+    function pager() {
+      if (shareLinks.length === 0) {
+        return null;
+      }
+      return (
         <PaginationWrapper
-          activePage={adminGeneralSecurityContainer.state.shareLinksActivePage}
+          activePage={shareLinksActivePage}
           changePage={this.getShareLinkList}
-          totalItemsCount={adminGeneralSecurityContainer.state.totalshareLinks}
-          pagingLimit={adminGeneralSecurityContainer.state.shareLinksPagingLimit}
+          totalItemsCount={totalshareLinks}
+          pagingLimit={shareLinksPagingLimit}
+          align="right"
         />
-      </div>
-    );
+      );
+    }
 
-    const deleteAllButton = (
-      adminGeneralSecurityContainer.state.shareLinks.length > 0
-        ? (
+    return (
+      <Fragment>
+        <div className="mb-3">
           <button
             className="pull-right btn btn-danger"
+            disabled={shareLinks.length === 0}
             type="button"
             onClick={this.showDeleteConfirmModal}
           >
             {t('share_links.delete_all_share_links')}
           </button>
-        )
-        : (
-          <p className="pull-right mr-2">{t('share_links.No_share_links')}</p>
-        )
-    );
-
-    return (
-      <Fragment>
-        <div className="mb-3">
-          {deleteAllButton}
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
-
         {pager}
-        <ShareLinkList
-          shareLinks={adminGeneralSecurityContainer.state.shareLinks}
-          onClickDeleteButton={this.deleteLinkById}
-          isAdmin
-        />
+
+        {(shareLinks.length !== 0) ? (
+          <ShareLinkList
+            shareLinks={shareLinks}
+            onClickDeleteButton={this.deleteLinkById}
+            isAdmin
+          />
+          )
+          : (<p className="text-center">{t('share_links.No_share_links')}</p>
+          )
+        }
+
 
         <DeleteAllShareLinksModal
           isOpen={this.state.isDeleteConfirmModalShown}

+ 2 - 1
src/client/js/components/Admin/UserManagement.jsx

@@ -114,12 +114,13 @@ class UserManagement extends React.Component {
     const { t, adminUsersContainer } = this.props;
 
     const pager = (
-      <div className="pull-right my-3">
+      <div className="my-3">
         <PaginationWrapper
           activePage={adminUsersContainer.state.activePage}
           changePage={this.handlePage}
           totalItemsCount={adminUsersContainer.state.totalUsers}
           pagingLimit={adminUsersContainer.state.pagingLimit}
+          align="right"
         />
       </div>
     );

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

@@ -40,7 +40,7 @@ class BookmarkButton extends React.Component {
         <button
           type="button"
           onClick={this.handleClick}
-          className={`btn rounded-circle btn-bookmark border-0 d-edit-none
+          className={`btn rounded-circle btn-bookmark border-0
           ${`btn-${this.props.size}`}
           ${pageContainer.state.isBookmarked ? 'active' : ''}`}
         >

+ 21 - 11
src/client/js/components/Fab.jsx

@@ -4,6 +4,7 @@ import loggerFactory from '@alias/logger';
 
 import StickyEvents from 'sticky-events';
 
+import AppContainer from '../services/AppContainer';
 import NavigationContainer from '../services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
@@ -12,7 +13,8 @@ import ReturnTopIcon from './Icons/ReturnTopIcon';
 const logger = loggerFactory('growi:cli:Fab');
 
 const Fab = (props) => {
-  const { navigationContainer } = props;
+  const { navigationContainer, appContainer } = props;
+  const { currentUser } = appContainer;
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
 
@@ -39,18 +41,25 @@ const Fab = (props) => {
     };
   }, [stickyChangeHandler]);
 
+  function renderPageCreateButton() {
+    return (
+      <>
+        <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+          <button
+            type="button"
+            className="btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light"
+            onClick={navigationContainer.openPageCreateModal}
+          >
+            <CreatePageIcon />
+          </button>
+        </div>
+      </>
+    );
+  }
 
   return (
     <div className="grw-fab d-none d-md-block">
-      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
-        <button
-          type="button"
-          className="btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light"
-          onClick={navigationContainer.openPageCreateModal}
-        >
-          <CreatePageIcon />
-        </button>
-      </div>
+      {currentUser != null && renderPageCreateButton()}
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button type="button" className="btn btn-light btn-scroll-to-top rounded-circle p-0" onClick={() => navigationContainer.smoothScrollIntoView()}>
           <ReturnTopIcon />
@@ -62,7 +71,8 @@ const Fab = (props) => {
 };
 
 Fab.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
-export default withUnstatedContainers(Fab, [NavigationContainer]);
+export default withUnstatedContainers(Fab, [AppContainer, NavigationContainer]);

+ 31 - 0
src/client/js/components/FootstampIcon.jsx

@@ -0,0 +1,31 @@
+import React from 'react';
+
+const FootstampIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="16"
+    height="16"
+    viewBox="0 0 16 16"
+  >
+    <path d="M7.34,8,3.31,9a1.83,1.83,0,0,1-1.24-.08A1.28,1.28,0,0,1,1.34,8a3.24,3.24,0,0,1,.2-1.82A6.06,6.06,0,0,1,2.6,4.35h0a2.56,
+    2.56,0,0,1,3.34-.77A5.65,5.65,0,0,1,7.69,4.73a3.23,3.23,0,0,1,1,1.53A1.29,1.29,0,0,1,8.42,7.4,1.86,1.86,0,0,1,7.34,8Zm-3-3.82a2.17,2.17,0,0,
+    0-1.05.74h0a4.75,4.75,0,0,0-.89,1.52,2.37,2.37,0,0,0-.17,1.3.38.38,0,0,0,.23.31,1,1,0,0,0,.65,
+      0l4-.94a1,1,0,0,0,.58-.3.39.39,0,0,0,.07-.38,2.32,2.32,0,0,0-.73-1.08,4.7,4.7,0,0,0-1.47-1A2.07,2.07,0,0,0,4.33,4.2Z"
+    />
+    <path d="M7.26,1.39a.57.57,0,0,0-.18,0,.81.81,0,0,0-.61,1l.09.38a.81.81,0,0,0,.79.63l.19,0a.82.82,0,0,0,.6-1L8.05,2a.81.81,0,0,0-.79-.63Z" />
+    <path d="M.81,2.9a.55.55,0,0,0-.18,0h0a.81.81,0,0,0-.61,1l.09.38A.81.81,0,0,0,.9,4.9l.18,0h0a.82.82,0,0,0,.61-1L1.6,3.52A.8.8,0,0,0,.81,2.9Z" />
+    <path d="M2.29.61a.57.57,0,0,0-.18,0,.81.81,0,0,0-.61,1l.16.7a.81.81,0,0,0,.79.63l.19,0h0a.8.8,0,0,0,.6-1l-.16-.71A.82.82,0,0,0,2.29.61Z" />
+    <path d="M4.93,0,4.75,0a.82.82,0,0,0-.61,1l.16.7a.82.82,0,0,0,.79.63l.19,0h0a.82.82,0,0,0,.61-1L5.72.63A.81.81,0,0,0,4.93,0Z" />
+    <path d="M13.22,16l-4.1-.54A1.88,1.88,0,0,1,8,14.94a1.34,1.34,0,0,1-.36-1.12,3.19,3.19,0,0,1,.83-1.62,5.73,5.73,0,0,1,1.62-1.32h0a2.57,2.57,
+    0,0,1,3.4.44A5.82,5.82,0,0,1,14.7,13a3.21,3.21,0,0,1,.38,1.78,1.28,1.28,0,0,1-.63,1A1.94,1.94,0,0,1,13.22,16Zm-1.48-4.64a2.12,2.12,0,0,
+    0-1.24.33h0a5.07,5.07,0,0,0-1.37,1.11,2.41,2.41,0,0,0-.62,1.16.43.43,0,0,0,.11.37,1.08,1.08,0,0,0,.61.24l4.11.53A1,1,0,0,0,14,15a.41.41,0,0,
+    0,.2-.33,2.47,2.47,0,0,0-.3-1.28,5,5,0,0,0-1-1.42A2.12,2.12,0,0,0,11.74,11.34Z"
+    />
+    <path d="M15.19,9.69a.82.82,0,0,0-.81.71l-.05.39a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.7l.05-.39a.8.8,0,0,0-.7-.91Z" />
+    <path d="M8.62,8.84a.82.82,0,0,0-.81.7l0,.39a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.71l.06-.39a.82.82,0,0,0-.7-.91Z" />
+    <path d="M10.8,7.22a.81.81,0,0,0-.8.7l-.09.72a.81.81,0,0,0,.7.91h.1a.83.83,0,0,0,.81-.71l.09-.72a.82.82,0,0,0-.7-.91Z" />
+    <path d="M13.49,7.57a.81.81,0,0,0-.8.71l-.1.71a.82.82,0,0,0,.7.91h.11a.81.81,0,0,0,.8-.71l.1-.71a.81.81,0,0,0-.7-.91Z" />
+  </svg>
+);
+
+export default FootstampIcon;

+ 27 - 0
src/client/js/components/Icons/AttachmentIcon.jsx

@@ -0,0 +1,27 @@
+import React from 'react';
+
+const Attachment = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <g className="cls-1">
+      <path
+        d="M2.9,13a2,2,0,0,1-1.44-.63,2.28,2.28,0,0,1,0-3.23l7-7.38a2.48,2.48,0,0,1,1.22-.7,2.61,
+        2.61,0,0,1,1.41.09A3.46,3.46,0,0,1,12.37,2a3.94,3.94,0,0,1,.36.45A2.61,2.61,0,0,1,13,3a3.41,3.41,
+        0,0,1,.16.57,3.06,3.06,0,0,1-.82,2.75L7.07,11.86a.35.35,0,0,1-.26.13.4.4,0,0,1-.28-.1.47.47,0,0,
+        1-.12-.27.39.39,0,0,1,.11-.29l5.26-5.59a2.28,2.28,0,0,0,.65-1.62,2.07,2.07,0,0,0-.62-1.58A2.62,2.62,
+        0,0,0,11,1.93a2,2,0,0,0-1-.13,1.63,1.63,0,0,0-1,.5L2,9.67a1.52,1.52,0,0,0,0,2.16,1.28,1.28,0,0,0,
+        .44.3,1,1,0,0,0,.51.08,1.43,1.43,0,0,0,1-.49L9.49,5.84l.12-.13.11-.15a1.24,1.24,0,0,0,.1-.2,1.94,
+        1.94,0,0,0,0-.2.6.6,0,0,0,0-.22.66.66,0,0,0-.14-.2.57.57,0,0,0-.45-.22,1,1,0,0,0-.52.3L4.56,
+        9.25a.42.42,0,0,1-.17.1.34.34,0,0,1-.2,0A.4.4,0,0,1,4,9.26.34.34,0,0,1,3.89,9,.41.41,0,0,1,4,8.72L8.16,
+        4.28a1.7,1.7,0,0,1,1-.53,1.32,1.32,0,0,1,1.06.43,1.23,1.23,0,0,1,.4,1.05,1.8,1.8,0,0,1-.58,1.14L4.52,
+        12.26A2.3,2.3,0,0,1,3,13H2.9Z"
+      />
+    </g>
+  </svg>
+);
+
+export default Attachment;

+ 0 - 0
src/client/js/components/GrowiLogo.jsx → src/client/js/components/Icons/GrowiLogo.jsx


+ 17 - 0
src/client/js/components/Icons/PageListIcon.jsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+const PageList = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path d="M12.63,2.72H1.37a.54.54,0,0,1,0-1.08H12.63a.54.54,0,0,1,0,1.08Z" />
+    <path d="M11.82,5.94H1.37a.55.55,0,0,1,0-1.09H11.82a.55.55,0,1,1,0,1.09Z" />
+    <path d="M9.41,9.15h-8a.54.54,0,0,1,0-1.08h8a.54.54,0,0,1,0,1.08Z" />
+    <path d="M10.84,12.36H1.37a.54.54,0,1,1,0-1.08h9.47a.54.54,0,1,1,0,1.08Z" />
+  </svg>
+);
+
+export default PageList;

+ 16 - 0
src/client/js/components/Icons/PagePreviewIcon.jsx

@@ -0,0 +1,16 @@
+import React from 'react';
+
+const PagePreviewIcon = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
+    <defs></defs>
+    <rect width="23" height="23" fillOpacity="0" />
+    <path d="M10.94,20.33H3.4V1.38H8.82V8.82h7.44v1.35a6.16,6.16,0,0,1,1.35.47V6.79L10.85,0H3.4a1.3,1.3,0,0,0-1,.39,1.3,1.3,0,0,0-.39,1v19A1.33,
+  1.33,0,0,0,3.4,21.68h9.84A5.94,5.94,0,0,1,10.94,20.33ZM10.17,1.38h.13l6,6v.11H10.17Z"
+    />
+    <path d="M21.87,22.14,18.75,19a4.74,4.74,0,0,0,1.1-3,4.89,4.89,0,1,0-1.8,3.73l3.11,3.11a.5.5,0,0,0,.35.15.51.51,0,0,0,.36-.15A.5.5,
+  0,0,0,21.87,22.14ZM15,19.57A3.57,3.57,0,1,1,18.59,16,3.58,3.58,0,0,1,15,19.57Z"
+    />
+  </svg>
+);
+
+export default PagePreviewIcon;

+ 22 - 0
src/client/js/components/Icons/PresentationIcon.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+
+const PresentationIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="12.25"
+    height="14"
+    viewBox="0 0 12.25 14"
+  >
+    <path
+      d="M44.261,0H32.909a.448.448,0,0,0-.449.448V7.635a.449.449,0,0,0,.9,0V.9H43.812V7.635a.449.449,0,0,0,.9,0V.448A.448.448,0,0,0,44.261,0Z"
+      transform="translate(-32.46)"
+    />
+    <path
+      d="M90.959,287.182H82.315a.448.448,0,1,0,0,.9h3.873v1.115l-3.207,3.381a.449.449,0,0,0,.652.616l2.555-2.694v2.013a.449.449,0,0,0,.9,0V
+        290.5l2.555,2.694a.449.449,0,0,0,.652-.616l-3.208-3.382v-1.114h3.873a.448.448,0,1,0,0-.9Z"
+      transform="translate(-80.512 -279.329)"
+    />
+  </svg>
+);
+
+export default PresentationIcon;

+ 21 - 0
src/client/js/components/Icons/RecentChangesIcon.jsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+const RecentChanges = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path
+      d="M7.94.94A6.13,6.13,0,0,0,1.89,7v.1L.67,5.89a.38.38,0,0,0-.55,0,.39.39,0,0,0,0,.56L2.36,8.69,4.6,6.45a.4.4,0,0,0,0-.56.39.39,0,0,0-.56,
+      0L2.68,7.25V7A5.33,5.33,0,0,1,7.94,1.73,5.33,5.33,0,0,1,13.21,7a5.34,5.34,0,0,1-5.27,5.27H7.86A5,5,0,0,1,4,10.38a.4.4,0,0,0-.55-.07.4.4,0,
+      0,0-.07.56,5.83,5.83,0,0,0,4.52,2.19H8A6.13,6.13,0,0,0,14,7,6.13,6.13,0,0,0,7.94.94Z"
+    />
+    <path
+      d="M7.94,2.83a.4.4,0,0,0-.39.4V7.37L10,8.92a.37.37,0,0,0,.21.06.4.4,0,0,0,.21-.73L8.34,6.93V3.23A.4.4,0,0,0,7.94,2.83Z"
+    />
+  </svg>
+);
+
+export default RecentChanges;

+ 35 - 0
src/client/js/components/Icons/ShareLinkIcon.jsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+const ShareLink = () => (
+  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+    <g transform="translate(-142 -502)">
+      <rect width="20" height="20" transform="translate(142 502)" fill="none" />
+      <g transform="translate(16 286.938)">
+        <path
+          d="M-1.813-3.563a2.711,2.711,0,0,0-1.274.308,2.8,2.8,0,0,0-.976.835L-11.48-6.2a2.676,2.676,
+          0,0,0,.105-.738,2.555,2.555,0,0,0-.044-.466,3.34,3.34,0,0,0-.114-.448l7.453-3.621a2.71,2.71,
+          0,0,0,.984.853,2.764,2.764,0,0,0,1.283.308,2.708,2.708,0,0,0,1.986-.826A2.708,2.708,0,0,
+          0,1-13.125a2.751,2.751,0,0,0-.378-1.406A2.793,2.793,0,0,0-.406-15.56a2.751,2.751,0,0,
+          0-1.406-.378,2.751,2.751,0,0,0-1.406.378,2.793,2.793,0,0,0-1.028,1.028,2.751,2.751,0,0,0-.378,
+          1.406v.105a.64.64,0,0,0,.009.105.641.641,0,0,1,.009.105A.641.641,0,0,0-4.6-12.7a.694.694,0,0,0,
+          .026.105.332.332,0,0,1,.018.105l-7.559,3.674a2.735,2.735,0,0,0-.923-.686,2.727,2.727,0,0,
+          0-1.151-.246,2.708,2.708,0,0,0-1.986.826A2.708,2.708,0,0,0-17-6.937a2.708,2.708,0,0,0,
+          .826,1.986,2.708,2.708,0,0,0,1.986.826A2.666,2.666,0,0,0-11.99-5.2l7.453,3.8a1.388,1.388,0,0,
+          0-.053.211q-.018.105-.026.22t-.009.22A2.751,2.751,0,0,0-4.247.656,2.792,2.792,0,0,0-3.219,
+          1.685a2.751,2.751,0,0,0,1.406.378A2.708,2.708,0,0,0,.174,1.236,2.708,2.708,0,0,0,1-.75,2.708,
+          2.708,0,0,0,.174-2.736,2.708,2.708,0,0,0-1.813-3.563Zm-1.2-10.758a1.627,1.627,0,0,1,1.2-.492,
+          1.627,1.627,0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2,1.627,1.627,0,0,1-.492,1.2,1.627,1.627,
+          0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,1.627,0,0,1-.492-1.2A1.627,1.627,0,0,
+          1-3.008-14.32Zm-9.984,8.578a1.627,1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,
+          1.627,0,0,1-.492-1.2,1.627,1.627,0,0,1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,
+          0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2A1.627,1.627,0,0,1-12.992-5.742ZM-.617.445a1.627,
+          1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492A1.627,1.627,0,0,1-3.5-.75a1.627,1.627,0,0,
+          1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,0,0,1,1.2.492A1.627,1.627,0,0,1-.125-.75,1.627,1.627,0,0,1-.617.445Z"
+          transform="translate(144 232)"
+        />
+      </g>
+    </g>
+  </svg>
+);
+
+export default ShareLink;

+ 19 - 0
src/client/js/components/Icons/TimeLineIcon.jsx

@@ -0,0 +1,19 @@
+import React from 'react';
+
+const TimeLine = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 14 14"
+
+  >
+    <rect width="14" height="14" fillOpacity="0" />
+    <path
+      d="M13.6,4.6a1.2,1.2,0,0,1-1.2,1.2,1,1,0,0,1-.3,0L10,7.89a1.1,1.1,0,0,1,0,.31,1.2,1.2,0,1,1-2.4,0,1.1,1.1,0,0,1,
+      0-.31L6.11,6.36a1.3,1.3,0,0,1-.62,0L2.75,9.1a1,1,0,0,1,0,.3A1.2,1.2,0,1,1,1.6,8.2a1,1,0,0,1,.3,0L4.64,
+      5.51a1.1,1.1,0,0,1,0-.31A1.2,1.2,0,0,1,7,5.2a1.1,1.1,0,0,1,0,.31L8.49,7a1.3,1.3,0,0,1,.62,0L11.25,4.9a1,
+      1,0,0,1-.05-.3,1.2,1.2,0,1,1,2.4,0Z"
+    />
+  </svg>
+);
+
+export default TimeLine;

+ 1 - 1
src/client/js/components/Navbar/GrowiNavbar.jsx

@@ -7,7 +7,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '../../services/NavigationContainer';
 import AppContainer from '../../services/AppContainer';
 
-import GrowiLogo from '../GrowiLogo';
+import GrowiLogo from '../Icons/GrowiLogo';
 
 import PersonalDropdown from './PersonalDropdown';
 import GlobalSearch from './GlobalSearch';

+ 7 - 3
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -83,7 +83,7 @@ const UserPagePathNav = ({ pageId, pagePath }) => {
 /* eslint-disable react/prop-types */
 const UserInfo = ({ pageUser }) => {
   return (
-    <div className="grw-users-info d-flex align-items-center d-edit-none">
+    <div className="grw-users-info d-flex align-items-center">
       <UserPicture user={pageUser} />
 
       <div className="users-meta">
@@ -150,6 +150,10 @@ const GrowiSubNavigation = (props) => {
     );
   }
 
+  function onThreeStrandedButtonClicked(viewType) {
+    navigationContainer.setEditorMode(viewType);
+  }
+
   return (
     <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
 
@@ -191,13 +195,13 @@ const GrowiSubNavigation = (props) => {
             { !isPageInTrash && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
           </div>
           <div className="mt-2">
-            <ThreeStrandedButton />
+            <ThreeStrandedButton onThreeStrandedButtonClicked={onThreeStrandedButtonClicked} />
           </div>
         </div>
 
         {/* Page Authors */}
         { (!isCompactMode && !isUserPage) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none">
+          <ul className="authors text-nowrap border-left d-none d-lg-block">
             { creator != null && (
               <li className="pb-1">
                 <PageCreator creator={creator} createdAt={createdAt} />

+ 15 - 4
src/client/js/components/Navbar/ThreeStrandedButton.jsx

@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 const ThreeStrandedButton = (props) => {
-
   const { t } = props;
 
   function threeStrandedButtonClickedHandler(viewType) {
@@ -14,15 +13,27 @@ const ThreeStrandedButton = (props) => {
 
   return (
     <div className="btn-group grw-three-stranded-button" role="group " aria-label="three-stranded-button">
-      <button type="button" className="btn btn-outline-primary view-button" onClick={() => { threeStrandedButtonClickedHandler('view') }}>
+      <button
+        type="button"
+        className="btn btn-outline-primary view-button"
+        onClick={() => { threeStrandedButtonClickedHandler('view') }}
+      >
         <i className="icon-control-play icon-fw" />
         { t('view') }
       </button>
-      <button type="button" className="btn btn-outline-primary edit-button" onClick={() => { threeStrandedButtonClickedHandler('edit') }}>
+      <button
+        type="button"
+        className="btn btn-outline-primary edit-button"
+        onClick={() => { threeStrandedButtonClickedHandler('edit') }}
+      >
         <i className="icon-note icon-fw" />
         { t('Edit') }
       </button>
-      <button type="button" className="btn btn-outline-primary hackmd-button" onClick={() => { threeStrandedButtonClickedHandler('hackmd') }}>
+      <button
+        type="button"
+        className="btn btn-outline-primary hackmd-button"
+        onClick={() => { threeStrandedButtonClickedHandler('hackmd') }}
+      >
         <i className="fa fa-fw fa-file-text-o" />
         { t('hackmd.hack_md') }
       </button>

+ 2 - 2
src/client/js/components/Page.jsx

@@ -129,12 +129,12 @@ class Page extends React.Component {
 
   render() {
     const { appContainer, pageContainer } = this.props;
-    const isMobile = appContainer.isMobile;
+    const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
     const { markdown } = pageContainer.state;
 
     return (
-      <div className={isMobile ? 'page-mobile' : ''}>
+      <div className={isMobile && 'page-mobile'}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
 
         { isLoggedIn && (

+ 1 - 0
src/client/js/components/Page/CopyDropdown.jsx

@@ -75,6 +75,7 @@ class CopyDropdown extends React.Component {
   }
 
   generatePermalink() {
+    const { origin } = window.location;
     const { pageId, isShareLinkMode } = this.props;
 
     if (pageId == null) {

+ 43 - 0
src/client/js/components/Page/DisplaySwitcher.jsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import { TabContent, TabPane } from 'reactstrap';
+import propTypes from 'prop-types';
+import { withUnstatedContainers } from '../UnstatedUtils';
+import NavigationContainer from '../../services/NavigationContainer';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+
+
+const DisplaySwitcher = (props) => {
+  const { navigationContainer } = props;
+  const { editorMode } = navigationContainer.state;
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId="view">
+          <Page />
+        </TabPane>
+        <TabPane tabId="edit">
+          <div id="page-editor">
+            <Editor />
+          </div>
+        </TabPane>
+        <TabPane tabId="hackmd">
+          <div id="page-editor-with-hackmd">
+            <PageEditorByHackmd />
+          </div>
+        </TabPane>
+      </TabContent>
+      {editorMode !== 'view' && <EditorNavbarBottom /> }
+    </>
+  );
+};
+
+DisplaySwitcher.propTypes = {
+  navigationContainer: propTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+
+export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer]);

+ 57 - 0
src/client/js/components/Page/PageManagement.jsx

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
+import urljoin from 'url-join';
 
 import { isTopPage } from '@commons/util/path-utils';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -11,6 +12,8 @@ import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
+import PagePresentationModal from '../PagePresentationModal';
+import PresentationIcon from '../Icons/PresentationIcon';
 
 
 const PageManagement = (props) => {
@@ -24,6 +27,7 @@ const PageManagement = (props) => {
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
+  const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
 
   function openPageRenameModalHandler() {
     setIsPageRenameModalShown(true);
@@ -57,6 +61,44 @@ const PageManagement = (props) => {
     setIsPageDeleteModalShown(false);
   }
 
+  function openPagePresentationModalHandler() {
+    setIsPagePresentationModalShown(true);
+  }
+
+  function closePagePresentationModalHandler() {
+    setIsPagePresentationModalShown(false);
+  }
+
+
+  // TODO GW-2746 bulk export pages
+  // async function getArchivePageData() {
+  //   try {
+  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
+  //     setTotalPages(res.data.dummy);
+  //   }
+  //   catch (err) {
+  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
+  //   }
+  // }
+
+  async function exportPageHandler(format) {
+    const { pageId, revisionId } = pageContainer.state;
+    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+    url.searchParams.append('format', format);
+    url.searchParams.append('revisionId', revisionId);
+    window.location.href = url.href;
+  }
+
+  // TODO GW-2746 create api to bulk export pages
+  // function openArchiveModalHandler() {
+  //   setIsArchiveCreateModalShown(true);
+  //   getArchivePageData();
+  // }
+
+  // TODO GW-2746 create api to bulk export pages
+  // function closeArchiveCreateModalHandler() {
+  //   setIsArchiveCreateModalShown(false);
+  // }
 
   function renderDropdownItemForNotTopPage() {
     return (
@@ -67,6 +109,16 @@ const PageManagement = (props) => {
         <button className="dropdown-item" type="button" onClick={openPageDuplicateModalHandler}>
           <i className="icon-fw icon-docs"></i> { t('Duplicate') }
         </button>
+        <button className="dropdown-item" type="button" onClick={openPagePresentationModalHandler}>
+          <i className="icon-fw"><PresentationIcon /></i><span className="d-none d-sm-inline"> { t('Presentation Mode') }</span>
+        </button>
+        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+          <i className="icon-fw icon-cloud-download"></i>{t('export_bulk.export_page_markdown')}
+        </button>
+        {/* TODO GW-2746 create api to bulk export pages */}
+        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
+          <i className="icon-fw"></i>{t('Create Archive Page')}
+        </button> */}
         <div className="dropdown-divider"></div>
       </>
     );
@@ -109,6 +161,11 @@ const PageManagement = (props) => {
           path={path}
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}
         />
+        <PagePresentationModal
+          isOpen={isPagePresentationModalShown}
+          onClose={closePagePresentationModalHandler}
+          href="?presentation=1"
+        />
       </>
     );
   }

+ 0 - 159
src/client/js/components/Page/PageShareManagement.jsx

@@ -1,159 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-import { withTranslation } from 'react-i18next';
-import urljoin from 'url-join';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
-import OutsideShareLinkModal from '../OutsideShareLinkModal';
-
-// TODO GW-2746 bulk export pages
-// import ArchiveCreateModal from '../ArchiveCreateModal';
-
-const PageShareManagement = (props) => {
-  const { t, appContainer, pageContainer } = props;
-
-  // TODO GW-2746 bulk export pages
-  // eslint-disable-next-line no-unused-vars
-  const { path, pageId } = pageContainer.state;
-  const { currentUser } = appContainer;
-
-  const [isOutsideShareLinkModalShown, setIsOutsideShareLinkModalShown] = useState(false);
-
-  // TODO GW-2746 bulk export pages
-  // const [isArchiveCreateModalShown, setIsArchiveCreateModalShown] = useState(false);
-  // const [totalPages, setTotalPages] = useState(null);
-  // const [errorMessage, setErrorMessage] = useState(null);
-
-  function openOutsideShareLinkModalHandler() {
-    setIsOutsideShareLinkModalShown(true);
-  }
-
-  function closeOutsideShareLinkModalHandler() {
-    setIsOutsideShareLinkModalShown(false);
-  }
-
-  // TODO GW-2746 bulk export pages
-  // async function getArchivePageData() {
-  //   try {
-  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
-  //     setTotalPages(res.data.dummy);
-  //   }
-  //   catch (err) {
-  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
-  //   }
-  // }
-
-  async function exportPageHandler(format) {
-    const { pageId, revisionId } = pageContainer.state;
-    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
-    url.searchParams.append('format', format);
-    url.searchParams.append('revisionId', revisionId);
-    window.location.href = url.href;
-  }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function openArchiveModalHandler() {
-  //   setIsArchiveCreateModalShown(true);
-  //   getArchivePageData();
-  // }
-
-  // TODO GW-2746 create api to bulk export pages
-  // function closeArchiveCreateModalHandler() {
-  //   setIsArchiveCreateModalShown(false);
-  // }
-
-
-  function renderModals() {
-    if (currentUser == null) {
-      return null;
-    }
-
-    return (
-      <>
-        <OutsideShareLinkModal
-          isOpen={isOutsideShareLinkModalShown}
-          onClose={closeOutsideShareLinkModalHandler}
-        />
-
-        {/* TODO GW-2746 bulk export pages */}
-        {/* <ArchiveCreateModal
-          isOpen={isArchiveCreateModalShown}
-          onClose={closeArchiveCreateModalHandler}
-          path={path}
-          errorMessage={errorMessage}
-          totalPages={totalPages}
-        /> */}
-      </>
-    );
-  }
-
-
-  function renderCurrentUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
-          data-toggle="dropdown"
-        >
-          <i className="icon-share"></i>
-        </button>
-      </>
-    );
-  }
-
-  function renderGuestUser() {
-    return (
-      <>
-        <button
-          type="button"
-          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
-          id="auth-guest-tltips"
-        >
-          <i className="icon-share"></i>
-        </button>
-        <UncontrolledTooltip placement="top" target="auth-guest-tltips">
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      </>
-    );
-  }
-
-  return (
-    <>
-      {currentUser == null ? renderGuestUser() : renderCurrentUser()}
-      <div className="dropdown-menu dropdown-menu-right">
-        <button className="dropdown-item" type="button" onClick={openOutsideShareLinkModalHandler}>
-          <i className="icon-fw icon-link"></i>{t('share_links.Shere this page link to public')}
-          <span className="ml-2 badge badge-info badge-pill">{pageContainer.state.shareLinksNumber}</span>
-        </button>
-        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
-          <span>{t('export_bulk.export_page_markdown')}</span>
-        </button>
-        {/* TODO GW-2746 create api to bulk export pages */}
-        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
-          <i className="icon-fw"></i>{t('Create Archive Page')}
-        </button> */}
-      </div>
-      {renderModals()}
-    </>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageShareManagementWrapper = withUnstatedContainers(PageShareManagement, [AppContainer, PageContainer]);
-
-
-PageShareManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withTranslation()(PageShareManagementWrapper);

+ 7 - 18
src/client/js/components/Page/TagLabels.jsx

@@ -28,12 +28,11 @@ class TagLabels extends React.Component {
 
   /**
    * @return tags data
-   *   1. pageContainer.state.tags if isEditorMode is false
-   *   2. editorContainer.state.tags if isEditorMode is true
+   *   1. pageContainer.state.tags if pageId is not null
+   *   2. editorContainer.state.tags if pageId is null
    */
   getEditTargetData() {
-    const { isEditorMode } = this.props;
-    return (isEditorMode) ? this.props.editorContainer.state.tags : this.props.pageContainer.state.tags;
+    return (this.props.editorContainer.state.pageId != null) ? this.props.editorContainer.state.tags : this.props.pageContainer.state.tags;
   }
 
   openEditorModal() {
@@ -45,24 +44,19 @@ class TagLabels extends React.Component {
   }
 
   async tagsUpdatedHandler(tags) {
-    const { appContainer, editorContainer, isEditorMode } = this.props;
+    const { appContainer, editorContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
 
-    // only update tags in editorContainer
-    if (isEditorMode) {
+    // only update tags in editorContainer when new page
+    if (pageId != null) {
       return editorContainer.setState({ tags });
     }
 
-    // post api request and update tags
-    const { pageContainer } = this.props;
-
     try {
-      const { pageId } = pageContainer.state;
       await appContainer.apiPost('/tags.update', { pageId, tags });
 
       // update pageContainer.state
       pageContainer.setState({ tags });
-      editorContainer.setState({ tags });
-
       toastSuccess('updated tags successfully');
     }
     catch (err) {
@@ -113,11 +107,6 @@ TagLabels.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
-  isEditorMode: PropTypes.bool,
-};
-
-TagLabels.defaultProps = {
-  isEditorMode: false,
 };
 
 export default withTranslation()(TagLabelsWrapper);

+ 160 - 0
src/client/js/components/PageAccessoriesModal.jsx

@@ -0,0 +1,160 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Modal, ModalBody, ModalHeader, Nav, NavItem, NavLink, TabContent, TabPane,
+} from 'reactstrap';
+
+import { withTranslation } from 'react-i18next';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import RecentChangesIcon from './Icons/RecentChangesIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+import PageAttachment from './PageAttachment';
+import PageTimeline from './PageTimeline';
+import PageList from './PageList';
+import PageHistory from './PageHistory';
+import ShareLink from './ShareLink/ShareLink';
+
+
+const navTabMapping = {
+  pagelist: {
+    icon: <PageListIcon />,
+    i18n: 'page_list',
+    index: 0,
+  },
+  timeline:  {
+    icon: <TimeLineIcon />,
+    i18n: 'Timeline View',
+    index: 1,
+  },
+  pageHistory: {
+    icon: <RecentChangesIcon />,
+    i18n: 'History',
+    index: 2,
+  },
+  attachment: {
+    icon: <AttachmentIcon />,
+    i18n: 'attachment_data',
+    index: 3,
+  },
+  shareLink: {
+    icon: <ShareLinkIcon />,
+    i18n: 'share_links.share_link_management',
+    index: 4,
+  },
+};
+
+const PageAccessoriesModal = (props) => {
+  const { t, pageAccessoriesContainer } = props;
+  const { switchActiveTab } = pageAccessoriesContainer;
+  const { activeTab } = pageAccessoriesContainer.state;
+
+  const [sliderWidth, setSliderWidth] = useState(null);
+  const [sliderMarginLeft, setSliderMarginLeft] = useState(null);
+
+  function closeModalHandler() {
+    if (props.onClose == null) {
+      return;
+    }
+    props.onClose();
+  }
+
+  // Might make this dynamic for px, %, pt, em
+  function getPercentage(min, max) {
+    return min / max * 100;
+  }
+
+  useEffect(() => {
+    if (activeTab === '') {
+      return;
+    }
+
+    const navTitle = document.getElementById('nav-title');
+    const navTabs = document.querySelectorAll('li.nav-link');
+
+    if (navTitle == null || navTabs == null) {
+      return;
+    }
+
+    let tempML = 0;
+
+    const styles = [].map.call(navTabs, (el) => {
+      const width = getPercentage(el.offsetWidth, navTitle.offsetWidth);
+      const marginLeft = tempML;
+      tempML += width;
+      return { width, marginLeft };
+    });
+
+    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
+
+    setSliderWidth(width);
+    setSliderMarginLeft(marginLeft);
+
+  }, [activeTab]);
+
+
+  return (
+    <React.Fragment>
+      <Modal size="xl" isOpen={props.isOpen} toggle={closeModalHandler} className="grw-page-accessories-modal">
+        {/* [TODO: insert a modal header and move nav tabs there  by gw-3890] */}
+        <ModalHeader className="p-0" toggle={closeModalHandler}>
+          <Nav className="nav-title" id="nav-title">
+            {Object.entries(navTabMapping).map(([key, value]) => {
+              return (
+                <NavItem key={key} type="button" className={`p-0 nav-link ${activeTab === key && 'active'}`}>
+                  <NavLink onClick={() => { switchActiveTab(key) }}>
+                    {value.icon}
+                    {t(value.i18n)}
+                  </NavLink>
+                </NavItem>
+              );
+            })}
+          </Nav>
+          <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
+        </ModalHeader>
+        <ModalBody className="overflow-auto grw-modal-body-style p-0">
+          <TabContent activeTab={activeTab} className="p-5">
+            <TabPane tabId="pagelist">
+              {pageAccessoriesContainer.state.activeComponents.has('pagelist') && <PageList />}
+            </TabPane>
+            <TabPane tabId="timeline">
+              {pageAccessoriesContainer.state.activeComponents.has('timeline') && <PageTimeline /> }
+            </TabPane>
+            <TabPane tabId="pageHistory">
+              <div className="overflow-auto">
+                {pageAccessoriesContainer.state.activeComponents.has('pageHistory') && <PageHistory /> }
+              </div>
+            </TabPane>
+            <TabPane tabId="attachment">
+              {pageAccessoriesContainer.state.activeComponents.has('attachment') && <PageAttachment />}
+            </TabPane>
+            <TabPane tabId="shareLink">
+              {pageAccessoriesContainer.state.activeComponents.has('shareLink') && <ShareLink />}
+            </TabPane>
+          </TabContent>
+        </ModalBody>
+      </Modal>
+    </React.Fragment>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageAccessoriesContainer]);
+
+PageAccessoriesModal.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+};
+
+export default withTranslation()(PageAccessoriesModalWrapper);

+ 55 - 19
src/client/js/components/PageAttachment.jsx

@@ -1,9 +1,11 @@
 /* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import PaginationWrapper from './PaginationWrapper';
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
@@ -14,6 +16,9 @@ class PageAttachment extends React.Component {
     super(props);
 
     this.state = {
+      activePage: 1,
+      limit: 10,
+      totalAttachments: 0,
       attachments: [],
       inUse: {},
       attachmentToDelete: null,
@@ -21,31 +26,46 @@ class PageAttachment extends React.Component {
       deleteError: '',
     };
 
+    this.handlePage = this.handlePage.bind(this);
     this.onAttachmentDeleteClicked = this.onAttachmentDeleteClicked.bind(this);
     this.onAttachmentDeleteClickedConfirm = this.onAttachmentDeleteClickedConfirm.bind(this);
   }
 
-  componentDidMount() {
+
+  async handlePage(selectedPage) {
     const { pageId } = this.props.pageContainer.state;
+    const { limit } = this.state;
+    const offset = (selectedPage - 1) * limit;
+    const activePage = selectedPage;
+
+    if (!pageId) { return }
+
+    const res = await this.props.appContainer.apiv3Get('/attachment/list', {
+      pageId, limit, offset,
+    });
+    const attachments = res.data.paginateResult.docs;
+    const totalAttachments = res.data.paginateResult.totalDocs;
+
+    const inUse = {};
 
-    if (!pageId) {
-      return;
+    for (const attachment of attachments) {
+      inUse[attachment._id] = this.checkIfFileInUse(attachment);
     }
 
-    this.props.appContainer.apiGet('/attachments.list', { page_id: pageId })
-      .then((res) => {
-        const attachments = res.attachments;
-        const inUse = {};
+    this.setState({
+      activePage,
+      totalAttachments,
+      attachments,
+      inUse,
+    });
+  }
 
-        for (const attachment of attachments) {
-          inUse[attachment._id] = this.checkIfFileInUse(attachment);
-        }
 
-        this.setState({
-          attachments,
-          inUse,
-        });
-      });
+  async componentDidMount() {
+    await this.handlePage(1);
+    this.setState({
+      activePage: 1,
+    });
   }
 
   checkIfFileInUse(attachment) {
@@ -92,7 +112,15 @@ class PageAttachment extends React.Component {
     return this.props.appContainer.currentUser != null;
   }
 
+
   render() {
+
+    const { t } = this.props;
+    if (this.state.attachments.length === 0) {
+      return t('No_attachments_yet');
+
+    }
+
     let deleteAttachmentModal = '';
     if (this.isUserLoggedIn()) {
       const attachmentToDelete = this.state.attachmentToDelete;
@@ -121,9 +149,8 @@ class PageAttachment extends React.Component {
       );
     }
 
-
     return (
-      <div>
+      <>
         <PageAttachmentList
           attachments={this.state.attachments}
           inUse={this.state.inUse}
@@ -132,7 +159,15 @@ class PageAttachment extends React.Component {
         />
 
         {deleteAttachmentModal}
-      </div>
+
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalAttachments}
+          pagingLimit={this.state.limit}
+          align="center"
+        />
+      </>
     );
   }
 
@@ -145,8 +180,9 @@ const PageAttachmentWrapper = withUnstatedContainers(PageAttachment, [AppContain
 
 
 PageAttachment.propTypes = {
+  t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default PageAttachmentWrapper;
+export default withTranslation()(PageAttachmentWrapper);

+ 2 - 1
src/client/js/components/PageEditor.jsx

@@ -131,7 +131,7 @@ class PageEditor extends React.Component {
    * @param {any} file
    */
   async onUpload(file) {
-    const { appContainer, pageContainer } = this.props;
+    const { appContainer, pageContainer, editorContainer } = this.props;
 
     try {
       let res = await appContainer.apiGet('/attachments.limit', {
@@ -167,6 +167,7 @@ class PageEditor extends React.Component {
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
         pageContainer.updateStateAfterSave(res.page);
+        editorContainer.setState({ grant: res.page.grant });
       }
     }
     catch (e) {

+ 1 - 1
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -876,7 +876,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         <LinkEditModal
           ref={this.linkEditModal}
-          onSave={(link) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), link) }}
+          onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
         <HandsontableModal
           ref={this.handsontableModal}

+ 1 - 1
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -119,7 +119,7 @@ class DrawioModal extends React.PureComponent {
   get drawioURL() {
     const { config } = this.props.appContainer;
 
-    const drawioUri = config.env.DRAWIO_URI || 'https://www.draw.io/';
+    const drawioUri = config.env.DRAWIO_URI || 'https://embed.diagrams.net/';
     const url = new URL(drawioUri);
 
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-

+ 270 - 150
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -5,13 +5,16 @@ import {
   Modal,
   ModalHeader,
   ModalBody,
-  ModalFooter,
+  Popover,
+  PopoverBody,
 } from 'reactstrap';
 
 import { debounce } from 'throttle-debounce';
 
 import path from 'path';
+import validator from 'validator';
 import Preview from './Preview';
+import PagePreviewIcon from '../Icons/PagePreviewIcon';
 
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
@@ -34,7 +37,10 @@ class LinkEditModal extends React.PureComponent {
       labelInputValue: '',
       linkerType: Linker.types.markdownLink,
       markdown: '',
+      previewError: '',
       permalink: '',
+      linkText: '',
+      isPreviewOpen: false,
     };
 
     this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
@@ -52,47 +58,78 @@ class LinkEditModal extends React.PureComponent {
     this.generateLink = this.generateLink.bind(this);
     this.renderPreview = this.renderPreview.bind(this);
     this.getRootPath = this.getRootPath.bind(this);
-
-    this.getPreviewDebounced = debounce(200, this.getPreview.bind(this));
+    this.toggleIsPreviewOpen = this.toggleIsPreviewOpen.bind(this);
+    this.generateAndSetPreviewDebounced = debounce(200, this.generateAndSetPreview.bind(this));
   }
 
   componentDidUpdate(prevProps, prevState) {
     const { linkInputValue: prevLinkInputValue } = prevState;
     const { linkInputValue } = this.state;
     if (linkInputValue !== prevLinkInputValue) {
-      this.getPreviewDebounced(linkInputValue);
+      this.generateAndSetPreviewDebounced(linkInputValue);
     }
   }
 
   // defaultMarkdownLink is an instance of Linker
   show(defaultMarkdownLink = null) {
     // if defaultMarkdownLink is null, set default value in inputs.
-    const { label = '' } = defaultMarkdownLink;
-    let { link = '', type = Linker.types.markdownLink } = defaultMarkdownLink;
+    const { label = '', link = '' } = defaultMarkdownLink;
+    let { type = Linker.types.markdownLink } = defaultMarkdownLink;
 
     // if type of defaultMarkdownLink is pukiwikiLink when pukiwikiLikeLinker plugin is disable, change type(not change label and link)
     if (type === Linker.types.pukiwikiLink && !this.isApplyPukiwikiLikeLinkerPlugin) {
       type = Linker.types.markdownLink;
     }
 
-    const url = new URL(link, 'http://example.com');
-    const isUseRelativePath = url.origin === 'http://example.com' && !link.startsWith('/') && link !== '';
-    if (isUseRelativePath) {
-      const rootPath = this.getRootPath(type);
-      link = path.resolve(rootPath, link);
-    }
+    this.parseLinkAndSetState(link, type);
 
     this.setState({
       show: true,
       labelInputValue: label,
-      linkInputValue: link,
       isUsePermanentLink: false,
       permalink: '',
       linkerType: type,
+    });
+  }
+
+  // parse link, link is ...
+  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
+  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
+  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
+  // case-4. external link (ex. 'https://growi.org')
+  // case-5. the others (ex. '')
+  parseLinkAndSetState(link, type) {
+    // create url from link, add dummy origin if link is not valid url.
+    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
+    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
+    const url = new URL(link, 'http://example.com');
+    const isUrl = url.origin !== 'http://example.com';
+
+    let isUseRelativePath = false;
+    let reshapedLink = link;
+
+    // if case-1, reshapedLink becomes page path
+    reshapedLink = this.convertUrlToPathIfPageUrl(reshapedLink, url);
+
+    // case-3
+    if (!isUrl && !reshapedLink.startsWith('/') && reshapedLink !== '') {
+      isUseRelativePath = true;
+      const rootPath = this.getRootPath(type);
+      reshapedLink = path.resolve(rootPath, reshapedLink);
+    }
+
+    this.setState({
+      linkInputValue: reshapedLink,
       isUseRelativePath,
     });
   }
 
+  // return path name of link if link is this growi page url, else return original link.
+  convertUrlToPathIfPageUrl(link, url) {
+    // when link is this growi's page url, url.origin === window.location.origin and return path name
+    return url.origin === window.location.origin ? decodeURI(url.pathname) : link;
+  }
+
   cancel() {
     this.hide();
   }
@@ -122,27 +159,73 @@ class LinkEditModal extends React.PureComponent {
   }
 
   renderPreview() {
-    return (
-      <div className="linkedit-preview">
-        <Preview
-          markdown={this.state.markdown}
-        />
-      </div>
-    );
+    if (this.state.markdown !== '') {
+      return (
+        <div className="linkedit-preview">
+          <Preview markdown={this.state.markdown} />
+        </div>
+      );
+    }
+    if (this.state.previewError !== '') {
+      return this.state.previewError;
+    }
+    return 'Page preview here.';
   }
 
-  async getPreview(path) {
+  async generateAndSetPreview(path) {
     let markdown = '';
+    let previewError = '';
     let permalink = '';
-    try {
-      const res = await this.props.appContainer.apiGet('/pages.get', { path });
-      markdown = res.page.revision.body;
-      permalink = `${window.location.origin}/${res.page.id}`;
+
+    if (path.startsWith('/')) {
+      const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
+      const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
+      const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
+
+      try {
+        const { page } = await this.props.appContainer.apiGet('/pages.get', { path: pathWithoutFragment, page_id: pageId });
+        markdown = page.revision.body;
+        // create permanent link only if path isn't permanent link because checkbox for isUsePermanentLink is disabled when permalink is ''.
+        permalink = !isPermanentLink ? `${window.location.origin}/${page.id}` : '';
+      }
+      catch (err) {
+        previewError = err.message;
+      }
+    }
+    this.setState({ markdown, previewError, permalink });
+  }
+
+  renderLinkPreview() {
+    const linker = this.generateLink();
+
+    if (this.isUsePermanentLink && this.permalink != null) {
+      linker.link = this.permalink;
     }
-    catch (err) {
-      markdown = `<div class="alert alert-warning" role="alert"><strong>${err.message}</strong></div>`;
+
+    if (linker.label === '') {
+      linker.label = linker.link;
     }
-    this.setState({ markdown, permalink });
+
+    const linkText = linker.generateMarkdownText();
+    return (
+      <div className="d-flex justify-content-between mb-3">
+        <div className="card card-disabled w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">Markdown</p>
+          <p className="text-center text-truncate text-muted">{linkText}</p>
+        </div>
+        <div className="d-flex align-items-center">
+          <span className="lead mx-3">
+            <i className="fa fa-caret-right"></i>
+          </span>
+        </div>
+        <div className="card w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">HTML</p>
+          <p className="text-center text-truncate">
+            <a href={linker.link}>{linker.label}</a>
+          </p>
+        </div>
+      </div>
+    );
   }
 
   handleChangeTypeahead(selected) {
@@ -174,10 +257,8 @@ class LinkEditModal extends React.PureComponent {
   }
 
   save() {
-    const output = this.generateLink();
-
     if (this.props.onSave != null) {
-      this.props.onSave(output);
+      this.props.onSave(this.state.linkText);
     }
 
     this.hide();
@@ -185,12 +266,7 @@ class LinkEditModal extends React.PureComponent {
 
   generateLink() {
     const {
-      linkInputValue,
-      labelInputValue,
-      linkerType,
-      isUseRelativePath,
-      isUsePermanentLink,
-      permalink,
+      linkInputValue, labelInputValue, linkerType, isUseRelativePath, isUsePermanentLink, permalink,
     } = this.state;
 
     let reshapedLink = linkInputValue;
@@ -199,13 +275,11 @@ class LinkEditModal extends React.PureComponent {
       reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
     }
 
-    return new Linker(
-      linkerType,
-      labelInputValue,
-      reshapedLink,
-      isUsePermanentLink,
-      permalink,
-    );
+    if (isUsePermanentLink && permalink != null) {
+      reshapedLink = permalink;
+    }
+
+    return new Linker(linkerType, labelInputValue, reshapedLink);
   }
 
   getRootPath(type) {
@@ -215,124 +289,170 @@ class LinkEditModal extends React.PureComponent {
     return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
   }
 
+  toggleIsPreviewOpen() {
+    this.setState({ isPreviewOpen: !this.state.isPreviewOpen });
+  }
+
+  renderLinkAndLabelForm() {
+    return (
+      <>
+        <h3 className="grw-modal-head">Set link and label</h3>
+        <form className="form-group">
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">link</span>
+              </div>
+              <SearchTypeahead
+                onChange={this.handleChangeTypeahead}
+                onInputChange={this.handleChangeLinkInput}
+                inputName="link"
+                placeholder="Input page path or URL"
+                keywordOnInit={this.state.linkInputValue}
+              />
+              <div className="input-group-append">
+                <button type="button" id="preview-btn" className="btn btn-info btn-page-preview">
+                  <PagePreviewIcon />
+                </button>
+                <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
+                  <PopoverBody>
+                    {this.renderPreview()}
+                  </PopoverBody>
+                </Popover>
+              </div>
+            </div>
+          </div>
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">label</span>
+              </div>
+              <input
+                type="text"
+                className="form-control"
+                id="label"
+                value={this.state.labelInputValue}
+                onChange={e => this.handleChangeLabelInput(e.target.value)}
+                disabled={this.state.linkerType === Linker.types.growiLink}
+              />
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  }
+
+  renderPathFormatForm() {
+    return (
+      <div className="card well pt-3">
+        <form className="form-group mb-0">
+          <div className="form-group row">
+            <label className="col-sm-3">Path format</label>
+            <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+              <input
+                className="custom-control-input"
+                id="relativePath"
+                type="checkbox"
+                checked={this.state.isUseRelativePath}
+                onChange={this.toggleIsUseRelativePath}
+                disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
+              />
+              <label className="custom-control-label" htmlFor="relativePath">
+                Use relative path
+              </label>
+            </div>
+            <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+              <input
+                className="custom-control-input"
+                id="permanentLink"
+                type="checkbox"
+                checked={this.state.isUsePermanentLink}
+                onChange={this.toggleIsUsePamanentLink}
+                disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
+              />
+              <label className="custom-control-label" htmlFor="permanentLink">
+                Use permanent link
+              </label>
+            </div>
+          </div>
+          <div className="form-group row mb-0">
+            <label className="col-sm-3">Notation</label>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="markdownType"
+                value={Linker.types.markdownLink}
+                checked={this.state.linkerType === Linker.types.markdownLink}
+                onChange={e => this.handleSelecteLinkerType(e.target.value)}
+              />
+              <label className="custom-control-label" htmlFor="markdownType">
+                Markdown
+              </label>
+            </div>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="growiType"
+                value={Linker.types.growiLink}
+                checked={this.state.linkerType === Linker.types.growiLink}
+                onChange={e => this.handleSelecteLinkerType(e.target.value)}
+              />
+              <label className="custom-control-label" htmlFor="growiType">
+                Growi original
+              </label>
+            </div>
+            <div className="custom-control custom-radio custom-control-inline">
+              <input
+                type="radio"
+                className="custom-control-input"
+                id="pukiwikiType"
+                value={Linker.types.pukiwikiLink}
+                checked={this.state.linkerType === Linker.types.pukiwikiLink}
+                onChange={e => this.handleSelecteLinkerType(e.target.value)}
+              />
+              <label className="custom-control-label" htmlFor="pukiwikiType">
+                Pukiwiki
+              </label>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
   render() {
     return (
-      <Modal isOpen={this.state.show} toggle={this.cancel} size="lg">
+      <Modal className="link-edit-modal" isOpen={this.state.show} toggle={this.cancel} size="lg">
         <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
           Edit Links
         </ModalHeader>
 
         <ModalBody className="container">
           <div className="row">
-            <div className="col-12 col-lg-6">
-              <form className="form-group">
-                <div className="form-gorup my-3">
-                  <label htmlFor="linkInput">Link</label>
-                  <div className="input-group">
-                    <SearchTypeahead
-                      onChange={this.handleChangeTypeahead}
-                      onInputChange={this.handleChangeLinkInput}
-                      inputName="link"
-                      placeholder="Input page path or URL"
-                      keywordOnInit={this.state.linkInputValue}
-                    />
-                  </div>
-                </div>
-              </form>
-
-              <div className="d-block d-lg-none mb-3 overflow-auto">
-                {this.renderPreview()}
-              </div>
-
-              <div className="card">
-                <div className="card-body">
-                  <form className="form-group">
-                    <div className="form-group btn-group d-flex" role="group" aria-label="type">
-                      <button
-                        type="button"
-                        name={Linker.types.markdownLink}
-                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.markdownLink && 'active'}`}
-                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
-                      >
-                        Markdown
-                      </button>
-                      <button
-                        type="button"
-                        name={Linker.types.growiLink}
-                        className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.growiLink && 'active'}`}
-                        onClick={e => this.handleSelecteLinkerType(e.target.name)}
-                      >
-                        Growi Original
-                      </button>
-                      {this.isApplyPukiwikiLikeLinkerPlugin && (
-                        <button
-                          type="button"
-                          name={Linker.types.pukiwikiLink}
-                          className={`btn btn-outline-secondary col ${this.state.linkerType === Linker.types.pukiwikiLink && 'active'}`}
-                          onClick={e => this.handleSelecteLinkerType(e.target.name)}
-                        >
-                          Pukiwiki
-                        </button>
-                      )}
-                    </div>
-
-                    <div className="form-group">
-                      <label htmlFor="label">Label</label>
-                      <input
-                        type="text"
-                        className="form-control"
-                        id="label"
-                        value={this.state.labelInputValue}
-                        onChange={e => this.handleChangeLabelInput(e.target.value)}
-                        disabled={this.state.linkerType === Linker.types.growiLink}
-                      />
-                    </div>
-                    <div className="form-inline">
-                      <div className="custom-control custom-checkbox custom-checkbox-info">
-                        <input
-                          className="custom-control-input"
-                          id="relativePath"
-                          type="checkbox"
-                          checked={this.state.isUseRelativePath}
-                          disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
-                        />
-                        <label className="custom-control-label" htmlFor="relativePath" onClick={this.toggleIsUseRelativePath}>
-                          Use relative path
-                        </label>
-                      </div>
-                    </div>
-                    <div className="form-inline">
-                      <div className="custom-control custom-checkbox custom-checkbox-info">
-                        <input
-                          className="custom-control-input"
-                          id="permanentLink"
-                          type="checkbox"
-                          checked={this.state.isUsePermanentLink}
-                          disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
-                        />
-                        <label className="custom-control-label" htmlFor="permanentLink" onClick={this.toggleIsUsePamanentLink}>
-                          Use permanent link
-                        </label>
-                      </div>
-                    </div>
-                  </form>
-                </div>
-              </div>
+            <div className="col-12">
+              {this.renderLinkAndLabelForm()}
+              {this.renderPathFormatForm()}
             </div>
-
-            <div className="col d-none d-lg-block pr-0 mr-3 overflow-auto">
-              {this.renderPreview()}
+          </div>
+          <div className="row">
+            <div className="col-12">
+              <h3 className="grw-modal-head">Preview</h3>
+              {this.renderLinkPreview()}
+            </div>
+          </div>
+          <div className="row">
+            <div className="col-12 text-center">
+              <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
+                Cancel
+              </button>
+              <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
+                Done
+              </button>
             </div>
           </div>
         </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.hide}>
-            Cancel
-          </button>
-          <button type="submit" className="btn btn-sm btn-primary" onClick={this.save}>
-            Done
-          </button>
-        </ModalFooter>
       </Modal>
     );
   }

+ 3 - 4
src/client/js/components/PageEditor/MarkdownLinkUtil.js

@@ -27,16 +27,15 @@ class MarkdownLinkUtil {
   }
 
   // replace link(link is an instance of Linker)
-  replaceFocusedMarkdownLinkWithEditor(editor, link) {
+  replaceFocusedMarkdownLinkWithEditor(editor, linkText) {
     const curPos = editor.getCursor();
-    const linkStr = link.generateMarkdownText();
     if (!this.isInLink(editor)) {
-      editor.getDoc().replaceSelection(linkStr);
+      editor.getDoc().replaceSelection(linkText);
     }
     else {
       const line = editor.getDoc().getLine(curPos.line);
       const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(line, curPos.ch);
-      editor.getDoc().replaceRange(linkStr, { line: curPos.line, ch: beginningOfLink }, { line: curPos.line, ch: endOfLink });
+      editor.getDoc().replaceRange(linkText, { line: curPos.line, ch: beginningOfLink }, { line: curPos.line, ch: endOfLink });
     }
   }
 

+ 0 - 48
src/client/js/components/PageEditor/PagePathNavForEditor.jsx

@@ -1,48 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import LinkedPagePath from '@commons/models/linked-page-path';
-import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
-
-import RevisionPathControls from '../Page/RevisionPathControls';
-import TagLabels from '../Page/TagLabels';
-
-const PagePathNavForEditor = (props) => {
-  const { pageId, path } = props.pageContainer.state;
-
-  const linkedPagePath = new LinkedPagePath(path);
-  const pagePathHierarchicalLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-
-  return (
-    <div className="grw-page-path-nav-for-edit">
-      <span className="d-flex align-items-center flex-wrap">
-        <h3 className="mb-0 grw-page-path-link">{pagePathHierarchicalLink}</h3>
-        <RevisionPathControls
-          pageId={pageId}
-          pagePath={path}
-        />
-      </span>
-      <TagLabels isEditorMode />
-    </div>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PagePathNavForEditorWrapper = withUnstatedContainers(PagePathNavForEditor, [AppContainer, PageContainer]);
-
-
-PagePathNavForEditor.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withTranslation()(PagePathNavForEditorWrapper);

+ 8 - 10
src/client/js/components/PageHistory.jsx

@@ -53,21 +53,19 @@ function PageHistory(props) {
 
   function pager() {
     return (
-      <div className="my-3">
-        <PaginationWrapper
-          activePage={pageHistoryContainer.state.activePage}
-          changePage={handlePage}
-          totalItemsCount={pageHistoryContainer.state.totalPages}
-          pagingLimit={pageHistoryContainer.state.pagingLimit}
-        />
-      </div>
+      <PaginationWrapper
+        activePage={pageHistoryContainer.state.activePage}
+        changePage={handlePage}
+        totalItemsCount={pageHistoryContainer.state.totalPages}
+        pagingLimit={pageHistoryContainer.state.pagingLimit}
+        align="center"
+      />
     );
   }
 
 
   return (
-    <div className="mt-4">
-      {pager()}
+    <div>
       <PageRevisionList
         revisions={pageHistoryContainer.state.revisions}
         diffOpened={pageHistoryContainer.state.diffOpened}

+ 86 - 0
src/client/js/components/PageList.jsx

@@ -0,0 +1,86 @@
+import React, { useEffect, useCallback, useState } from 'react';
+import PropTypes from 'prop-types';
+
+import Page from './PageList/Page';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import PaginationWrapper from './PaginationWrapper';
+
+
+const PageList = (props) => {
+  const { appContainer, pageContainer } = props;
+  const { path } = pageContainer.state;
+  const [pages, setPages] = useState(null);
+  const [isLoading, setIsLoading] = useState(false);
+
+  const [activePage, setActivePage] = useState(1);
+  const [totalPages, setTotalPages] = useState(0);
+  const [limit, setLimit] = useState(appContainer.getConfig().recentCreatedLimit);
+  const [offset, setOffset] = useState(0);
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * limit);
+  }
+
+  const updatePageList = useCallback(async() => {
+    const res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+
+    setPages(res.data.pages);
+    setIsLoading(true);
+    setTotalPages(res.data.totalCount);
+    setLimit(res.data.limit);
+    setOffset(res.data.offset);
+  }, [appContainer, path, limit, offset]);
+
+  useEffect(() => {
+    updatePageList();
+  }, [updatePageList]);
+
+
+  if (isLoading === false) {
+    return (
+      <div className="wiki">
+        <div className="text-muted test-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  const pageList = pages.map(page => (
+    <li key={page._id} className="mb-3">
+      <Page page={page} />
+    </li>
+  ));
+
+  return (
+    <div className="page-list-container-create">
+      <ul className="page-list-ul page-list-ul-flat ml-n4">
+        {pageList}
+      </ul>
+      <PaginationWrapper
+        activePage={activePage}
+        changePage={setPageNumber}
+        totalItemsCount={totalPages}
+        pagingLimit={limit}
+        align="center"
+      />
+    </div>
+  );
+
+
+};
+
+const PageListWrapper = withUnstatedContainers(PageList, [AppContainer, PageContainer]);
+
+
+PageList.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer),
+  pageContainer: PropTypes.instanceOf(PageContainer),
+};
+
+export default PageListWrapper;

+ 31 - 0
src/client/js/components/PagePresentationModal.jsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  Modal, ModalBody,
+} from 'reactstrap';
+
+const PagePresentationModal = (props) => {
+
+  function closeModalHandler() {
+    if (props.onClose === null) {
+      return;
+    }
+    props.onClose();
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeModalHandler} className="grw-presentation-modal" unmountOnClose={false}>
+      <ModalBody className="modal-body">
+        <iframe src={props.href} />
+      </ModalBody>
+    </Modal>
+  );
+};
+PagePresentationModal.propTypes = {
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  href: PropTypes.string.isRequired,
+};
+
+
+export default PagePresentationModal;

+ 61 - 72
src/client/js/components/PageTimeline.jsx

@@ -4,6 +4,8 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+import PaginationWrapper from './PaginationWrapper';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import RevisionLoader from './Page/RevisionLoader';
@@ -15,111 +17,98 @@ class PageTimeline extends React.Component {
     super(props);
 
     const { appContainer } = this.props;
-
     this.state = {
-      isEnabled: appContainer.getConfig().isEnabledTimeline,
-      isInitialized: false,
+      activePage: 1,
+      totalPageItems: 0,
+      limit: appContainer.getConfig().recentCreatedLimit,
 
       // TODO: remove after when timeline is implemented with React and inject data with props
       pages: this.props.pages,
     };
 
+    this.handlePage = this.handlePage.bind(this);
   }
 
-  componentWillMount() {
-    if (!this.state.isEnabled) {
-      return;
-    }
 
-    const { appContainer } = this.props;
+  async handlePage(selectedPage) {
+    const { appContainer, pageContainer } = this.props;
+    const { path } = pageContainer.state;
+    const { limit } = this.state;
+    const offset = (selectedPage - 1) * limit;
+    const activePage = selectedPage;
+
+    const res = await appContainer.apiv3Get('/pages/list', { path, limit, offset });
+    const totalPageItems = res.data.totalCount;
+    const pages = res.data.pages;
+    this.setState({
+      activePage,
+      totalPageItems,
+      pages,
+    });
+  }
 
+  componentWillMount() {
+    const { appContainer } = this.props;
     // initialize GrowiRenderer
     this.growiRenderer = appContainer.getRenderer('timeline');
-
-    this.initBsTab();
   }
 
-  /**
-   * initialize Bootstrap Tab event for 'shown.bs.tab'
-   * TODO: remove this method after implement with React
-   */
-  initBsTab() {
-    $('a[data-toggle="tab"][href="#view-timeline"]').on('shown.bs.tab', () => {
-      if (this.state.isInitialized) {
-        return;
-      }
-
-      const pageIdsElm = document.getElementById('page-timeline-data');
-
-      if (pageIdsElm == null || pageIdsElm.text.length === 0) {
-        return;
-      }
-
-      const pages = this.extractDataFromDom();
-
-      this.setState({
-        isInitialized: true,
-        pages,
-      });
+  async componentDidMount() {
+    await this.handlePage(1);
+    this.setState({
+      activePage: 1,
     });
   }
 
-  /**
-   * extract page data from DOM
-   * TODO: remove this method after implement with React
-   */
-  extractDataFromDom() {
-    const pageIdsElm = document.getElementById('page-timeline-data');
-
-    if (pageIdsElm == null || pageIdsElm.text.length === 0) {
-      return null;
-    }
-
-    return JSON.parse(pageIdsElm.text);
-  }
-
   render() {
-    if (!this.state.isEnabled) {
-      return <React.Fragment></React.Fragment>;
-    }
-
     const { pages } = this.state;
-
     if (pages == null) {
       return <React.Fragment></React.Fragment>;
     }
 
-    return pages.map((page) => {
-      return (
-        <div className="timeline-body" key={`key-${page.id}`}>
-          <div className="card card-timeline">
-            <div className="card-header"><a href={page.path}>{page.path}</a></div>
-            <div className="card-body">
-              <RevisionLoader
-                lazy
-                growiRenderer={this.growiRenderer}
-                pageId={page.id}
-                revisionId={page.revision}
-              />
+    return (
+      <div>
+        { pages.map((page) => {
+          return (
+            <div className="timeline-body" key={`key-${page.id}`}>
+              <div className="card card-timeline">
+                <div className="card-header"><a href={page.path}>{page.path}</a></div>
+                <div className="card-body">
+                  <RevisionLoader
+                    lazy
+                    growiRenderer={this.growiRenderer}
+                    pageId={page.id}
+                    revisionId={page.revision}
+                  />
+                </div>
+              </div>
             </div>
-          </div>
-        </div>
-      );
-    });
+          );
+        }) }
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalPageItems}
+          pagingLimit={this.state.limit}
+          align="center"
+        />
+      </div>
+    );
 
   }
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageTimelineWrapper = withUnstatedContainers(PageTimeline, [AppContainer, PageContainer]);
+
 PageTimeline.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pages: PropTypes.arrayOf(PropTypes.object),
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PageTimelineWrapper = withUnstatedContainers(PageTimeline, [AppContainer]);
-
 export default withTranslation()(PageTimelineWrapper);

+ 19 - 1
src/client/js/components/PaginationWrapper.jsx

@@ -143,6 +143,20 @@ class PaginationWrapper extends React.Component {
 
   }
 
+  getListClassName() {
+    const listClassNames = [];
+
+    const { align } = this.props;
+    if (align === 'center') {
+      listClassNames.push('justify-content-center');
+    }
+    if (align === 'right') {
+      listClassNames.push('justify-content-end');
+    }
+
+    return listClassNames.join(' ');
+  }
+
   render() {
     const paginationItems = [];
 
@@ -159,7 +173,7 @@ class PaginationWrapper extends React.Component {
 
     return (
       <React.Fragment>
-        <Pagination size="sm">{paginationItems}</Pagination>
+        <Pagination size="sm" listClassName={this.getListClassName()}>{paginationItems}</Pagination>
       </React.Fragment>
     );
   }
@@ -176,6 +190,10 @@ PaginationWrapper.propTypes = {
   changePage: PropTypes.func.isRequired,
   totalItemsCount: PropTypes.number.isRequired,
   pagingLimit: PropTypes.number.isRequired,
+  align: PropTypes.string,
+};
+PaginationWrapper.defaultProps = {
+  align: 'left',
 };
 
 export default withTranslation()(PaginationWrappered);

+ 4 - 26
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -47,24 +47,11 @@ class GrantSelector extends React.Component {
     this.state = {
       userRelatedGroups: [],
       isSelectGroupModalShown: false,
-      grant: this.props.grant,
-      grantGroup: null,
     };
-    if (this.props.grantGroupId != null) {
-      this.state.grantGroup = {
-        _id: this.props.grantGroupId,
-        name: this.props.grantGroupName,
-      };
-    }
-
-    // retrieve xss library from window
-    this.xss = window.xss;
 
     this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
     this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
-    this.getGroupName = this.getGroupName.bind(this);
-
     this.changeGrantHandler = this.changeGrantHandler.bind(this);
     this.groupListItemClickHandler = this.groupListItemClickHandler.bind(this);
   }
@@ -78,11 +65,6 @@ class GrantSelector extends React.Component {
     this.setState({ isSelectGroupModalShown: false });
   }
 
-  getGroupName() {
-    const grantGroup = this.state.grantGroup;
-    return grantGroup ? this.xss.process(grantGroup.name) : '';
-  }
-
   /**
    * Retrieve user-group-relations data from backend
    */
@@ -109,16 +91,12 @@ class GrantSelector extends React.Component {
       return;
     }
 
-    this.setState({ grant, grantGroup: null });
-
     if (this.props.onUpdateGrant != null) {
       this.props.onUpdateGrant({ grant, grantGroupId: null, grantGroupName: null });
     }
   }
 
   groupListItemClickHandler(grantGroup) {
-    this.setState({ grant: 5, grantGroup });
-
     if (this.props.onUpdateGrant != null) {
       this.props.onUpdateGrant({ grant: 5, grantGroupId: grantGroup._id, grantGroupName: grantGroup.name });
     }
@@ -134,13 +112,13 @@ class GrantSelector extends React.Component {
    */
   renderGrantSelector() {
     const { t } = this.props;
-    const { grant: currentGrant, grantGroup } = this.state;
+    const { grant: currentGrant, grantGroupId } = this.props;
 
     let dropdownToggleBtnColor = null;
     let dropdownToggleLabelElm = null;
 
     const dropdownMenuElems = this.availableGrants.map((opt) => {
-      const label = (opt.grant === 5 && grantGroup != null)
+      const label = (opt.grant === 5 && grantGroupId != null)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -161,11 +139,11 @@ class GrantSelector extends React.Component {
     });
 
     // add specified group option
-    if (grantGroup != null) {
+    if (grantGroupId != null) {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{this.getGroupName()}</span>
+          <span className="label">{this.props.grantGroupName}</span>
         </span>
       );
 

+ 29 - 43
src/client/js/components/OutsideShareLinkModal.jsx → src/client/js/components/ShareLink/ShareLink.jsx

@@ -1,23 +1,18 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
 import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import AppContainer from '../services/AppContainer';
-import PageContainer from '../services/PageContainer';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
 
 import ShareLinkList from './ShareLinkList';
 import ShareLinkForm from './ShareLinkForm';
 
-import { toastSuccess, toastError } from '../util/apiNotification';
+import { toastSuccess, toastError } from '../../util/apiNotification';
 
-class OutsideShareLinkModal extends React.Component {
+class ShareLink extends React.Component {
 
   constructor() {
     super();
@@ -90,33 +85,27 @@ class OutsideShareLinkModal extends React.Component {
     const { t } = this.props;
 
     return (
-      <Modal size="xl" isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-primary text-light">{t('share_links.Shere this page link to public')}
-        </ModalHeader>
-        <ModalBody>
-          <div className="container">
-            <h3 className="grw-modal-head  d-flex  pb-2">
-              { t('share_links.share_link_list') }
-              <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>
-            </h3>
-
-            <div>
-              <ShareLinkList
-                shareLinks={this.state.shareLinks}
-                onClickDeleteButton={this.deleteLinkById}
-              />
-              <button
-                className="btn btn-outline-secondary d-block mx-auto px-5 mb-3"
-                type="button"
-                onClick={this.toggleShareLinkFormHandler}
-              >
-                {this.state.isOpenShareLinkForm ? t('Close') : t('New')}
-              </button>
-              {this.state.isOpenShareLinkForm && <ShareLinkForm onCloseForm={this.toggleShareLinkFormHandler} />}
-            </div>
-          </div>
-        </ModalBody>
-      </Modal>
+      <div className="container p-0">
+        <h3 className="grw-modal-head d-flex pb-2">
+          { t('share_links.share_link_list') }
+          <button className="btn btn-danger ml-auto " type="button" onClick={this.deleteAllLinksButtonHandler}>{t('delete_all')}</button>
+        </h3>
+
+        <div>
+          <ShareLinkList
+            shareLinks={this.state.shareLinks}
+            onClickDeleteButton={this.deleteLinkById}
+          />
+          <button
+            className="btn btn-outline-secondary d-block mx-auto px-5"
+            type="button"
+            onClick={this.toggleShareLinkFormHandler}
+          >
+            {this.state.isOpenShareLinkForm ? t('Close') : t('New')}
+          </button>
+          {this.state.isOpenShareLinkForm && <ShareLinkForm onCloseForm={this.toggleShareLinkFormHandler} />}
+        </div>
+      </div>
     );
   }
 
@@ -125,15 +114,12 @@ class OutsideShareLinkModal extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const ModalControlWrapper = withUnstatedContainers(OutsideShareLinkModal, [AppContainer, PageContainer]);
+const ShareLinkWrapper = withUnstatedContainers(ShareLink, [AppContainer, PageContainer]);
 
-OutsideShareLinkModal.propTypes = {
+ShareLink.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(ModalControlWrapper);
+export default withTranslation()(ShareLinkWrapper);

+ 4 - 4
src/client/js/components/ShareLinkForm.jsx → src/client/js/components/ShareLink/ShareLinkForm.jsx

@@ -6,12 +6,12 @@ import dateFnsFormat from 'date-fns/format';
 import parse from 'date-fns/parse';
 
 import { isInteger } from 'core-js/fn/number';
-import { withUnstatedContainers } from './UnstatedUtils';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
-import { toastSuccess, toastError } from '../util/apiNotification';
+import { toastSuccess, toastError } from '../../util/apiNotification';
 
-import AppContainer from '../services/AppContainer';
-import PageContainer from '../services/PageContainer';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
 
 class ShareLinkForm extends React.Component {
 

+ 3 - 3
src/client/js/components/ShareLinkList.jsx → src/client/js/components/ShareLink/ShareLinkList.jsx

@@ -5,10 +5,10 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 
-import { withUnstatedContainers } from './UnstatedUtils';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
-import AppContainer from '../services/AppContainer';
-import CopyDropdown from './Page/CopyDropdown';
+import AppContainer from '../../services/AppContainer';
+import CopyDropdown from '../Page/CopyDropdown';
 
 const ShareLinkList = (props) => {
 

+ 4 - 3
src/client/js/components/TableOfContents.jsx

@@ -8,6 +8,7 @@ import PageContainer from '../services/PageContainer';
 import NavigationContainer from '../services/NavigationContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
+import TopOfTableContents from './TopOfTableContents';
 import StickyStretchableScroller from './StickyStretchableScroller';
 
 // eslint-disable-next-line no-unused-vars
@@ -26,8 +27,8 @@ const TableOfContents = (props) => {
     const containerElem = document.querySelector('#revision-toc');
     const containerTop = containerElem.getBoundingClientRect().top;
 
-    // window height - revisionToc top - .system-version - .grw-fab-container height
-    return window.innerHeight - containerTop - 20 - 155;
+    // window height - revisionToc top - .system-version - .grw-fab-container height - top-of-table-contents height
+    return window.innerHeight - containerTop - 20 - 155 - 26;
   }, []);
 
   const { tocHtml } = pageContainer.state;
@@ -41,7 +42,7 @@ const TableOfContents = (props) => {
 
   return (
     <>
-      {/* TODO GW-3253 add four contents */}
+      <TopOfTableContents />
       <StickyStretchableScroller
         contentsElemSelector=".revision-toc .markdownIt-TOC"
         stickyElemSelector="#revision-toc"

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

@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import PageAccessoriesContainer from '../services/PageAccessoriesContainer';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import RecentChangesIcon from './Icons/RecentChangesIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+
+import PageAccessoriesModal from './PageAccessoriesModal';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+const TopOfTableContents = (props) => {
+  const { pageAccessoriesContainer } = props;
+
+  function renderModal() {
+    return (
+      <>
+        <PageAccessoriesModal
+          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')}
+        >
+          <RecentChangesIcon />
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-link grw-btn-top-of-table"
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('attachment')}
+        >
+          <AttachmentIcon />
+        </button>
+
+        <button
+          type="button"
+          className="btn btn-link grw-btn-top-of-table"
+          onClick={() => pageAccessoriesContainer.openPageAccessoriesModal('shareLink')}
+        >
+          <ShareLinkIcon />
+        </button>
+
+        <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>
+      {renderModal()}
+    </>
+  );
+};
+/**
+ * Wrapper component for using unstated
+ */
+const TopOfTableContentsWrapper = withUnstatedContainers(TopOfTableContents, [PageAccessoriesContainer]);
+
+TopOfTableContents.propTypes = {
+  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+};
+
+export default withTranslation()(TopOfTableContentsWrapper);

+ 29 - 19
src/client/js/components/User/SeenUserList.jsx

@@ -1,30 +1,40 @@
-import React from 'react';
+// import React from 'react';
 import PropTypes from 'prop-types';
 
+import React, { useState } from 'react';
+import {
+  Button, Popover, PopoverBody,
+} from 'reactstrap';
 import UserPictureList from './UserPictureList';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import PageContainer from '../../services/PageContainer';
 
-class SeenUserList extends React.Component {
-
-  render() {
-    const { pageContainer } = this.props;
-    return (
-      <div className="user-list-content text-truncate text-muted text-right">
-        <span className="text-danger">
-          <span className="seen-user-count">{pageContainer.state.sumOfSeenUsers}</span>
-          <i className="fa fa-fw fa-paw"></i>
-        </span>
-        <span className="mr-1">
-          <UserPictureList users={pageContainer.state.seenUsers} />
-        </span>
-      </div>
-    );
-  }
-
-}
+import FootstampIcon from '../FootstampIcon';
+
+/* eslint react/no-multi-comp: 0, react/prop-types: 0 */
+
+const SeenUserList = (props) => {
+  const [popoverOpen, setPopoverOpen] = useState(false);
+  const toggle = () => setPopoverOpen(!popoverOpen);
+  const { pageContainer } = props;
+  return (
+    <div className="grw-seen-user-list pl-2 ml-2">
+      <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>
+      </Button>
+      <Popover placement="bottom" isOpen={popoverOpen} target="po-seen-user" toggle={toggle} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            <UserPictureList users={pageContainer.state.seenUsers} />
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
 
 SeenUserList.propTypes = {
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,

+ 1 - 23
src/client/js/legacy/crowi.js

@@ -197,31 +197,9 @@ $(() => {
         }
       });
     }
-
-    // presentation
-    let presentaionInitialized = false;
-
-
-    const $b = $('body');
-
-    $(document).on('click', '.toggle-presentation', function(e) {
-      const $a = $(this);
-
-      e.preventDefault();
-      $b.toggleClass('overlay-on');
-
-      if (!presentaionInitialized) {
-        presentaionInitialized = true;
-
-        $('<iframe />').attr({
-          src: $a.attr('href'),
-        }).appendTo($('#presentation-container'));
-      }
-    }).on('click', '.fullscreen-layer', () => {
-      $b.toggleClass('overlay-on');
-    });
   } // end if pageId
 
+  // TODO clean code after GW-3605
   // tab changing handling
   $('a[href="#revision-body"]').on('show.bs.tab', () => {
     const navigationContainer = appContainer.getContainer('NavigationContainer');

+ 7 - 26
src/client/js/models/Linker.js

@@ -1,17 +1,13 @@
 export default class Linker {
 
   constructor(
-      type,
-      label,
-      link,
-      isUsePermanentLink = false,
-      permalink = '',
+      type = Linker.types.markdownLink,
+      label = '',
+      link = '',
   ) {
     this.type = type;
     this.label = label;
     this.link = link;
-    this.isUsePermanentLink = isUsePermanentLink;
-    this.permalink = permalink;
 
     this.generateMarkdownText = this.generateMarkdownText.bind(this);
   }
@@ -30,25 +26,15 @@ export default class Linker {
   }
 
   generateMarkdownText() {
-    let reshapedLink = this.link;
-
-    if (this.isUsePermanentLink && this.permalink != null) {
-      reshapedLink = this.permalink;
-    }
-
-    if (this.label === '') {
-      this.label = reshapedLink;
-    }
-
     if (this.type === Linker.types.pukiwikiLink) {
-      if (this.label === reshapedLink) return `[[${reshapedLink}]]`;
-      return `[[${this.label}>${reshapedLink}]]`;
+      if (this.label === this.link) return `[[${this.link}]]`;
+      return `[[${this.label}>${this.link}]]`;
     }
     if (this.type === Linker.types.growiLink) {
-      return `[${reshapedLink}]`;
+      return `[${this.link}]`;
     }
     if (this.type === Linker.types.markdownLink) {
-      return `[${this.label}](${reshapedLink})`;
+      return `[${this.label}](${this.link})`;
     }
   }
 
@@ -82,15 +68,10 @@ export default class Linker {
       link = label;
     }
 
-    const isUsePermanentLink = false;
-    const permalink = '';
-
     return new Linker(
       type,
       label,
       link,
-      isUsePermanentLink,
-      permalink,
     );
   }
 

+ 58 - 38
src/client/js/services/AdminAppContainer.js

@@ -23,11 +23,15 @@ export default class AdminAppContainer extends Container {
       siteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
+      isMailerSetup: false,
       fromAddress: '',
+      transmissionMethod: '',
       smtpHost: '',
       smtpPort: '',
       smtpUser: '',
       smtpPassword: '',
+      sesAccessKeyId: '',
+      sesSecretAccessKey: '',
       region: '',
       customEndpoint: '',
       bucket: '',
@@ -36,27 +40,6 @@ export default class AdminAppContainer extends Container {
       isEnabledPlugins: true,
     };
 
-    this.changeTitle = this.changeTitle.bind(this);
-    this.changeConfidential = this.changeConfidential.bind(this);
-    this.changeGlobalLang = this.changeGlobalLang.bind(this);
-    this.changeFileUpload = this.changeFileUpload.bind(this);
-    this.changeSiteUrl = this.changeSiteUrl.bind(this);
-    this.changeFromAddress = this.changeFromAddress.bind(this);
-    this.changeSmtpHost = this.changeSmtpHost.bind(this);
-    this.changeSmtpPort = this.changeSmtpPort.bind(this);
-    this.changeSmtpUser = this.changeSmtpUser.bind(this);
-    this.changeSmtpPassword = this.changeSmtpPassword.bind(this);
-    this.changeRegion = this.changeRegion.bind(this);
-    this.changeCustomEndpoint = this.changeCustomEndpoint.bind(this);
-    this.changeBucket = this.changeBucket.bind(this);
-    this.changeAccessKeyId = this.changeAccessKeyId.bind(this);
-    this.changeSecretAccessKey = this.changeSecretAccessKey.bind(this);
-    this.changeIsEnabledPlugins = this.changeIsEnabledPlugins.bind(this);
-    this.updateAppSettingHandler = this.updateAppSettingHandler.bind(this);
-    this.updateSiteUrlSettingHandler = this.updateSiteUrlSettingHandler.bind(this);
-    this.updateMailSettingHandler = this.updateMailSettingHandler.bind(this);
-    this.updateAwsSettingHandler = this.updateAwsSettingHandler.bind(this);
-    this.updatePluginSettingHandler = this.updatePluginSettingHandler.bind(this);
   }
 
   /**
@@ -81,11 +64,15 @@ export default class AdminAppContainer extends Container {
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
+      isMailerSetup: appSettingsParams.isMailerSetup,
       fromAddress: appSettingsParams.fromAddress,
+      transmissionMethod: appSettingsParams.transmissionMethod,
       smtpHost: appSettingsParams.smtpHost,
       smtpPort: appSettingsParams.smtpPort,
       smtpUser: appSettingsParams.smtpUser,
       smtpPassword: appSettingsParams.smtpPassword,
+      sesAccessKeyId: appSettingsParams.sesAccessKeyId,
+      sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
       region: appSettingsParams.region,
       customEndpoint: appSettingsParams.customEndpoint,
       bucket: appSettingsParams.bucket,
@@ -138,6 +125,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fromAddress });
   }
 
+  /**
+   * Change from transmission method
+   */
+  changeTransmissionMethod(transmissionMethod) {
+    this.setState({ transmissionMethod });
+  }
+
   /**
    * Change smtp host
    */
@@ -166,6 +160,20 @@ export default class AdminAppContainer extends Container {
     this.setState({ smtpPassword });
   }
 
+  /**
+   * Change sesAccessKeyId
+   */
+  changeSesAccessKeyId(sesAccessKeyId) {
+    this.setState({ sesAccessKeyId });
+  }
+
+  /**
+   * Change sesSecretAccessKey
+   */
+  changeSesSecretAccessKey(sesSecretAccessKey) {
+    this.setState({ sesSecretAccessKey });
+  }
+
   /**
    * Change region
    */
@@ -239,49 +247,61 @@ export default class AdminAppContainer extends Container {
   }
 
   /**
-   * Update from adress
+   * Update mail setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async updateFromAdressHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/from-address', {
-      fromAddress: this.state.fromAddress,
-    });
-    const { mailSettingParams } = response.data;
-    return mailSettingParams;
+  updateMailSettingHandler() {
+    if (this.state.transmissionMethod === 'smtp') {
+      return this.updateSmtpSetting();
+    }
+    return this.updateSesSetting();
   }
 
   /**
-   * Update mail setting
+   * Update smtp setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async updateMailSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/mail-setting', {
+  async updateSmtpSetting() {
+    const response = await this.appContainer.apiv3.put('/app-settings/smtp-setting', {
       fromAddress: this.state.fromAddress,
+      transmissionMethod: this.state.transmissionMethod,
       smtpHost: this.state.smtpHost,
       smtpPort: this.state.smtpPort,
       smtpUser: this.state.smtpUser,
       smtpPassword: this.state.smtpPassword,
     });
     const { mailSettingParams } = response.data;
+    this.setState({ isMailerSetup: mailSettingParams.isMailerSetup });
     return mailSettingParams;
   }
 
   /**
-   * Initialize mail setting
+   * Update ses setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async initializeMailSettingHandler() {
-    const response = await this.appContainer.apiv3.delete('/app-settings/mail-setting', {});
-    const {
-      mailSettingParams,
-    } = response.data;
-    this.setState(mailSettingParams);
+  async updateSesSetting() {
+    const response = await this.appContainer.apiv3.put('/app-settings/ses-setting', {
+      fromAddress: this.state.fromAddress,
+      transmissionMethod: this.state.transmissionMethod,
+      sesAccessKeyId: this.state.sesAccessKeyId,
+      sesSecretAccessKey: this.state.sesSecretAccessKey,
+    });
+    const { mailSettingParams } = response.data;
+    this.setState({ isMailerSetup: mailSettingParams.isMailerSetup });
     return mailSettingParams;
   }
 
+  /**
+   * send test e-mail
+   * @memberOf AdminAppContainer
+   */
+  async sendTestEmail() {
+    return this.appContainer.apiv3.post('/app-settings/smtp-test');
+  }
+
   /**
    * Update AWS setting
    * @memberOf AdminAppContainer

+ 4 - 19
src/client/js/services/AdminCustomizeContainer.js

@@ -24,7 +24,6 @@ export default class AdminCustomizeContainer extends Container {
       retrieveError: null,
       // set dummy value tile for using suspense
       currentTheme: this.dummyCurrentTheme,
-      currentLayout: '',
       isEnabledTimeline: false,
       isSavedStatesOfTabChanges: false,
       isEnabledAttachTitleHeader: false,
@@ -72,7 +71,6 @@ export default class AdminCustomizeContainer extends Container {
 
       this.setState({
         currentTheme: customizeParams.themeType,
-        currentLayout: customizeParams.layoutType,
         isEnabledTimeline: customizeParams.isEnabledTimeline,
         isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
@@ -97,21 +95,10 @@ export default class AdminCustomizeContainer extends Container {
     }
   }
 
-  /**
-   * Switch layoutType
-   */
-  switchLayoutType(lauoutName) {
-    this.setState({ currentLayout: lauoutName });
-  }
-
   /**
    * Switch themeType
    */
   switchThemeType(themeName) {
-    // can't choose theme when kibela
-    if (this.state.currentLayout === 'kibela') {
-      return;
-    }
     this.setState({ currentTheme: themeName });
 
     // preview if production
@@ -216,7 +203,7 @@ export default class AdminCustomizeContainer extends Container {
   async previewTheme(themeName) {
     try {
       // get theme asset path
-      const response = await this.appContainer.apiv3.get('/customize-setting/layout-theme/asset-path', { themeName });
+      const response = await this.appContainer.apiv3.get('/customize-setting/theme/asset-path', { themeName });
       const { assetPath } = response.data;
 
       const themeLink = document.getElementById('grw-theme-link');
@@ -239,18 +226,16 @@ export default class AdminCustomizeContainer extends Container {
   }
 
   /**
-   * Update layout
+   * Update theme
    * @memberOf AdminCustomizeContainer
    */
-  async updateCustomizeLayoutAndTheme() {
+  async updateCustomizeTheme() {
     try {
-      const response = await this.appContainer.apiv3.put('/customize-setting/layout-theme', {
-        layoutType: this.state.currentLayout,
+      const response = await this.appContainer.apiv3.put('/customize-setting/theme', {
         themeType: this.state.currentTheme,
       });
       const { customizedParams } = response.data;
       this.setState({
-        layoutType: customizedParams.layoutType,
         themeType: customizedParams.themeType,
       });
     }

+ 19 - 2
src/client/js/services/NavigationContainer.js

@@ -19,7 +19,7 @@ export default class NavigationContainer extends Container {
     const { localStorage } = window;
 
     this.state = {
-      editorMode: null,
+      editorMode: 'view',
 
       isDeviceSmallerThanMd: null,
       preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
@@ -86,6 +86,23 @@ export default class NavigationContainer extends Container {
 
   setEditorMode(editorMode) {
     this.setState({ editorMode });
+    if (editorMode === 'view') {
+      $('body').removeClass('on-edit');
+      $('body').removeClass('builtin-editor');
+      $('body').removeClass('hackmd');
+    }
+
+    if (editorMode === 'edit') {
+      $('body').addClass('on-edit');
+      $('body').addClass('builtin-editor');
+    }
+
+    if (editorMode === 'hackmd') {
+      $('body').addClass('on-edit');
+      $('body').addClass('hackmd');
+      $('body').removeClass('builtin-editor');
+    }
+
     this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
   }
 
@@ -136,7 +153,7 @@ export default class NavigationContainer extends Container {
     } = newState;
 
     // get preference on view or edit
-    const preferDrawerMode = editorMode != null ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
+    const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
 
     const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
     const isDrawerOpened = false; // close Drawer anyway

+ 54 - 0
src/client/js/services/PageAccessoriesContainer.js

@@ -0,0 +1,54 @@
+import { Container } from 'unstated';
+
+/**
+ * Service container related to options for Application
+ * @extends {Container} unstated Container
+ */
+
+export default class PageAccessoriesContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      isPageAccessoriesModalShown: false,
+      activeTab: '',
+      // Prevent unnecessary rendering
+      activeComponents: new Set(['']),
+    };
+    this.openPageAccessoriesModal = this.openPageAccessoriesModal.bind(this);
+    this.closePageAccessoriesModal = this.closePageAccessoriesModal.bind(this);
+    this.switchActiveTab = this.switchActiveTab.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PageAccessoriesContainer';
+  }
+
+
+  openPageAccessoriesModal(activeTab) {
+    this.setState({
+      isPageAccessoriesModalShown: true,
+    });
+    this.switchActiveTab(activeTab);
+  }
+
+  closePageAccessoriesModal() {
+    this.setState({
+      isPageAccessoriesModalShown: false,
+      activeTab: '',
+    });
+  }
+
+  switchActiveTab(activeTab) {
+    this.setState({
+      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
+    });
+  }
+
+}

+ 9 - 16
src/client/js/services/PageContainer.js

@@ -51,8 +51,9 @@ export default class PageContainer extends Container {
       isLiked: false,
       isBookmarked: false,
       seenUsers: [],
+      countOfSeenUsers: mainContent.getAttribute('data-page-count-of-seen-users'),
+
       likerUsers: [],
-      sumOfSeenUsers: 0,
       sumOfLikers: 0,
       sumOfBookmarks: 0,
       createdAt: mainContent.getAttribute('data-page-created-at'),
@@ -83,6 +84,7 @@ export default class PageContainer extends Container {
     interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
 
+    this.retrieveSeenUsers();
     this.initStateMarkdown();
     this.initStateOthers();
 
@@ -129,23 +131,14 @@ export default class PageContainer extends Container {
     this.state.markdown = markdown;
   }
 
-  async initStateOthers() {
-
-    const seenUserListElem = document.getElementById('seen-user-list');
-    if (seenUserListElem != null) {
-      const { userIdsStr, sumOfSeenUsers } = seenUserListElem.dataset;
-      this.setState({ sumOfSeenUsers });
-
-      if (userIdsStr === '') {
-        return;
-      }
-
-      const { users } = await this.appContainer.apiGet('/users.list', { user_ids: userIdsStr });
-      this.setState({ seenUsers: users });
+  async retrieveSeenUsers() {
+    const { users } = await this.appContainer.apiGet('/users.list', { user_ids: this.state.seenUserIds });
 
-      this.checkAndUpdateImageUrlCached(users);
-    }
+    this.setState({ seenUsers: users });
+    this.checkAndUpdateImageUrlCached(users);
+  }
 
+  async initStateOthers() {
     const like = await this.appContainer.apiv3Get('/page/like-info', { _id: this.state.pageId });
     this.setState({
       sumOfLikers: like.data.sumOfLikers,

+ 0 - 169
src/client/styles/scss/_comment_kibela.scss

@@ -1,169 +0,0 @@
-.kibela {
-  /* Comment section */
-  %comment-section {
-    position: relative;
-    padding: 1em;
-
-    // speech balloon
-    &:before {
-      position: absolute;
-      top: 1.5em;
-      left: -1em;
-      display: block;
-      width: 0;
-      height: 0;
-      content: '';
-      border-top: 20px solid transparent;
-      border-right: 20px solid $gray-200;
-      border-bottom: 20px solid transparent;
-      border-left: 20px solid transparent;
-      border-left-width: 0;
-
-      @include media-breakpoint-down(xs) {
-        top: 1em;
-      }
-    }
-  }
-
-  %picture {
-    float: left;
-    width: 3em;
-    height: 3em;
-    margin-top: 0.8em;
-
-    @include media-breakpoint-down(xs) {
-      width: 2em;
-      height: 2em;
-    }
-  }
-
-  .page-comments-row {
-    margin: 10px 0px;
-  }
-
-  .page-comments {
-    h4 {
-      margin-bottom: 1em;
-    }
-  }
-  .page-comment {
-    position: relative;
-
-    // ユーザー名
-    .page-comment-creator {
-      margin-top: -0.5em;
-      margin-bottom: 0.5em;
-      font-weight: bold;
-    }
-
-    // ユーザーアイコン
-    .picture {
-      @extend %picture;
-    }
-
-    // コメントセクション
-    .page-comment-main {
-      @extend %comment-section;
-      margin-left: 4.5em;
-      background: $gray-200;
-      border-radius: 0.35em;
-    }
-
-    // コメント本文
-    .page-comment-body {
-      margin-bottom: 0.5em;
-      word-wrap: break-word;
-    }
-  }
-
-  /*
-   * reply
-   */
-  .page-comment-reply {
-    margin-top: 1em;
-  }
-  // remove margin after hidden replies
-  .page-comments-hidden-replies + .page-comment-reply {
-    margin-top: 0;
-  }
-  .page-comment-reply,
-  .page-comment-reply-form {
-    margin-right: 15px;
-    margin-left: 6em;
-  }
-  // reply button
-  .btn.btn-comment-reply {
-    width: 120px;
-    margin-top: 0.5em;
-    margin-right: 15px;
-
-    border-top: none;
-    border-right: none;
-    border-left: none;
-  }
-
-  // display cheatsheet for comment form only
-  .comment-form {
-    .editor-cheatsheet {
-      display: none;
-    }
-
-    position: relative;
-    margin-top: 1em;
-
-    // user icon
-    .picture {
-      @extend %picture;
-    }
-
-    // seciton
-    .comment-form-main {
-      @extend %comment-section;
-      margin-left: 4.5em;
-      @include media-breakpoint-down(xs) {
-        margin-left: 3.5em;
-      }
-      background: #e6e9ec;
-      border-radius: 0.35em;
-      .CodeMirror {
-        border: 0px;
-      }
-    }
-
-    // textarea
-    .comment-write {
-      margin-bottom: 0.5em;
-    }
-    .comment-form-comment {
-      height: 80px;
-      &:focus,
-      &:not(:invalid) {
-        height: 180px;
-        transition: height 0.2s ease-out;
-      }
-    }
-    .CodeMirror {
-      border: 0px !important;
-    }
-
-    //// TODO: migrate to Bootstrap 4
-    // use @include media-breakpoint-*
-    // #page-editor {
-    //   @media (max-width: $screen-sm) {
-    //     .desc-long {
-    //       display: none;
-    //     }
-    //   }
-    // }
-    // @media screen and (max-width: 1400px) {
-    //   .desc-long {
-    //     display: none;
-    //   }
-    //   @media screen and (max-width: 570px) {
-    //     .gfm-cheatsheet {
-    //       display: none;
-    //     }
-    //   }
-    // }
-  }
-}

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

@@ -29,33 +29,11 @@ body {
 }
 
 .main {
+  padding-right: 15px;
+  padding-left: 15px;
   margin-top: 1rem;
 }
 
-.revision-toc {
-  // to get on the Attachment row
-  z-index: 1;
-  overflow: hidden;
-  font-size: 0.9em;
-
-  .revision-toc-content {
-    padding: 10px;
-
-    > ul {
-      padding-left: 0;
-      ul {
-        padding-left: 1em;
-      }
-    }
-
-    // first level of li
-    > ul > li {
-      padding: 5px;
-      margin: 4px 4px 4px 17px;
-    }
-  }
-}
-
 .grw-fab {
   position: fixed;
   right: 1.5rem;

+ 6 - 4
src/client/styles/scss/_layout_growi.scss

@@ -5,9 +5,7 @@
     padding: 0;
   }
 
-  .liker-and-seenusers {
-    // adjusting position with negative margin
-    height: $grw-nav-main-tab-height;
+  .top-of-table-contents {
     line-height: 1.25;
     border-bottom: 1px solid transparent;
 
@@ -16,9 +14,13 @@
 
       .liker-user-count,
       .seen-user-count {
-        font-weight: bold;
+        font-size: 12px;
+        font-weight: bolder;
       }
     }
+    .cls-1 {
+      isolation: isolate;
+    }
   }
 
   .revision-toc {

+ 0 - 170
src/client/styles/scss/_layout_kibela.scss

@@ -1,170 +0,0 @@
-$navbar-height-adjustment: 10px;
-
-body.kibela {
-  .grw-pt-10px {
-    padding-top: 10px !important;
-  }
-
-  /* Logo */
-  .logo {
-    .logo-mark {
-      height: 50px;
-      box-shadow: none;
-
-      svg {
-        width: 60px;
-      }
-    }
-  }
-
-  /* header */
-  .authors {
-    padding-top: 10px;
-
-    li {
-      list-style: none !important;
-    }
-  }
-
-  .panel-heading {
-    border-radius: 0 !important;
-  }
-
-  /* page list */
-  .page-attachments-row {
-    border: 0px;
-  }
-
-  .round-corner {
-    border-radius: 0.35em;
-  }
-
-  .round-corner-top {
-    z-index: absolute;
-    border-radius: 0.35em;
-  }
-
-  .kibela-block {
-    position: relative;
-    top: 30px;
-    right: 100px;
-    bottom: 0px;
-    left: 0px;
-    z-index: absolute;
-    max-width: 1024px;
-    min-height: 8em;
-    margin: auto;
-    border-radius: 0.35em;
-    @include media-breakpoint-down(xs) {
-      top: 0px;
-    }
-  }
-
-  .grw-subnav {
-    position: relative;
-    border: none;
-
-    svg {
-      display: none;
-    }
-
-    &.grw-subnav-user-page {
-      min-height: 128px;
-    }
-
-    @media screen and (max-width: 765px) {
-      padding-top: 30px;
-    }
-
-    @include media-breakpoint-down(xs) {
-      padding-top: 0px;
-    }
-  }
-
-  .revision-toc {
-    position: sticky;
-    top: calc(60px + 5px);
-    right: 10rem;
-    min-width: 100%;
-    margin-top: 40px;
-
-    .revision-toc-content {
-      padding: 0;
-    }
-
-    @media screen and (max-width: 1400px) {
-      &.affix {
-        right: 0rem !important;
-        transition: 0.5s;
-      }
-    }
-  }
-
-  /* admin navigation */
-  .admin-navigation {
-    .list-group-item + .list-group-item.active {
-      margin-top: 2px;
-    }
-  }
-
-  /* Tabs */
-  .nav.nav-tabs {
-    > .nav-item {
-      cursor: pointer;
-
-      > .nav-link {
-        border: none;
-        border-radius: 3px;
-      }
-    }
-  }
-
-  /* edit */
-  .CodeMirror {
-    border-radius: 0.35em;
-  }
-
-  &.on-edit {
-    $header-plus-footer: 42px //  .nav height
-      + 5.5px //                  .kibela-block border-top
-      + 15px //                   .tab-content padding-top
-      + 1px //                    .page-editor-footer border-top
-      + 60px; //                  .page-editor-footer min-height
-
-    @include expand-editor($header-plus-footer, $navbar-height-adjustment);
-
-    .kibela-block {
-      top: 0px;
-      max-width: unset;
-      padding-top: 0px;
-      border: 0px;
-    }
-
-    .tab-content {
-      padding-top: 15px;
-
-      #edit {
-        margin-right: 1em;
-        margin-left: 1em;
-      }
-    }
-
-    .tab-pane {
-      .page-editor-editor-container {
-        margin: 0px;
-        border: none !important;
-      }
-    }
-
-    .page-editor-preview-container {
-      padding-right: 0px !important;
-      padding-left: 2em;
-    }
-
-    .page-editor-footer {
-      min-height: 60px;
-      padding: 13px;
-      margin: 0;
-    }
-  }
-}

+ 13 - 5
src/client/styles/scss/_linkedit-preview.scss

@@ -1,8 +1,16 @@
-.modal .modal-body .linkedit-preview {
-  height: 0;
-  padding-bottom: 50%;
-
+.linkedit-preview {
   .page-editor-preview-body {
-    overflow-y: unset;
+    max-height: 70vh;
+    padding-top: 0px;
+    margin: 0px -10px 0px -10px;
+    .wiki {
+      overflow-y: scroll;
+    }
   }
 }
+
+// page preview button
+.btn-page-preview svg {
+  width: 18px;
+  height: 18px;
+}

+ 16 - 7
src/client/styles/scss/_mixins.scss

@@ -15,9 +15,8 @@
   }
 }
 
-@mixin expand-editor($editor-header-plus-footer, $navbar-height-adjustment: 0px) {
-  $navbar-height: $grw-navbar-border-width + $navbar-height-adjustment;
-  $header-plus-footer: $navbar-height + $editor-header-plus-footer + 2px; // add .main padding-top
+@mixin expand-editor($editor-margin-top) {
+  $header-plus-footer: $editor-margin-top + $grw-editor-navbar-bottom-height;
 
   $editor-margin: $header-plus-footer //
     + 25px //   add .btn-open-dropzone height
@@ -25,8 +24,7 @@
 
   .main {
     width: 100%;
-    height: calc(100vh - #{$navbar-height});
-    padding-top: 2px;
+    height: calc(100vh - #{$editor-margin-top});
     margin-top: 0px !important;
 
     &,
@@ -36,8 +34,7 @@
       flex: 1;
       flex-direction: column;
 
-      .tab-pane#edit,
-      .tab-pane#hackmd {
+      .tab-pane {
         height: calc(100vh - #{$header-plus-footer});
         min-height: calc(100vh - #{$header-plus-footer}); // for IE11
       }
@@ -223,3 +220,15 @@
   transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
   transition-duration: 300ms;
 }
+
+@mixin three-stranded-button($textColor, $borderColor, $bgColorHoverAndActive, $bgColor: white) {
+  color: $textColor;
+  background-color: $bgColor;
+  border-color: $borderColor;
+  &:hover,
+  &:active {
+    color: $textColor;
+    background-color: $bgColorHoverAndActive;
+    border-color: $borderColor;
+  }
+}

+ 0 - 36
src/client/styles/scss/_navbar_kibela.scss

@@ -1,36 +0,0 @@
-/* navbar */
-
-.kibela {
-  .grw-navbar {
-    height: 60px;
-    background: white;
-    border-bottom: solid 1px $gray-200;
-    .navbar-nav {
-      .confidential {
-        color: white;
-        background: #0d3e75;
-      }
-      & > li > a {
-        height: 40px !important;
-        margin-right: 1.5em;
-        color: #3c4a60;
-        border-radius: 0.35em;
-        &:hover {
-          color: #3c4a60;
-        }
-      }
-    }
-
-    .btn-create-page {
-      background: #5584e1;
-      border-radius: 0.35em;
-      &:hover {
-        background: rgb(124, 168, 255);
-      }
-      span,
-      i {
-        color: white;
-      }
-    }
-  }
-}

+ 20 - 41
src/client/styles/scss/_on-edit.scss

@@ -11,11 +11,24 @@ body:not(.on-edit) {
 body.on-edit {
   overflow-y: hidden !important;
 
+  .container {
+    max-width: 100%;
+  }
+
   .grw-navbar {
     position: fixed !important;
     width: 100vw;
   }
 
+  // restrict height of subnav
+  .grw-subnav {
+    max-height: $grw-subnav-max-height-on-edit;
+
+    @include media-breakpoint-up(md) {
+      max-height: $grw-subnav-max-height-md-on-edit;
+    }
+  }
+
   .page-wrapper {
     position: relative;
     top: $grw-navbar-border-width;
@@ -23,11 +36,14 @@ body.on-edit {
   }
 
   // calculate margin
-  $editor-header-plus-footer: 42px //               .nav-tabs height
-    + 1px //                                        .page-editor-footer border-top
-    + $grw-editor-navbar-bottom-height !default; // .EditorNavbarBottom min-height
+  $editor-margin-top: $grw-navbar-border-width + $grw-subnav-max-height-on-edit;
+  @include expand-editor($editor-margin-top);
 
-  @include expand-editor($editor-header-plus-footer);
+  @include media-breakpoint-up(md) {
+    // calculate margin
+    $editor-margin-top: $grw-navbar-border-width + $grw-subnav-max-height-md-on-edit;
+    @include expand-editor($editor-margin-top);
+  }
 
   // for growi layout
   .main {
@@ -82,43 +98,6 @@ body.on-edit {
     padding-bottom: 0;
   }
 
-  .row.grw-subnav {
-    $left-margin: $grw-nav-main-left-tab-width * 2 + 25px; // width of .grw-nav-main-left-tab x 2 + some margin
-    $right-margin: 128px + 94px + 46px; //                    width of all of grw-nav-main-right-tab
-
-    position: absolute;
-    left: $left-margin;
-    z-index: 7; // forward than .CodeMirror-vscrollbar
-    width: calc(100% - #{$left-margin} - #{$right-margin});
-    padding-top: 3px;
-    pointer-events: none; // disable pointer-events because it becomes an obstacle
-
-    background: none;
-
-    > .grw-subnav-container {
-      width: 100%; //   for crowi layout
-      padding: 0; //    for crowi layout
-      pointer-events: initial; // enable pointer-events
-    }
-  }
-
-  .grw-page-path-nav-for-edit {
-    position: absolute;
-
-    .grw-page-path-link {
-      font-size: 20px;
-      line-height: 1em;
-    }
-    .separator {
-      margin-right: 0.1em;
-      margin-left: 0.1em;
-    }
-  }
-
-  .tag-labels {
-    line-height: 1em;
-  }
-
   .grw-editor-navbar-bottom {
     height: $grw-editor-navbar-bottom-height;
 

+ 13 - 0
src/client/styles/scss/_page-presentation.scss

@@ -0,0 +1,13 @@
+.grw-presentation-modal {
+  @include expand-modal-fullscreen(false, false);
+
+  .modal-body {
+    background: black;
+
+    iframe {
+      width: 100%;
+      height: 100%;
+      border: 0;
+    }
+  }
+}

+ 0 - 45
src/client/styles/scss/_page.scss

@@ -135,51 +135,6 @@
   }
 }
 
-/*
- * for Presentation
- */
-.fullscreen-layer {
-  position: fixed;
-  top: 0;
-  left: 0;
-  z-index: 9999;
-  width: 100%;
-  height: 0;
-  background: rgba(0, 0, 0, 0.5);
-  opacity: 0;
-  transition: opacity 0.3s ease-out;
-
-  & > * {
-    box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
-  }
-}
-
-.overlay-on {
-  #wrapper {
-    filter: blur(5px);
-  }
-
-  .fullscreen-layer {
-    height: 100%;
-    opacity: 1;
-  }
-}
-
-#presentation-container {
-  position: absolute;
-  top: 5%;
-  left: 5%;
-  width: 90%;
-  height: 90%;
-  background: black;
-
-  iframe {
-    width: 100%;
-    height: 100%;
-    border: 0;
-  }
-}
-
 .card.grw-page-status-alert {
   $margin-bottom: $grw-navbar-bottom-height + 10px;
 

+ 40 - 0
src/client/styles/scss/_page_accessaries_modal.scss

@@ -0,0 +1,40 @@
+.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);
+  }
+  ul.pagination {
+    margin-bottom: 0rem;
+  }
+}
+
+// revision-history
+// to stay d2h-code-side-line-number in the revision history diff area
+.d2h-wrapper {
+  position: relative;
+}

+ 3 - 2
src/client/styles/scss/_subnav.scss

@@ -1,8 +1,9 @@
 .grw-subnav {
-  padding: 10px 15px;
+  min-height: $grw-subnav-min-height;
+  padding: 8px 15px;
 
   @include media-breakpoint-up(md) {
-    min-height: 115px;
+    min-height: $grw-subnav-min-height-md;
   }
 
   &:hover {

+ 49 - 0
src/client/styles/scss/_toc.scss

@@ -0,0 +1,49 @@
+.top-of-table-contents {
+  flex-wrap: wrap;
+
+  .grw-btn-top-of-table {
+    svg {
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+  .seen-user-count {
+    font-size: 12px;
+    font-weight: bolder;
+  }
+  .grw-seen-user-list {
+    border-left: 1px solid;
+
+    .btn {
+      white-space: nowrap;
+    }
+  }
+
+  .seen-user-popover {
+    max-width: 200px;
+  }
+}
+
+.revision-toc {
+  // to get on the Attachment row
+  z-index: 1;
+  font-size: 0.9em;
+
+  .revision-toc-content {
+    padding: 10px;
+
+    > ul {
+      padding-left: 0;
+      ul {
+        padding-left: 1em;
+      }
+    }
+
+    // first level of li
+    > ul > li {
+      padding: 5px;
+      margin: 4px 4px 4px 17px;
+    }
+  }
+}

+ 5 - 0
src/client/styles/scss/_variables.scss

@@ -9,6 +9,11 @@ $font-family-monospace-not-strictly: Monaco, Menlo, Consolas, 'Courier New', Mei
 $grw-navbar-height: 52px;
 $grw-navbar-border-width: 3.3333px;
 
+$grw-subnav-min-height: 95px;
+$grw-subnav-min-height-md: 115px;
+$grw-subnav-max-height-on-edit: 95px;
+$grw-subnav-max-height-md-on-edit: 115px;
+
 $grw-navbar-bottom-height: 48px;
 $grw-editor-navbar-bottom-height: 48px;
 

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

@@ -29,9 +29,7 @@
 @import 'attachments';
 @import 'comment';
 @import 'comment_growi';
-@import 'comment_kibela';
 @import 'drawio';
-@import 'navbar_kibela';
 @import 'create-page';
 @import 'draft';
 @import 'editor-attachment';
@@ -39,26 +37,27 @@
 @import 'handsontable';
 @import 'layout';
 @import 'layout_growi';
-@import 'layout_kibela';
 @import 'login';
 @import 'me';
 @import 'mirror_mode';
 @import 'navbar';
-@import 'navbar_kibela';
 @import 'on-edit';
 @import 'page_list';
 @import 'page-path';
 @import 'page';
+@import 'page-presentation';
 @import 'search';
 @import 'shortcuts';
 @import 'sidebar';
 @import 'subnav';
 @import 'tag';
+@import 'toc';
 @import 'user';
 @import 'user_growi';
 @import 'staff_credit';
 @import 'waves';
 @import 'wiki';
+@import 'page_accessaries_modal';
 @import 'sharelink';
 @import 'linkedit-preview';
 

Неке датотеке нису приказане због велике количине промена