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

Merge branch 'master' into feat/transplant-tabs-to-modal-for-master-merge

zahmis 5 лет назад
Родитель
Сommit
b50d1f2d05
100 измененных файлов с 2775 добавлено и 586 удалено
  1. 47 0
      .github/workflows/list-unhealthy-branches.yml
  2. 35 0
      .github/workflows/prerelease.yml
  3. 1 9
      .github/workflows/release-rc.yml
  4. 13 12
      .github/workflows/release.yml
  5. 37 3
      CHANGES.md
  6. 4 4
      README.md
  7. 154 0
      bin/github-actions/list-branches.js
  8. 2 2
      bin/github-actions/update-readme.sh
  9. 1 1
      config/env.dev.js
  10. 1 1
      config/logger/config.dev.js
  11. 1 13
      config/webpack.common.js
  12. 10 0
      config/webpack.dev.js
  13. 9 15
      docker/Dockerfile
  14. 4 2
      docker/README.md
  15. 6 5
      package.json
  16. BIN
      public/images/agile-admin/tooltip/Euclid.png
  17. 0 8
      public/images/agile-admin/tooltip/shape1.svg
  18. 0 18
      public/images/agile-admin/tooltip/shape2.svg
  19. 0 5
      public/images/agile-admin/tooltip/shape3.svg
  20. 0 8
      public/images/agile-admin/tooltip/tooltip1.svg
  21. 0 6
      public/images/agile-admin/tooltip/tooltip2.svg
  22. 0 6
      public/images/agile-admin/tooltip/tooltip3.svg
  23. 1 1
      resource/cdn-manifests.js
  24. 4 1
      resource/locales/en_US/admin/admin.json
  25. 46 7
      resource/locales/en_US/translation.json
  26. 3 0
      resource/locales/ja_JP/admin/admin.json
  27. 44 4
      resource/locales/ja_JP/translation.json
  28. 4 1
      resource/locales/zh_CN/admin/admin.json
  29. 35 8
      resource/locales/zh_CN/translation.json
  30. 3 2
      src/client/js/admin.jsx
  31. 9 3
      src/client/js/app.jsx
  32. 5 3
      src/client/js/base.jsx
  33. 29 67
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  34. 62 0
      src/client/js/components/Admin/App/AppSettingsPageContents.jsx
  35. 82 2
      src/client/js/components/Admin/App/MailSetting.jsx
  36. 7 7
      src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  37. 12 8
      src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  38. 4 4
      src/client/js/components/Admin/ExportArchiveDataPage.jsx
  39. 5 5
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  40. 66 0
      src/client/js/components/Admin/Security/DeleteAllShareLinksModal.jsx
  41. 8 1
      src/client/js/components/Admin/Security/SecurityManagement.jsx
  42. 147 0
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  43. 1 1
      src/client/js/components/BookmarkButton.jsx
  44. 4 2
      src/client/js/components/Hotkeys/HotkeysDetector.jsx
  45. 1 1
      src/client/js/components/LikeButton.jsx
  46. 139 0
      src/client/js/components/OutsideShareLinkModal.jsx
  47. 4 1
      src/client/js/components/Page.jsx
  48. 40 18
      src/client/js/components/Page/CopyDropdown.jsx
  49. 38 11
      src/client/js/components/Page/PageManagement.jsx
  50. 113 0
      src/client/js/components/Page/PageShareManagement.jsx
  51. 5 7
      src/client/js/components/Page/RevisionLoader.jsx
  52. 59 0
      src/client/js/components/Page/ShareLinkAlert.jsx
  53. 0 1
      src/client/js/components/PageCreateModal.jsx
  54. 0 1
      src/client/js/components/PageDuplicateModal.jsx
  55. 29 0
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  56. 353 0
      src/client/js/components/PageEditor/LinkEditModal.jsx
  57. 3 3
      src/client/js/components/PageEditor/MarkdownDrawioUtil.js
  58. 48 0
      src/client/js/components/PageEditor/MarkdownLinkUtil.js
  59. 55 139
      src/client/js/components/PageHistory.jsx
  60. 6 3
      src/client/js/components/PageHistory/PageRevisionList.jsx
  61. 0 2
      src/client/js/components/PagePathAutoComplete.jsx
  62. 1 1
      src/client/js/components/PageStatusAlert.jsx
  63. 274 0
      src/client/js/components/ShareLinkForm.jsx
  64. 82 0
      src/client/js/components/ShareLinkList.jsx
  65. 10 6
      src/client/js/components/Sidebar.jsx
  66. 10 1
      src/client/js/components/Sidebar/SidebarContents.jsx
  67. 3 3
      src/client/js/components/Sidebar/SidebarNav.jsx
  68. 33 23
      src/client/js/components/SlackNotification.jsx
  69. 7 2
      src/client/js/components/StickyStretchableScroller.jsx
  70. 21 0
      src/client/js/components/SuspenseUtils.jsx
  71. 0 1
      src/client/js/hackmd-agent.js
  72. 147 0
      src/client/js/models/Linker.js
  73. 17 1
      src/client/js/services/AdminAppContainer.js
  74. 29 0
      src/client/js/services/AdminGeneralSecurityContainer.js
  75. 19 0
      src/client/js/services/AdminSocketIoContainer.js
  76. 3 0
      src/client/js/services/AppContainer.js
  77. 18 16
      src/client/js/services/PageContainer.js
  78. 162 0
      src/client/js/services/PageHistoryContainer.js
  79. 9 5
      src/client/js/services/SocketIoContainer.js
  80. 0 16
      src/client/js/util/interceptor/drawio-interceptor.js
  81. 3 3
      src/client/styles/scss/_admin.scss
  82. 1 1
      src/client/styles/scss/_comment.scss
  83. 2 2
      src/client/styles/scss/_comment_kibela.scss
  84. 4 4
      src/client/styles/scss/_editor-attachment.scss
  85. 1 1
      src/client/styles/scss/_editor-overlay.scss
  86. 4 4
      src/client/styles/scss/_hljs.scss
  87. 5 5
      src/client/styles/scss/_layout.scss
  88. 1 6
      src/client/styles/scss/_layout_kibela.scss
  89. 8 0
      src/client/styles/scss/_linkedit-preview.scss
  90. 7 7
      src/client/styles/scss/_login.scss
  91. 1 1
      src/client/styles/scss/_navbar_kibela.scss
  92. 0 11
      src/client/styles/scss/_notification.scss
  93. 48 14
      src/client/styles/scss/_on-edit.scss
  94. 13 1
      src/client/styles/scss/_override-bootstrap-variables.scss
  95. 2 0
      src/client/styles/scss/_override-bootstrap.scss
  96. 4 4
      src/client/styles/scss/_page.scss
  97. 3 3
      src/client/styles/scss/_page_list.scss
  98. 9 9
      src/client/styles/scss/_search.scss
  99. 12 0
      src/client/styles/scss/_sharelink.scss
  100. 3 3
      src/client/styles/scss/_shortcuts.scss

+ 47 - 0
.github/workflows/list-unhealthy-branches.yml

@@ -0,0 +1,47 @@
+name: List Unhealthy Branches
+
+on:
+  schedule:
+    - cron: '0 16 * * wed'
+
+jobs:
+  list:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        fetch-depth: 0
+
+    - uses: actions/setup-node@v2-beta
+      with:
+        node-version: '14'
+
+    - name: List branches
+      id: list-branches
+      run: |
+        export SLACK_ATTACHMENTS_ILLEGAL=`node bin/github-actions/list-branches --illegal`
+        export SLACK_ATTACHMENTS_INACTIVE=`node bin/github-actions/list-branches --inactive`
+        echo ::set-output name=SLACK_ATTACHMENTS_ILLEGAL::$SLACK_ATTACHMENTS_ILLEGAL
+        echo ::set-output name=SLACK_ATTACHMENTS_INACTIVE::$SLACK_ATTACHMENTS_INACTIVE
+        echo ::set-output name=SLACK_ATTACHMENTS_LENGTH_ILLEGAL::$(echo $SLACK_ATTACHMENTS_ILLEGAL | jq '. | length')
+        echo ::set-output name=SLACK_ATTACHMENTS_LENGTH_INACTIVE::$(echo $SLACK_ATTACHMENTS_INACTIVE | jq '. | length')
+
+    - name: Slack Notification for illegal named branches
+      if: steps.list-branches.outputs.SLACK_ATTACHMENTS_LENGTH_ILLEGAL > 0
+      uses: tokorom/action-slack-incoming-webhook@master
+      env:
+        INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_FOR_DEV }}
+      with:
+        text: There is some *illegal named branches* on GitHub.
+        attachments: ${{ steps.list-branches.outputs.SLACK_ATTACHMENTS_ILLEGAL }}
+
+    - name: Slack Notification for inactive branches
+      if: steps.list-branches.outputs.SLACK_ATTACHMENTS_LENGTH_INACTIVE > 0
+      uses: tokorom/action-slack-incoming-webhook@master
+      env:
+        INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_FOR_DEV }}
+      with:
+        text: There is some *inactive branches* on GitHub.
+        attachments: ${{ steps.list-branches.outputs.SLACK_ATTACHMENTS_INACTIVE }}

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

@@ -0,0 +1,35 @@
+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 .

+ 1 - 9
.github/workflows/release-rc.yml

@@ -14,16 +14,8 @@ jobs:
     steps:
     - uses: actions/checkout@v2
 
-    - name: Cache/Restore yarn cache
-      uses: actions/cache@v1
-      with:
-        path: /usr/local/share/.cache/yarn
-        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
-
     - name: Set up Docker Buildx
-      uses: crazy-max/ghaction-docker-buildx@v1.0.4
+      uses: crazy-max/ghaction-docker-buildx@v3
 
     - name: Login to docker.io registry
       run: |

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

@@ -71,31 +71,32 @@ jobs:
         [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
         echo ::set-env name=SUFFIX::$suffix
 
-    - name: Cache/Restore yarn cache
-      uses: actions/cache@v1
-      with:
-        path: /usr/local/share/.cache/yarn
-        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
-
     - name: Set up Docker Buildx
-      uses: crazy-max/ghaction-docker-buildx@v1.0.4
+      uses: crazy-max/ghaction-docker-buildx@v3
 
     - name: Login to docker.io registry
       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: |
-        CACHE_REF=weseek/growi-cache:4${{ env.SUFFIX }}
         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 \
           --load \
-          --cache-from=type=registry,ref=$CACHE_REF \
-          --cache-to=type=registry,ref=$CACHE_REF,mode=max \
           --file ./docker/Dockerfile .
 
     - name: Docker Tags by SemVer

+ 37 - 3
CHANGES.md

@@ -1,6 +1,29 @@
 # CHANGES
 
-## v4.1.0-RC
+## v4.1.3-RC
+
+* Feature: Create/edit linker with GUI
+
+## v4.1.2
+
+* Fix: Uploaded images do not displayed
+    * Introduced by v4.1.1
+
+## v4.1.1
+
+* Feature: External share link
+* Improvement: Optimize some features that operate revision data
+    * Page history
+    * Renaming pages
+    * Deleting pages
+* Fix: Cmd+c/v/... does not work on Mac
+    * Introduced by v4.1.0
+* Fix: "Append params" switch of CopyDropdown does not work when multiple CopyDropdown instance exists
+* Fix: "Append params" switch of CopyDropdown escapes spaces
+* Fix: Blockdiag does not be rendered
+* Fix: Access token parser
+
+## v4.1.0
 
 ### BREAKING CHANGES
 
@@ -11,21 +34,32 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
 
 ### Updates
 
-* Feature: Config synchronization for multiple GROWI Apps
+* Feature: Server settings synchronization for multiple GROWI Apps
+* Feature: Page status alert synchronization for multiple GROWI Apps
 * Feature: Smooth scroll for anchor links
 * Feature: Mirror Mode with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
 * Improvement: Determine whether the "In Use" badge is displayed or not by attachment ID
+* Improvement: draw.io under NO_CDN environment
+* Fix: Deleting/renaming with recursive option affects pages that are inaccessible to active users
+* Fix: DrawioModal cuts without beginning/ending line
 * Fix: New settings of SMTP and AWS SES are not reflected when server is running
+* Fix: Sidebar layout broken when using Kibela layout
 * Support: Support Node.js v14
+* Support: Update libs
+    * mathjax
 
+## v4.0.11
 
+* Fix: Fab on search result page does not displayed
+* Fix: Adjust margin/padding for search result page
+* Fix: PageAlert broken
+    * Introduced by v4.0.9
 
 ## v4.0.10
 
 * Improvement: Adjust ToC height
 * Fix: Fail to rename/delete a page set as "Anyone with the link"
 
-
 ## v4.0.9
 
 * Feature: Detailed configurations for OpenID Connect

+ 4 - 4
README.md

@@ -111,11 +111,11 @@ See [confirmed versions](https://docs.growi.org/en/dev/startup/dev-env.html#set-
 
 |command|desc|
 |--|--|
-|`npm run build:prod`|Build the client|
-|`npm run server:prod`|Launch the server|
-|`npm start`|Invoke `npm run build:prod` and `npm run server:prod`|
+|`yarn run build:prod`|Build the client|
+|`yarn run server:prod`|Launch the server|
+|`yarn start`|Invoke `yarn run build:prod` and `yarn run server:prod`|
 
-For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup/launch.html#list-of-npm-commands).
+For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup-v2/launch.html#list-of-npm-commands).
 
 
 Documentation

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

@@ -0,0 +1,154 @@
+/* eslint-disable no-console */
+
+/*
+ * USAGE:
+ *  node list-branches [OPTION]
+ *
+ * OPTIONS:
+ *  --inactive : Return inactive branches (default)
+ *  --illegal : Return illegal named branches
+ */
+
+const { execSync } = require('child_process');
+const url = require('url');
+
+const EXCLUDE_TERM_DAYS = 14;
+const EXCLUDE_PATTERNS = [
+  /^feat\/custom-sidebar-2$/,
+  // https://regex101.com/r/Lnx7Pz/3
+  /^dev\/[\d.x]*$/,
+];
+const LEGAL_PATTERNS = [
+  /^master$/,
+  // https://regex101.com/r/p9xswM/4
+  /^(dev|feat|imprv|support|fix|rc|release|tmp)\/.+$/,
+];
+const GITHUB_REPOS_URI = 'https://github.com/weseek/growi/';
+
+class BranchSummary {
+
+  constructor(line) {
+    const splitted = line.split('\t'); // split with '%09'
+
+    this.authorDate = new Date(splitted[0].trim());
+    this.authorName = splitted[1].trim();
+    this.branchName = splitted[2].trim().replace(/^origin\//, '');
+    this.subject = splitted[3].trim();
+  }
+
+}
+
+function getExcludeTermDate() {
+  const date = new Date();
+  date.setDate(date.getDate() - EXCLUDE_TERM_DAYS);
+  return date;
+}
+
+function getBranchSummaries() {
+  // exec git for-each-ref
+  const out = execSync(`\
+    git for-each-ref refs/remotes \
+      --sort=-committerdate \
+      --format='%(authordate:iso) %09 %(authorname) %09 %(refname:short) %09 %(subject)'
+  `).toString();
+
+  // parse
+  const summaries = out
+    .split('\n')
+    .filter(v => v !== '') // trim empty string
+    .map(line => new BranchSummary(line))
+    .filter((summary) => { // exclude branches that matches to patterns
+      return !EXCLUDE_PATTERNS.some(pattern => pattern.test(summary.branchName));
+    });
+
+  return summaries;
+}
+
+function getGitHubCommitsUrl(branchName) {
+  return url.resolve(GITHUB_REPOS_URI, `commits/${branchName}`);
+}
+
+function getGitHubComparingLink(branchName) {
+  const label = `master &lt;- ${branchName}`;
+  const link = url.resolve(GITHUB_REPOS_URI, `compare/${branchName}`);
+  return `<${link}|${label}>`;
+}
+
+/**
+ * @see https://api.slack.com/messaging/composing/layouts#building-attachments
+ * @see https://github.com/marketplace/actions/slack-incoming-webhook
+ *
+ * @param {string} mode
+ * @param {BranchSummary} summaries
+ */
+function printSlackAttachments(mode, summaries) {
+  const color = (mode === 'illegal') ? 'warning' : '#999999';
+
+  const attachments = summaries.map((summary) => {
+    const {
+      authorName, authorDate, branchName, subject,
+    } = summary;
+
+    return {
+      color,
+      title: branchName,
+      title_link: getGitHubCommitsUrl(branchName),
+      fields: [
+        {
+          title: 'Author Date',
+          value: authorDate,
+          short: true,
+        },
+        {
+          title: 'Author',
+          value: authorName,
+          short: true,
+        },
+        {
+          title: 'Last Commit Subject',
+          value: subject,
+        },
+        {
+          title: 'Comparing Link',
+          value: getGitHubComparingLink(branchName),
+        },
+      ],
+    };
+  });
+
+  console.log(JSON.stringify(attachments));
+}
+
+async function main(mode) {
+  const summaries = getBranchSummaries();
+
+  let filteredSummaries;
+
+  switch (mode) {
+    case 'illegal':
+      filteredSummaries = summaries
+        .filter((summary) => { // exclude branches that matches to patterns
+          return !LEGAL_PATTERNS.some(pattern => pattern.test(summary.branchName));
+        });
+      break;
+    default: {
+      const excludeTermDate = getExcludeTermDate();
+      filteredSummaries = summaries
+        .filter((summary) => {
+          return summary.authorDate < excludeTermDate;
+        });
+      break;
+    }
+  }
+
+  printSlackAttachments(mode, filteredSummaries);
+}
+
+const args = process.argv.slice(2);
+
+let mode = 'inactive';
+if (args.length > 0 && args[0] === '--illegal') {
+  mode = 'illegal';
+}
+
+main(mode);

+ 2 - 2
bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.0-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.1\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.1-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 1 - 1
config/env.dev.js

@@ -11,7 +11,7 @@ module.exports = {
   HACKMD_URI: 'http://localhost:3010',
   HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
-  // CONFIG_PUBSUB_SERVER_TYPE: 'nchan',
+  // S2SMSG_PUBSUB_SERVER_TYPE: 'nchan',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 1 - 1
config/logger/config.dev.js

@@ -16,7 +16,7 @@ module.exports = {
   'growi:routes:login-passport': 'debug',
   'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
-  'growi:service:config-pubsub:*': 'debug',
+  'growi:service:s2s-messaging:*': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   // 'growi:service:mail': 'debug',
   'growi:lib:search': 'debug',

+ 1 - 13
config/webpack.common.js

@@ -8,7 +8,7 @@ const webpack = require('webpack');
  */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
-const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
+
 const helpers = require('../src/lib/util/helpers');
 
 /*
@@ -96,10 +96,6 @@ module.exports = (options) => {
             basenameAsNamespace: true,
           },
         },
-        { // see https://github.com/abpetkov/switchery/issues/120
-          test: /switchery\.js$/,
-          loader: 'imports-loader?module=>false,exports=>false,define=>false,this=>window',
-        },
         /*
          * File loader for supporting images, for example, in CSS files.
          */
@@ -127,14 +123,6 @@ module.exports = (options) => {
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
       new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
 
-      new HardSourceWebpackPlugin(),
-      new HardSourceWebpackPlugin.ExcludeModulePlugin([
-        {
-          // see https://github.com/mzgoddard/hard-source-webpack-plugin/blob/master/README.md#excludemoduleplugin
-          test: /mini-css-extract-plugin[\\/]dist[\\/]loader/,
-        },
-      ]),
-
       new LodashModuleReplacementPlugin({
         flattening: true,
       }),

+ 10 - 0
config/webpack.dev.js

@@ -7,6 +7,8 @@
  */
 const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
+const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
+
 const helpers = require('../src/lib/util/helpers');
 
 /**
@@ -61,6 +63,14 @@ module.exports = require('./webpack.common')({
       analyzerMode: ANALYZE ? 'server' : 'disabled',
     }),
 
+    new HardSourceWebpackPlugin(),
+    new HardSourceWebpackPlugin.ExcludeModulePlugin([
+      {
+        // see https://github.com/mzgoddard/hard-source-webpack-plugin/blob/master/README.md#excludemoduleplugin
+        test: /mini-css-extract-plugin[\\/]dist[\\/]loader/,
+      },
+    ]),
+
   ],
   optimization: {},
   performance: {

+ 9 - 15
docker/Dockerfile

@@ -12,20 +12,17 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi
 
-COPY ./package.json ${appDir}/
-COPY ./yarn.lock ${appDir}/
 WORKDIR ${appDir}
+COPY ./package.json ./
+COPY ./yarn.lock ./
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn
+RUN yarn
 # install official plugins
-RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+RUN yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
 # install peerDependencies
-RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn add -D react-images@1.0.0 react-motion
+RUN yarn add -D react-images@1.0.0 react-motion
 
 
 
@@ -35,8 +32,7 @@ RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
 FROM deps-resolver AS deps-resolver-prod
 
 # shrink dependencies for production
-RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn install --production
+RUN yarn install --production
 
 
 
@@ -79,8 +75,7 @@ ENV appDir /opt/growi
 WORKDIR ${appDir}
 
 # build
-RUN --mount=type=cache,target=./node_modules/.cache \
-  yarn build:prod
+RUN yarn build:prod
 
 # remove except artifacts
 WORKDIR /tmp
@@ -92,14 +87,13 @@ WORKDIR ${appDir}
 ##
 ## release
 ##
-FROM node:12-alpine
+FROM node:14-alpine
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi
 
 # install tini
-RUN --mount=type=cache,target=/var/cache/apk \
-  apk add tini su-exec
+RUN apk add tini su-exec
 
 COPY docker/docker-entrypoint.sh /
 RUN chmod 700 /docker-entrypoint.sh

+ 4 - 2
docker/README.md

@@ -10,8 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.0.0`, `4.0`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.0/docker/Dockerfile)
-* [`4.0.0-nocdn`, `4.0-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.0/docker/Dockerfile)
+* [`4.1.0`, `4.1`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
+* [`4.1.0-nocdn`, `4.1-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.1.0/docker/Dockerfile)
+* [`4.0.11`, `4.0`(Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
+* [`4.0.11-nocdn`, `4.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.0.11/docker/Dockerfile)
 * [`3.8.0`, `3.8`, `3` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 * [`3.8.0-nocdn`, `3.8-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v3.8.0/docker/Dockerfile)
 

+ 6 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.0-RC",
+  "version": "4.1.3-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -137,10 +137,11 @@
     "passport-twitter": "^1.0.4",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
+    "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "slack-node": "^0.1.8",
-    "socket.io": "^2.0.3",
+    "socket.io": "^2.3.0",
     "stream-to-promise": "^2.2.0",
     "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
@@ -148,7 +149,7 @@
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "validator": "^12.0.0",
-    "websocket": "^1.0.31",
+    "ws": "^7.3.1",
     "xss": "^1.0.6"
   },
   "devDependencies": {
@@ -203,7 +204,7 @@
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.1.1",
-    "markdown-it-drawio-viewer": "^1.2.0",
+    "markdown-it-drawio-viewer": "^1.3.1",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
@@ -240,7 +241,7 @@
     "rs-i18n": "^0.0.9",
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
-    "socket.io-client": "^2.0.3",
+    "socket.io-client": "^2.3.0",
     "sticky-events": "^3.1.3",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",

BIN
public/images/agile-admin/tooltip/Euclid.png


+ 0 - 8
public/images/agile-admin/tooltip/shape1.svg

@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" viewBox="0 0 200 200" preserveAspectRatio="none">
-<path fill="#00AEEF" d="M174.209,28.162C154.645,8.88,124.289,2.08,100.06,2.08c-0.074,0-0.06-0.079-0.06-0.079
-	s0.015,0.079-0.06,0.079c-24.229,0-54.584,6.8-74.149,26.082C5.417,48.242,3,75,3,100s2.418,51.758,22.792,71.838
-	c19.564,19.281,49.92,26.082,74.149,26.082c0.074,0,0.06,0.079,0.06,0.079s-0.015-0.079,0.06-0.079
-	c24.229,0,54.585-6.801,74.149-26.082C194.582,151.758,197,125,197,100S194.582,48.242,174.209,28.162z"/>
-</svg>

+ 0 - 18
public/images/agile-admin/tooltip/shape2.svg

@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200px" height="150px" viewBox="0 0 200 150">
-<g>
-	<path id="path1" fill="#010101" d="M159.599,137.909c0.975,3.397,4.717,5.548,8.161,4.988c3.489-0.443,6.558-3.466,6.685-7.043
-		c0.217-3.19-1.805-6.34-5.113-7.118c-3.417-1.079-7.469,0.508-9.138,3.701c-0.91,1.636-1.166,3.624-0.612,5.414"/>
-	<path id="path2" fill="#010101" d="M130.646,125.253c1.368,4.656,6.393,7.288,10.806,6.718c4.763-0.451,9.26-4.276,9.71-9.394
-		c0.369-3.779-1.902-7.583-5.244-9.144c-5.404-2.732-12.557-0.222-14.908,5.448c-0.841,1.945-1.018,4.214-0.388,6.294"/>
-	<path id="path3" fill="#010101" d="M184.112,144.325c0.704,2.461,3.412,4.016,5.905,3.611c2.526-0.318,4.746-2.509,4.841-5.093
-		c0.153-2.315-1.483-4.54-3.703-5.155c-2.474-0.781-5.405,0.37-6.612,2.681c-0.657,1.181-0.845,2.619-0.442,3.917"/>
-	<path id="path4" fill="#010101" d="M53.149,10.686c12.101-3.695,24.478-1.625,33.84,4.571c3.187-5.687,8.381-10.144,14.943-12.148
-		c10.427-3.185,21.37,0.699,28.159,8.982c15.606-3.76,31.369,4.398,35.804,18.915c3.269,10.699-0.488,21.956-8.71,29.388
-		c0.395,0.934,0.762,1.882,1.064,2.873c4.73,15.485-3.992,31.889-19.473,36.617c-5.073,1.551-10.251,1.625-15.076,0.518
-		c-3.58,10.605-12.407,19.55-24.386,23.211c-15.015,4.586-30.547-0.521-39.226-11.624c-2.861,1.991-6.077,3.564-9.583,4.636
-		c-18.43,5.631-38.04-5.068-43.785-23.874l-0.083-0.272C1.564,75.375,9.696,57.543,25.083,50.302
-		C23.349,33.157,34.85,16.276,53.149,10.686L53.149,10.686z"/>
-</g>
-</svg>

+ 0 - 5
public/images/agile-admin/tooltip/shape3.svg

@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200px" height="150px" viewBox="0 0 200 150" enable-background="new 0 0 200 150" xml:space="preserve">
-<polygon fill="#FFFFFF" stroke="#000000" points="29.857,3.324 171.111,3.324 196.75,37.671 184.334,107.653 104.355,136.679 100,146.676 96.292,136.355 16.312,107.653 3.25,37.671 "/>
-</svg>

+ 0 - 8
public/images/agile-admin/tooltip/tooltip1.svg

@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="30px" height="20px" viewBox="0 0 30 20">
-	<g>
-		<path fill="#fb9678" d="M7.065,7.067C13.462,10.339,15,19.137,15,19.137V0H0C0,0,1.865,4.407,7.065,7.067z"/>
-		<path fill="#fb9678" d="M15,0v19.137c0,0,1.537-8.797,7.936-12.07C28.135,4.407,30,0,30,0H15z"/>
-	</g>
-</svg>

+ 0 - 6
public/images/agile-admin/tooltip/tooltip2.svg

@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80px" height="80px" viewBox="0 0 80 80">
-<path fill="#e35583" d="M80,0c0,0-5.631,14.445-25.715,27.213C29.946,42.688,12.79,33.997,3.752,30.417
-	c-3.956-1.567-4.265,1.021-2.966,3.814C16.45,67.934,80,79.614,80,79.614l0,0V0z"/>
-</svg>

+ 0 - 6
public/images/agile-admin/tooltip/tooltip3.svg

@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="60px" height="120px" preserveAspectRatio="none" viewBox="0 0 60 120">
-<path fill="#ffffff" d="M55.451-0.043C55.451-0.043,66.059-41.066,55.451-0.043C51.069,16.9,0.332,119.498,0.332,119.498
-	S43.365,18.315,39.532-0.043c-4.099-19.616,0,0,0,0"/>
-</svg>

+ 1 - 1
resource/cdn-manifests.js

@@ -39,7 +39,7 @@ module.exports = {
     },
     {
       name: 'mathjax',
-      url: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js',
+      url: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
       args: {
         async: true,
         integrity: '',

+ 4 - 1
resource/locales/en_US/admin/admin.json

@@ -26,7 +26,7 @@
     "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
     "update": "Update",
-    "mail_settings": "Mail settings",
+    "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.",
@@ -35,6 +35,9 @@
     "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?",
     "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.",

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

@@ -19,6 +19,7 @@
   "Tag": "Tag",
   "Tags": "Tags",
   "New": "New",
+  "Close": "Close",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",
   "add": "Add",
@@ -48,12 +49,16 @@
   "attachment_data": "Attachment Data",
   "Presentation Mode": "Presentation",
   "Not available for guest": "Not available for guest",
+  "Create Archive Page": "Create Archive Page",
+  "File type": "File type",
+  "Include Attachment File": "Include Attachment File",
+  "Include Comment": "Include Comment",
+  "Include Subordinated Page": "Include Subordinated Page",
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
   "Last_Login": "Last login",
   "Share": "Share",
-  "Share Link": "Share Link",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
   "Go to this version": "View this version",
@@ -191,6 +196,25 @@
     "password_is_not_set": "Password is not set"
   },
   "security_settings": "Security settings",
+  "share_links": {
+    "Shere this page link to public": "Shere this page link to public",
+    "share_link_list": "Share link list",
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "expire": "Expiration",
+    "Days": "Days",
+    "Custom": "Custom",
+    "description": "description",
+    "enter_desc": "Enter description",
+    "Unlimited": "unlimited",
+    "Issue": "Issue",
+    "share_settings" :"Share settings",
+    "Invalid_Number_of_Date" : "You entered invalid value"
+  },
   "API Settings": "API settings",
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
@@ -209,8 +233,8 @@
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",
     "Page URL": "Page URL",
-    "Parmanent link": "Parmanent link",
-    "Page path and parmanent link": "Page path and parmanent link",
+    "Permanent link": "Permanent link",
+    "Page path and permanent link": "Page path and permanent link",
     "Markdown link": "Markdown link"
   },
   "search_help": {
@@ -251,7 +275,9 @@
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
-      "stale_plural": "More than {{count}} years has passed since last update."
+      "stale_plural": "More than {{count}} years has passed since last update.",
+      "expiration": "This share link will expire at <strong>{{expiredAt}}</strong>.",
+      "no_deadline":"This page has no expiration date"
     }
   },
   "page_edit": {
@@ -338,12 +364,16 @@
   },
   "toaster": {
     "update_successed": "Succeeded to update {{target}}",
+    "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "remove_user_admin": "Succeeded to remove {{username}} admin",
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
-    "remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "remove_user_success": "Succeeded to removing {{username}}",
+    "remove_external_user_success": "Succeeded to remove {{accountId}}",
+    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
+    "issue_share_link": "Succeeded to issue new share link",
+    "remove_share_link": "Succeeded to remove {{count}} share links",
     "failed_to_reset_password":"Failed to reset password"
   },
   "template": {
@@ -412,6 +442,10 @@
     "someone_editing": "Someone editing this page on HackMD",
     "this_page_has_draft": "This page has a draft on HackMD"
   },
+  "slack_notification": {
+    "popover_title": "Slack Notification",
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+  },
   "search_result": {
     "result_meta": "Found \"{{keyword}}\" in {{total}}.",
     "deletion_mode_btn_lavel": "Select and delete page",
@@ -702,6 +736,11 @@
     "Registration successful": "Registration successful",
     "Setup": "Setup"
   },
+  "export_bulk": {
+    "failed_to_export": "Failed to export",
+    "export_page_markdown": "Export page as Markdown",
+    "export_page_pdf": "Export page as PDF"
+  },
   "message": {
     "successfully_connected": "Successfully Connected!",
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",

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

@@ -35,6 +35,9 @@
     "host": "ホスト",
     "port": "ポート",
     "user": "ユーザー",
+    "initialize_mail_settings": "設定を初期化",
+    "initialize_mail_modal_header": "メール設定の初期化",
+    "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
     "aws_settings": "AWS設定",
     "aws_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "no_smtp_setting": "また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。",

+ 44 - 4
resource/locales/ja_JP/translation.json

@@ -19,6 +19,7 @@
   "Tag": "タグ",
   "Tags": "タグ",
   "New": "作成",
+  "Close": "閉じる",
   "Shortcuts": "ショートカット",
   "eg": "例:",
   "add": "追加",
@@ -30,6 +31,7 @@
   "User": "ユーザー",
   "status": "ステータス",
   "account_id": "アカウントID",
+  "Initialize": "初期化",
   "Update": "更新",
   "Update Page": "ページを更新",
   "Warning": "注意",
@@ -48,12 +50,16 @@
   "attachment_data": "添付データ",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "Create Archive Page": "アーカイブページの作成",
+  "File type": "ファイル形式",
+  "Include Attachment File": "添付ファイルも含める",
+  "Include Comment": "コメントも含める",
+  "Include Subordinated Page": "配下ページも含める",
   "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
   "Last_Login": "最終ログイン",
   "Share": "共有",
-  "Share Link": "共有用リンク",
   "Markdown Link": "Markdown形式のリンク",
   "Create/Edit Template": "テンプレートページの作成/編集",
   "Go to this version": "このバージョンを見る",
@@ -193,6 +199,25 @@
     "password_is_not_set": "パスワードが設定されていません"
   },
   "security_settings": "セキュリティ設定",
+  "share_links": {
+    "Shere this page link to public": "外部に共有するリンクを発行する",
+    "share_link_list": "共有リンクリスト",
+    "share_link_management": "共有リンク管理",
+    "No_share_links":"共有リンクが存在しません",
+    "Share Link": "共有用リンク",
+    "Page Path": "ページパス",
+    "share_link_notice":"共有リンクを全て削除します",
+    "delete_all_share_links":"全ての共有リンクを削除します",
+    "expire": "有効期限",
+    "Days": "日間",
+    "Custom": "カスタム",
+    "description": "概要",
+    "enter_desc": "概要を入力",
+    "Unlimited": "無期限",
+    "Issue": "発行",
+    "share_settings" :"共有設定",
+    "Invalid_Number_of_Date" : "有効期限の日数には整数を入力してください"
+  },
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
@@ -211,8 +236,8 @@
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",
     "Page URL": "ページURL",
-    "Parmanent link": "パーマリンク",
-    "Page path and parmanent link": "ページ名とパーマリンク",
+    "Permanent link": "パーマリンク",
+    "Page path and permanent link": "ページ名とパーマリンク",
     "Markdown link": "マークダウン形式のリンク"
   },
   "search_help": {
@@ -252,7 +277,9 @@
       "duplicated": "このページは <code>%s</code> から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
-      "stale": "このページは最終更新日から{{count}}年以上が経過しています。"
+      "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
+      "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。",
+      "no_deadline": "このページに有効期限は設定されていません。"
     }
   },
   "page_edit": {
@@ -339,12 +366,16 @@
   },
   "toaster": {
     "update_successed": "{{target}}を更新しました",
+    "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
     "remove_external_user_success": "{{accountId}}を削除しました",
+    "remove_share_link_success": "{{shareLinkId}}を削除しました",
+    "issue_share_link": "共有リンクを作成しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました",
     "failed_to_reset_password":"パスワードのリセットに失敗しました"
   },
   "template": {
@@ -413,6 +444,10 @@
     "someone_editing": "このページは、HackMD で編集されています。",
     "this_page_has_draft": "このページは、HackMD のドラフトがあります。"
   },
+  "slack_notification": {
+    "popover_title": "Slack 通知",
+    "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
+  },
   "search_result": {
     "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
     "deletion_mode_btn_lavel": "ページを指定して削除",
@@ -694,6 +729,11 @@
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
   },
+  "export_bulk": {
+    "failed_to_export": "ページのエクスポートに失敗しました",
+    "export_page_markdown": "マークダウン形式でページをエクスポート",
+    "export_page_pdf": "PDF形式でページをエクスポート"
+  },
   "message": {
     "successfully_connected": "接続に成功しました!",
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",

+ 4 - 1
resource/locales/zh_CN/admin/admin.json

@@ -35,6 +35,9 @@
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
+    "initialize_mail_settings": "重置邮件设置",
+    "initialize_mail_modal_header": "重置邮件设置",
+    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
 		"aws_settings": "AWS设置",
 		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
 		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
@@ -315,4 +318,4 @@
 			"transfer_pages": "转移到另一组"
 		}
 	}
-}
+}

+ 35 - 8
resource/locales/zh_CN/translation.json

@@ -19,7 +19,8 @@
 	"administrator": "管理员",
 	"Tag": "标签",
 	"Tags": "Tags",
-	"New": "新建",
+  "New": "新建",
+  "Close": "Close",
 	"Shortcuts": "快捷方式",
 	"eg": "e.g.",
 	"add": "添加",
@@ -31,7 +32,8 @@
 	"User": "用户",
 	"status": "状态",
 	"account_id": "用户Id",
-	"Update": "更新",
+	"Initialize": "初始化",
+  "Update": "更新",
 	"Update Page": "更新本页",
 	"Warning": "警告",
 	"Sign in": "登录",
@@ -48,13 +50,13 @@
   "History": "历史",
   "attachment_data": "Attachment Data",
 	"Presentation Mode": "演示文稿",
-	"Not available for guest": "Not available for guest",
+  "Not available for guest": "Not available for guest",
 	"username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
-	"Last_Login": "上次登录",
+  "Last_Login": "上次登录",
 	"Share": "分享",
-	"Share Link": "分享链接",
+  "Share Link": "分享链接",
 	"Markdown Link": "Markdown链接",
 	"Create/Edit Template": "创建/编辑 模板页面",
 	"Unportalize": "未启动",
@@ -249,7 +251,8 @@
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
-			"stale_plural": "自上次更新以来已过去{{count}年以上。"
+      "stale_plural": "自上次更新以来已过去{{count}年以上。",
+      "no_deadline": "This page has no expiration date"
 		}
 	},
 	"page_edit": {
@@ -335,6 +338,7 @@
 	},
 	"toaster": {
 		"update_successed": "Succeeded to update {{target}}",
+    "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
 		"remove_user_admin": "Succeeded to remove {{username}} admin ",
 		"activate_user_success": "Succeeded to activating {{username}}",
@@ -408,8 +412,31 @@
 		"not_initialized": "HackmdEditor component has not initialized",
 		"someone_editing": "Someone editing this page on HackMD",
 		"this_page_has_draft": "This page has a draft on HackMD"
-	},
-	"security_settings": "安全设置",
+  },
+  "slack_notification": {
+    "popover_title": "Slack Notification",
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+  },
+  "security_settings": "安全设置",
+  "share_links": {
+    "Shere this page link to public": "Shere this page link to public",
+    "share_link_list": "Share link list",
+    "share_link_management": "Share Link Management",
+    "No_share_links":"No share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "expire": "Expiration",
+    "Days": "Days",
+    "Custom": "Custom",
+    "description": "description",
+    "enter_desc": "Enter description",
+    "Unlimited": "unlimited",
+    "Issue": "Issue",
+    "share_settings" :"Share settings",
+    "Invalid_Number_of_Date" : "You entered invalid value"
+  },
 	"security_setting": {
 		"Security settings": "安全设置",
 		"Guest Users Access": "来宾用户访问",

+ 3 - 2
src/client/js/admin.jsx

@@ -25,6 +25,7 @@ import AdminNavigation from './components/Admin/Common/AdminNavigation';
 
 import NavigationContainer from './services/NavigationContainer';
 
+import AdminSocketIoContainer from './services/AdminSocketIoContainer';
 import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import AdminUserGroupDetailContainer from './services/AdminUserGroupDetailContainer';
@@ -50,11 +51,11 @@ const logger = loggerFactory('growi:admin');
 appContainer.initContents();
 
 const { i18n } = appContainer;
-const websocketContainer = appContainer.getContainer('WebsocketContainer');
 
 // create unstated container instance
 const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
+const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
 const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
@@ -64,9 +65,9 @@ const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const injectableContainers = [
   appContainer,
-  websocketContainer,
   navigationContainer,
   adminAppContainer,
+  adminSocketIoContainer,
   adminHomeContainer,
   adminCustomizeContainer,
   adminUsersContainer,

+ 9 - 3
src/client/js/app.jsx

@@ -19,6 +19,7 @@ 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';
@@ -31,6 +32,7 @@ import TableOfContents from './components/TableOfContents';
 import PersonalSettings from './components/Me/PersonalSettings';
 import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
+import PageHistoryContainer from './services/PageHistoryContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
@@ -45,17 +47,18 @@ const logger = loggerFactory('growi:cli:app');
 appContainer.initContents();
 
 const { i18n } = appContainer;
-const websocketContainer = appContainer.getContainer('WebsocketContainer');
+const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 // create unstated container instance
 const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
+const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, websocketContainer, navigationContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
+  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -87,6 +90,7 @@ if (pageContainer.state.pageId != null) {
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-attachment': <PageAttachment />,
     'page-management': <PageManagement />,
+    'page-share-management': <PageShareManagement />,
 
     'revision-toc': <TableOfContents />,
     'seen-user-list': <SeenUserList />,
@@ -139,7 +143,9 @@ $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <ErrorBoundary>
-        <PageHistory pageId={pageContainer.state.pageId} crowi={appContainer} />
+        <Provider inject={injectableContainers}>
+          <PageHistory />
+        </Provider>
       </ErrorBoundary>
     </I18nextProvider>, document.getElementById('revision-history'),
   );

+ 5 - 3
src/client/js/base.jsx

@@ -6,11 +6,12 @@ import Xss from '@commons/service/xss';
 import GrowiNavbar from './components/Navbar/GrowiNavbar';
 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 WebsocketContainer from './services/WebsocketContainer';
+import SocketIoContainer from './services/SocketIoContainer';
 import PageCreateModal from './components/PageCreateModal';
 
 const logger = loggerFactory('growi:cli:app');
@@ -26,7 +27,7 @@ window.xss = xss;
 // create unstated container instance
 const appContainer = new AppContainer();
 // eslint-disable-next-line no-unused-vars
-const websocketContainer = new WebsocketContainer(appContainer);
+const socketIoContainer = new SocketIoContainer(appContainer);
 
 appContainer.initApp();
 
@@ -45,9 +46,10 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
+  'grw-fab-container': <Fab />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
-  'grw-fab-container': <Fab />,
+  'share-link-alert': <ShareLinkAlert />,
 };
 
 export { appContainer, componentMappings };

+ 29 - 67
src/client/js/components/Admin/App/AppSettingsPage.jsx

@@ -1,92 +1,54 @@
-import React, { Fragment } from 'react';
-import { withTranslation } from 'react-i18next';
+import React, { Suspense } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 
-import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
 
-import AppSetting from './AppSetting';
-import SiteUrlSetting from './SiteUrlSetting';
-import MailSetting from './MailSetting';
-import AwsSetting from './AwsSetting';
-import PluginSetting from './PluginSetting';
+import AppSettingsPageContents from './AppSettingsPageContents';
 
 const logger = loggerFactory('growi:appSettings');
 
-class AppSettingsPage extends React.Component {
-
-  async componentDidMount() {
-    const { adminAppContainer } = this.props;
-
-    try {
-      await adminAppContainer.retrieveAppSettingsData();
-    }
-    catch (err) {
-      toastError(err);
-      adminAppContainer.setState({ retrieveError: err.message });
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
+function AppSettingsPage(props) {
+  return (
+    <Suspense
+      fallback={(
         <div className="row">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('App Settings')}</h2>
-            <AppSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
-            <SiteUrlSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
-            <MailSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.aws_settings')}</h2>
-            <AwsSetting />
-          </div>
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
         </div>
+)}
+    >
+      <RenderAppSettingsPageWrapper />
+    </Suspense>
+  );
+}
 
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
-            <PluginSetting />
-          </div>
-        </div>
-      </Fragment>
-    );
+function RenderAppSettingsPage(props) {
+  if (props.adminAppContainer.state.title === props.adminAppContainer.dummyTitle) {
+    throw new Promise(async() => {
+      try {
+        await props.adminAppContainer.retrieveAppSettingsData();
+      }
+      catch (err) {
+        toastError(err);
+        props.adminAppContainer.setState({ retrieveError: err.message });
+        logger.error(err);
+      }
+    });
   }
 
+  return <AppSettingsPageContents />;
 }
 
-AppSettingsPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+RenderAppSettingsPage.propTypes = {
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const AppSettingsPageWrapper = withUnstatedContainers(AppSettingsPage, [AppContainer, AdminAppContainer]);
-
+const RenderAppSettingsPageWrapper = withUnstatedContainers(RenderAppSettingsPage, [AdminAppContainer]);
 
-export default withTranslation()(AppSettingsPageWrapper);
+export default AppSettingsPage;

+ 62 - 0
src/client/js/components/Admin/App/AppSettingsPageContents.jsx

@@ -0,0 +1,62 @@
+import React, { Fragment } from 'react';
+import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+
+import AppSetting from './AppSetting';
+import SiteUrlSetting from './SiteUrlSetting';
+import MailSetting from './MailSetting';
+import AwsSetting from './AwsSetting';
+import PluginSetting from './PluginSetting';
+
+class AppSettingsPageContents extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <div className="row">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('App Settings')}</h2>
+            <AppSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
+            <SiteUrlSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
+            <MailSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.aws_settings')}</h2>
+            <AwsSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
+            <PluginSetting />
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+AppSettingsPageContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(AppSettingsPageContents);

+ 82 - 2
src/client/js/components/Admin/App/MailSetting.jsx

@@ -3,12 +3,12 @@ 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 AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:appSettings');
 
@@ -17,7 +17,28 @@ class MailSetting extends React.Component {
   constructor(props) {
     super(props);
 
+    this.state = {
+      isInitializeValueModalOpen: false,
+    };
+
+    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.submitHandler = this.submitHandler.bind(this);
+    this.initialize = this.initialize.bind(this);
+  }
+
+  openInitializeValueModal() {
+    this.setState({ isInitializeValueModalOpen: true });
+  }
+
+  closeInitializeValueModal() {
+    this.setState({ isInitializeValueModalOpen: false });
   }
 
   async submitHandler() {
@@ -33,6 +54,26 @@ class MailSetting extends React.Component {
     }
   }
 
+  async initialize() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      const mailSettingParams = await adminAppContainer.initializeMailSettingHandler();
+      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.mail_settings') }));
+      // convert values to '' if value is null for overwriting values of inputs with refs
+      this.emailInput.current.value = mailSettingParams.fromAddress || '';
+      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;
 
@@ -45,6 +86,7 @@ class MailSetting extends React.Component {
             <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) }}
@@ -59,6 +101,7 @@ class MailSetting extends React.Component {
             <input
               className="form-control"
               type="text"
+              ref={this.hostInput}
               defaultValue={adminAppContainer.state.smtpHost || ''}
               onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
             />
@@ -67,6 +110,7 @@ class MailSetting extends React.Component {
             <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) }}
             />
@@ -79,6 +123,7 @@ class MailSetting extends React.Component {
             <input
               className="form-control"
               type="text"
+              ref={this.userInput}
               defaultValue={adminAppContainer.state.smtpUser || ''}
               onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
             />
@@ -88,13 +133,48 @@ class MailSetting extends React.Component {
             <input
               className="form-control"
               type="password"
+              ref={this.passwordInput}
               defaultValue={adminAppContainer.state.smtpPassword || ''}
               onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
             />
           </div>
         </div>
 
-        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+        <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>
+        <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>
     );
   }

+ 7 - 7
src/client/js/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
-import WebsocketContainer from '../../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../../services/AdminSocketIoContainer';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import StatusTable from './StatusTable';
@@ -45,22 +45,22 @@ class ElasticsearchManagement extends React.Component {
   }
 
   initWebSockets() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
-    socket.on('admin:addPageProgress', (data) => {
+    socket.on('addPageProgress', (data) => {
       this.setState({
         isRebuildingProcessing: true,
       });
     });
 
-    socket.on('admin:finishAddPage', (data) => {
+    socket.on('finishAddPage', (data) => {
       this.setState({
         isRebuildingProcessing: false,
         isRebuildingCompleted: true,
       });
     });
 
-    socket.on('admin:rebuildingFailed', (data) => {
+    socket.on('rebuildingFailed', (data) => {
       toastError(new Error(data.error), 'Rebuilding Index has failed.');
     });
   }
@@ -224,12 +224,12 @@ class ElasticsearchManagement extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const ElasticsearchManagementWrapper = withUnstatedContainers(ElasticsearchManagement, [AppContainer, WebsocketContainer]);
+const ElasticsearchManagementWrapper = withUnstatedContainers(ElasticsearchManagement, [AppContainer, AdminSocketIoContainer]);
 
 ElasticsearchManagement.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 
 export default withTranslation()(ElasticsearchManagementWrapper);

+ 12 - 8
src/client/js/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
-import WebsocketContainer from '../../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../../services/AdminSocketIoContainer';
 
 import ProgressBar from '../Common/ProgressBar';
 
@@ -25,17 +25,21 @@ class RebuildIndexControls extends React.Component {
   }
 
   initWebSockets() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
-    socket.on('admin:addPageProgress', (data) => {
+    socket.on('addPageProgress', (data) => {
       this.setState({
-        ...data,
+        total: data.totalCount,
+        current: data.count,
+        skip: data.skipped,
       });
     });
 
-    socket.on('admin:finishAddPage', (data) => {
+    socket.on('finishAddPage', (data) => {
       this.setState({
-        ...data,
+        total: data.totalCount,
+        current: data.count,
+        skip: data.skipped,
       });
     });
 
@@ -97,12 +101,12 @@ class RebuildIndexControls extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const RebuildIndexControlsWrapper = withUnstatedContainers(RebuildIndexControls, [AppContainer, WebsocketContainer]);
+const RebuildIndexControlsWrapper = withUnstatedContainers(RebuildIndexControls, [AppContainer, AdminSocketIoContainer]);
 
 RebuildIndexControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 
   isRebuildingProcessing: PropTypes.bool.isRequired,
   isRebuildingCompleted: PropTypes.bool.isRequired,

+ 4 - 4
src/client/js/components/Admin/ExportArchiveDataPage.jsx

@@ -8,7 +8,7 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../services/AppContainer';
-import WebsocketContainer from '../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../services/AdminSocketIoContainer';
 
 import ProgressBar from './Common/ProgressBar';
 
@@ -67,7 +67,7 @@ class ExportArchiveDataPage extends React.Component {
   }
 
   setupWebsocketEventHandler() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
     // websocket event
     socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
@@ -248,12 +248,12 @@ class ExportArchiveDataPage extends React.Component {
 ExportArchiveDataPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPage, [AppContainer, WebsocketContainer]);
+const ExportArchiveDataPageWrapper = withUnstatedContainers(ExportArchiveDataPage, [AppContainer, AdminSocketIoContainer]);
 
 export default withTranslation()(ExportArchiveDataPageWrapper);

+ 5 - 5
src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -8,7 +8,7 @@ import ImportOptionForRevisions from '@commons/models/admin/import-option-for-re
 
 import { withUnstatedContainers } from '../../../UnstatedUtils';
 import AppContainer from '../../../../services/AppContainer';
-import WebsocketContainer from '../../../../services/WebsocketContainer';
+import AdminSocketIoContainer from '../../../../services/AdminSocketIoContainer';
 import { toastSuccess, toastError } from '../../../../util/apiNotification';
 
 
@@ -102,7 +102,7 @@ class ImportForm extends React.Component {
   }
 
   setupWebsocketEventHandler() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
     // websocket event
     // eslint-disable-next-line object-curly-newline
@@ -142,7 +142,7 @@ class ImportForm extends React.Component {
   }
 
   teardownWebsocketEventHandler() {
-    const socket = this.props.websocketContainer.getWebSocket();
+    const socket = this.props.adminSocketIoContainer.getSocket();
 
     socket.removeAllListeners('admin:onProgressForImport');
     socket.removeAllListeners('admin:onTerminateForImport');
@@ -493,7 +493,7 @@ class ImportForm extends React.Component {
 ImportForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 
   fileName: PropTypes.string,
   innerFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
@@ -504,6 +504,6 @@ ImportForm.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ImportFormWrapper = withUnstatedContainers(ImportForm, [AppContainer, WebsocketContainer]);
+const ImportFormWrapper = withUnstatedContainers(ImportForm, [AppContainer, AdminSocketIoContainer]);
 
 export default withTranslation()(ImportFormWrapper);

+ 66 - 0
src/client/js/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+const DeleteAllShareLinksModal = React.memo((props) => {
+  const { t } = props;
+
+  function closeModal() {
+    if (props.onClose == null) {
+      return;
+    }
+
+    props.onClose();
+  }
+
+  function deleteAllLinkHandler() {
+    if (props.onClickDeleteButton == null) {
+      return;
+    }
+
+    props.onClickDeleteButton();
+
+    closeModal();
+  }
+
+  function closeButtonHandler() {
+    closeModal();
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
+      <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
+        <span>
+          <i className="icon-fw icon-fire"></i>
+          {t('share_links.delete_all_share_links')}
+        </span>
+      </ModalHeader>
+      <ModalBody>
+        { t('share_links.share_link_notice')}
+      </ModalBody>
+      <ModalFooter>
+        <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
+        <Button color="danger" onClick={deleteAllLinkHandler}>
+          <i className="icon icon-fire"></i>
+          {t('Delete')}
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+});
+
+DeleteAllShareLinksModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  onClickDeleteButton: PropTypes.func,
+};
+
+export default withTranslation()(DeleteAllShareLinksModal);

+ 8 - 1
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -18,6 +18,7 @@ import GoogleSecuritySetting from './GoogleSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import TwitterSecuritySetting from './TwitterSecuritySetting';
 import FacebookSecuritySetting from './FacebookSecuritySetting';
+import ShareLinkSetting from './ShareLinkSetting';
 
 class SecurityManagement extends React.Component {
 
@@ -44,10 +45,16 @@ class SecurityManagement extends React.Component {
     const { activeTab, activeComponents } = this.state;
     return (
       <Fragment>
-        <div>
+        <div className="mb-5">
           <SecuritySetting />
         </div>
 
+        {/* Shared Link List */}
+        <div className="mb-5">
+          <ShareLinkSetting />
+        </div>
+
+
         {/* XSS configuration link */}
         <div className="mb-5">
           <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>

+ 147 - 0
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -0,0 +1,147 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import PaginationWrapper from '../../PaginationWrapper';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+
+import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
+import ShareLinkList from '../../ShareLinkList';
+
+class ShareLinkSetting extends React.Component {
+
+  constructor() {
+    super();
+
+    this.state = {
+      isDeleteConfirmModalShown: false,
+    };
+    this.getShareLinkList = this.getShareLinkList.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
+    this.deleteLinkById = this.deleteLinkById.bind(this);
+  }
+
+  componentWillMount() {
+    this.getShareLinkList(1);
+  }
+
+  async getShareLinkList(page) {
+    try {
+      await this.props.adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: false });
+  }
+
+  async deleteAllLinksButtonHandler() {
+    const { t, appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete('/share-links/all');
+      const { deletedCount } = res.data;
+      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.getShareLinkList(1);
+  }
+
+  async deleteLinkById(shareLinkId) {
+    const { t, appContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.getShareLinkList(adminGeneralSecurityContainer.state.shareLinksActivePage);
+  }
+
+
+  render() {
+    const { t, adminGeneralSecurityContainer } = this.props;
+
+    const pager = (
+      <div className="pull-right my-3">
+        <PaginationWrapper
+          activePage={adminGeneralSecurityContainer.state.shareLinksActivePage}
+          changePage={this.getShareLinkList}
+          totalItemsCount={adminGeneralSecurityContainer.state.totalshareLinks}
+          pagingLimit={adminGeneralSecurityContainer.state.shareLinksPagingLimit}
+        />
+      </div>
+    );
+
+    const deleteAllButton = (
+      adminGeneralSecurityContainer.state.shareLinks.length > 0
+        ? (
+          <button
+            className="pull-right btn btn-danger"
+            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
+        />
+
+        <DeleteAllShareLinksModal
+          isOpen={this.state.isDeleteConfirmModalShown}
+          onClose={this.closeDeleteConfirmModal}
+          onClickDeleteButton={this.deleteAllLinksButtonHandler}
+        />
+
+      </Fragment>
+    );
+  }
+
+}
+
+const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [AppContainer, AdminGeneralSecurityContainer]);
+
+ShareLinkSetting.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+};
+
+export default withTranslation()(ShareLinkSettingWrapper);

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

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

+ 4 - 2
src/client/js/components/Hotkeys/HotkeysDetector.jsx

@@ -44,12 +44,14 @@ const HotkeysDetector = (props) => {
    * evaluate the key user pressed and trigger onDetected
    */
   const checkHandler = useCallback((event) => {
-    event.preventDefault();
-
     const eventKey = getKeyExpression(event);
 
     hotkeyStrokes.forEach((hotkeyStroke) => {
+      // if any stroke is completed
       if (hotkeyStroke.evaluate(eventKey)) {
+        // cancel the key event
+        event.preventDefault();
+        // invoke detected handler
         onDetected(hotkeyStroke.stroke);
       }
     });

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

@@ -44,7 +44,7 @@ class LikeButton extends React.Component {
         type="button"
         onClick={this.handleClick}
         className={`btn rounded-circle btn-like border-0 d-edit-none
-        ${this.state.isLiked ? 'btn-info active' : 'btn-outline-info'}`}
+        ${this.state.isLiked ? 'active' : ''}`}
       >
         <i className="icon-like"></i>
       </button>

+ 139 - 0
src/client/js/components/OutsideShareLinkModal.jsx

@@ -0,0 +1,139 @@
+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 AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import ShareLinkList from './ShareLinkList';
+import ShareLinkForm from './ShareLinkForm';
+
+import { toastSuccess, toastError } from '../util/apiNotification';
+
+class OutsideShareLinkModal extends React.Component {
+
+  constructor() {
+    super();
+    this.state = {
+      shareLinks: [],
+      isOpenShareLinkForm: false,
+    };
+
+    this.toggleShareLinkFormHandler = this.toggleShareLinkFormHandler.bind(this);
+    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
+    this.deleteLinkById = this.deleteLinkById.bind(this);
+  }
+
+  componentDidMount() {
+    this.retrieveShareLinks();
+  }
+
+  async retrieveShareLinks() {
+    const { appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await appContainer.apiv3.get('/share-links/', { relatedPage: pageId });
+      const { shareLinksResult } = res.data;
+      this.setState({ shareLinks: shareLinksResult });
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  toggleShareLinkFormHandler() {
+    this.setState({ isOpenShareLinkForm: !this.state.isOpenShareLinkForm });
+    this.retrieveShareLinks();
+  }
+
+  async deleteAllLinksButtonHandler() {
+    const { t, appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await appContainer.apiv3.delete('/share-links/', { relatedPage: pageId });
+      const count = res.data.n;
+      toastSuccess(t('toaster.remove_share_link', { count }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.retrieveShareLinks();
+  }
+
+  async deleteLinkById(shareLinkId) {
+    const { t, appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.retrieveShareLinks();
+  }
+
+  render() {
+    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>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ModalControlWrapper = withUnstatedContainers(OutsideShareLinkModal, [AppContainer, PageContainer]);
+
+OutsideShareLinkModal.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);

+ 4 - 1
src/client/js/components/Page.jsx

@@ -9,6 +9,7 @@ import EditorContainer from '../services/EditorContainer';
 
 import MarkdownTable from '../models/MarkdownTable';
 
+import LinkEditModal from './PageEditor/LinkEditModal';
 import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import DrawioModal from './PageEditor/DrawioModal';
@@ -29,6 +30,7 @@ class Page extends React.Component {
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
+    this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
@@ -59,7 +61,7 @@ class Page extends React.Component {
    */
   launchDrawioModal(beginLineNumber, endLineNumber) {
     const markdown = this.props.pageContainer.state.markdown;
-    const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber, endLineNumber);
+    const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber);
     const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
     this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
     this.drawioModal.current.show(drawioData);
@@ -137,6 +139,7 @@ class Page extends React.Component {
 
         { isLoggedIn && (
           <>
+            <LinkEditModal ref={this.LinkEditModal} />
             <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
           </>

+ 40 - 18
src/client/js/components/Page/CopyDropdown.jsx

@@ -21,6 +21,8 @@ class CopyDropdown extends React.Component {
       isParamsAppended: true,
     };
 
+    this.id = (Math.random() * 1000).toString();
+
     this.toggle = this.toggle.bind(this);
     this.showToolTip = this.showToolTip.bind(this);
     this.generatePagePathWithParams = this.generatePagePathWithParams.bind(this);
@@ -53,6 +55,15 @@ class CopyDropdown extends React.Component {
     return `${search}${hash}`;
   }
 
+  encodeSpaces(str) {
+    if (str == null) {
+      return null;
+    }
+
+    // Encode SPACE and IDEOGRAPHIC SPACE
+    return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
+  }
+
   generatePagePathWithParams() {
     const { pagePath } = this.props;
     return decodeURI(`${pagePath}${this.uriParams}`);
@@ -60,17 +71,20 @@ class CopyDropdown extends React.Component {
 
   generatePagePathUrl() {
     const { origin } = window.location;
-    return `${origin}${this.generatePagePathWithParams()}`;
+    return `${origin}${this.encodeSpaces(this.generatePagePathWithParams())}`;
   }
 
   generatePermalink() {
-    const { pageId } = this.props;
+    const { pageId, isShareLinkMode } = this.props;
 
     if (pageId == null) {
       return null;
     }
+    if (isShareLinkMode) {
+      return decodeURI(`${origin}/share/${pageId}`);
+    }
 
-    return decodeURI(`${origin}/${pageId}${this.uriParams}`);
+    return this.encodeSpaces(decodeURI(`${origin}/${pageId}${this.uriParams}`));
   }
 
   generateMarkdownLink() {
@@ -90,28 +104,36 @@ class CopyDropdown extends React.Component {
   );
 
   render() {
-    const { t, pageId } = this.props;
+    const {
+      t, pageId, isShareLinkMode,
+    } = this.props;
     const { isParamsAppended } = this.state;
 
     const pagePathWithParams = this.generatePagePathWithParams();
     const pagePathUrl = this.generatePagePathUrl();
     const permalink = this.generatePermalink();
 
-    const { DropdownItemContents } = this;
+    const copyTarget = isShareLinkMode ? `copyShareLink${pageId}` : 'copyPagePathDropdown';
+    const dropdownToggleStyle = isShareLinkMode ? 'btn btn-secondary' : 'd-block text-muted bg-transparent btn-copy border-0';
+
+    const { id, DropdownItemContents } = this;
+
+    const customSwitchForParamsId = `customSwitchForParams_${id}`;
 
     return (
       <>
-        <UncontrolledDropdown id="copyPagePathDropdown" className="grw-copy-dropdown">
-
+        <UncontrolledDropdown id={copyTarget} className="grw-copy-dropdown">
           <DropdownToggle
             caret
-            className="d-block text-muted bg-transparent btn-copy border-0"
+            className={dropdownToggleStyle}
             style={this.props.buttonStyle}
           >
-            <i className="ti-clipboard"></i>
+            { isShareLinkMode ? (
+              <>Copy Link</>
+            ) : (<i className="ti-clipboard"></i>)}
           </DropdownToggle>
 
-          <DropdownMenu>
+          <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
 
             <div className="d-flex align-items-center justify-content-between">
               <DropdownItem header className="px-3">
@@ -120,12 +142,12 @@ class CopyDropdown extends React.Component {
               <div className="px-3 custom-control custom-switch custom-switch-sm">
                 <input
                   type="checkbox"
-                  id="customSwitchForParams"
+                  id={customSwitchForParamsId}
                   className="custom-control-input"
                   checked={isParamsAppended}
                   onChange={e => this.setState({ isParamsAppended: !isParamsAppended })}
                 />
-                <label className="custom-control-label small" htmlFor="customSwitchForParams">Append params</label>
+                <label className="custom-control-label small" htmlFor={customSwitchForParamsId}>Append params</label>
               </div>
             </div>
 
@@ -146,25 +168,24 @@ class CopyDropdown extends React.Component {
                 <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl} />
               </DropdownItem>
             </CopyToClipboard>
-
             <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Parmanent Link */}
+            {/* Permanent Link */}
             { pageId && (
               <CopyToClipboard text={permalink} onCopy={this.showToolTip}>
                 <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Parmanent link')} contents={permalink} />
+                  <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={permalink} />
                 </DropdownItem>
               </CopyToClipboard>
             )}
 
             <DropdownItem divider className="my-0"></DropdownItem>
 
-            {/* Page path + Parmanent Link */}
+            {/* Page path + Permanent Link */}
             { pageId && (
               <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={this.showToolTip}>
                 <DropdownItem className="px-3">
-                  <DropdownItemContents title={t('copy_to_clipboard.Page path and parmanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
+                  <DropdownItemContents title={t('copy_to_clipboard.Page path and permanent link')} contents={<>{pagePathWithParams}<br />{permalink}</>} />
                 </DropdownItem>
               </CopyToClipboard>
             )}
@@ -183,7 +204,7 @@ class CopyDropdown extends React.Component {
 
         </UncontrolledDropdown>
 
-        <Tooltip placement="bottom" isOpen={this.state.tooltipOpen} target="copyPagePathDropdown" fade={false}>
+        <Tooltip placement="bottom" isOpen={this.state.tooltipOpen} target={copyTarget} fade={false}>
           copied!
         </Tooltip>
       </>
@@ -198,6 +219,7 @@ CopyDropdown.propTypes = {
   pagePath: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   buttonStyle: PropTypes.object,
+  isShareLinkMode: PropTypes.bool,
 };
 
 export default withTranslation()(CopyDropdown);

+ 38 - 11
src/client/js/components/Page/PageManagement.jsx

@@ -1,5 +1,6 @@
 import React, { useState } from 'react';
 import PropTypes from 'prop-types';
+import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 
 import { isTopPage } from '@commons/util/path-utils';
@@ -83,6 +84,10 @@ const PageManagement = (props) => {
   }
 
   function renderModals() {
+    if (currentUser == null) {
+      return null;
+    }
+
     return (
       <>
         <PageRenameModal
@@ -108,19 +113,41 @@ const PageManagement = (props) => {
     );
   }
 
+  function renderDotsIconForCurrentUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
+          data-toggle="dropdown"
+        >
+          <i className="icon-options-vertical"></i>
+        </button>
+      </>
+    );
+  }
+
+  function renderDotsIconForGuestUser() {
+    return (
+      <>
+        <button
+          type="button"
+          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
+          id="icon-options-guest-tltips"
+        >
+          <i className="icon-options-vertical"></i>
+        </button>
+        <UncontrolledTooltip placement="top" target="icon-options-guest-tltips">
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      </>
+    );
+  }
+
+
   return (
     <>
-      <a
-        role="button"
-        className={`nav-link dropdown-toggle dropdown-toggle-no-caret ${currentUser == null && 'dropdown-toggle-disabled'}`}
-        href="#"
-        data-toggle={`${currentUser == null ? 'tooltip' : 'dropdown'}`}
-        data-placement="top"
-        data-container="body"
-        title={t('Not available for guest')}
-      >
-        <i className="icon-options-vertical"></i>
-      </a>
+      {currentUser == null ? renderDotsIconForGuestUser() : renderDotsIconForCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
         {!isTopPagePath && renderDropdownItemForNotTopPage()}
         <button className="dropdown-item" type="button" onClick={openPageTemplateModalHandler}>

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

@@ -0,0 +1,113 @@
+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';
+
+const PageShareManagement = (props) => {
+  const { t, appContainer, pageContainer } = props;
+
+  const { currentUser } = appContainer;
+
+  const [isOutsideShareLinkModalShown, setIsOutsideShareLinkModalShown] = useState(false);
+
+
+  function openOutsideShareLinkModalHandler() {
+    setIsOutsideShareLinkModalShown(true);
+  }
+
+  function closeOutsideShareLinkModalHandler() {
+    setIsOutsideShareLinkModalShown(false);
+  }
+
+  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;
+  }
+
+  function renderModals() {
+    if (currentUser == null) {
+      return null;
+    }
+
+    return (
+      <>
+        <OutsideShareLinkModal
+          isOpen={isOutsideShareLinkModalShown}
+          onClose={closeOutsideShareLinkModalHandler}
+        />
+      </>
+    );
+  }
+
+
+  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>
+      </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);

+ 5 - 7
src/client/js/components/Page/RevisionLoader.jsx

@@ -40,22 +40,20 @@ class RevisionLoader extends React.Component {
       this.setState({ isLoading: true });
     }
 
-    const requestData = {
-      page_id: this.props.pageId,
-      revision_id: this.props.revisionId,
-    };
+    const { pageId, revisionId } = this.props;
+
 
     // load data with REST API
     try {
-      const res = await this.props.appContainer.apiGet('/revisions.get', requestData);
+      const res = await this.props.appContainer.apiv3Get(`/revisions/${revisionId}`, { pageId });
 
       this.setState({
-        markdown: res.revision.body,
+        markdown: res.data.revision.body,
         error: null,
       });
 
       if (this.props.onRevisionLoaded != null) {
-        this.props.onRevisionLoaded(res.revision);
+        this.props.onRevisionLoaded(res.data.revision);
       }
     }
     catch (error) {

+ 59 - 0
src/client/js/components/Page/ShareLinkAlert.jsx

@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+const ShareLinkAlert = (props) => {
+  const { t } = props;
+
+
+  const shareContent = document.getElementById('is-shared-page');
+  const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
+  const createdAt = shareContent.getAttribute('data-share-link-created-at');
+
+  function generateRatio() {
+    const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+    const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
+    return remainingTime / wholeTime;
+  }
+
+  let ratio = 1;
+
+  if (expiredAt !== '') {
+    ratio = generateRatio();
+  }
+
+  function specifyColor() {
+    let color;
+    if (ratio >= 0.75) {
+      color = 'success';
+    }
+    else if (ratio < 0.75 && ratio >= 0.5) {
+      color = 'info';
+    }
+    else if (ratio < 0.5 && ratio >= 0.25) {
+      color = 'warning';
+    }
+    else {
+      color = 'danger';
+    }
+    return color;
+  }
+
+  return (
+    <p className={`alert alert-${specifyColor()} py-3 px-4`}>
+      <i className="icon-fw icon-link"></i>
+      {(expiredAt === '' ? <span>{t('page_page.notice.no_deadline')}</span>
+      // eslint-disable-next-line react/no-danger
+      : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+      )}
+    </p>
+  );
+};
+
+
+ShareLinkAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(ShareLinkAlert);

+ 0 - 1
src/client/js/components/PageCreateModal.jsx

@@ -162,7 +162,6 @@ const PageCreateModal = (props) => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    crowi={appContainer}
                     initializedPath={pathname}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}

+ 0 - 1
src/client/js/components/PageDuplicateModal.jsx

@@ -80,7 +80,6 @@ const PageDuplicateModal = (props) => {
               {isReachable
               ? (
                 <PagePathAutoComplete
-                  crowi={appContainer}
                   initializedPath={path}
                   onSubmit={ppacSubmitHandler}
                   onInputChange={ppacInputChangeHandler}

+ 29 - 0
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -16,8 +16,10 @@ import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mlu from './MarkdownLinkUtil';
 import mtu from './MarkdownTableUtil';
 import mdu from './MarkdownDrawioUtil';
+import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
@@ -54,6 +56,9 @@ require('../../util/codemirror/autorefresh.ext');
 
 
 const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
+// TODO: activate by GW-3443
+// const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
+const MARKDOWN_LINK_ACTIVATED_CLASS = '';
 
 export default class CodeMirrorEditor extends AbstractEditor {
 
@@ -71,6 +76,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet: new Set(),
     };
 
+    this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
 
@@ -98,6 +104,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
 
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
+    this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
   }
@@ -462,8 +469,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
   cursorHandler(editor, event) {
     const { additionalClassSet } = this.state;
     const hasCustomClass = additionalClassSet.has(MARKDOWN_TABLE_ACTIVATED_CLASS);
+    const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
 
     const isInTable = mtu.isInTable(editor);
+    const isInLink = mlu.isInLink(editor);
 
     if (!hasCustomClass && isInTable) {
       additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
@@ -474,6 +483,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet.delete(MARKDOWN_TABLE_ACTIVATED_CLASS);
       this.setState({ additionalClassSet });
     }
+
+    if (!hasLinkClass && isInLink) {
+      additionalClassSet.add(MARKDOWN_LINK_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
+    }
+
+    if (hasLinkClass && !isInLink) {
+      additionalClassSet.delete(MARKDOWN_LINK_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
+    }
   }
 
   changeHandler(editor, data, value) {
@@ -649,6 +668,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     cm.focus();
   }
 
+  showLinkEditHandler() {
+    this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+  }
+
   showHandsonTableHandler() {
     this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
@@ -745,6 +768,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Link"
+        // TODO: activate by GW-3443
+        // onClick={this.showLinkEditHandler}
         onClick={this.createReplaceSelectionHandler('[', ']()')}
       >
         <EditorIcon icon="Link" />
@@ -849,6 +874,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         { this.renderCheatsheetOverlay() }
 
+        <LinkEditModal
+          ref={this.linkEditModal}
+          onSave={(link) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), link) }}
+        />
         <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}

+ 353 - 0
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -0,0 +1,353 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { debounce } from 'throttle-debounce';
+
+import path from 'path';
+import Preview from './Preview';
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
+import SearchTypeahead from '../SearchTypeahead';
+import Linker from '../../models/Linker';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+class LinkEditModal extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      show: false,
+      isUseRelativePath: false,
+      isUsePermanentLink: false,
+      linkInputValue: '',
+      labelInputValue: '',
+      linkerType: Linker.types.markdownLink,
+      markdown: '',
+      permalink: '',
+    };
+
+    this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
+
+    this.show = this.show.bind(this);
+    this.hide = this.hide.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.handleChangeTypeahead = this.handleChangeTypeahead.bind(this);
+    this.handleChangeLabelInput = this.handleChangeLabelInput.bind(this);
+    this.handleChangeLinkInput = this.handleChangeLinkInput.bind(this);
+    this.handleSelecteLinkerType = this.handleSelecteLinkerType.bind(this);
+    this.toggleIsUseRelativePath = this.toggleIsUseRelativePath.bind(this);
+    this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
+    this.save = this.save.bind(this);
+    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));
+  }
+
+  componentDidUpdate(prevState) {
+    const { linkInputValue: prevLinkInputValue } = prevState;
+    const { linkInputValue } = this.state;
+    if (linkInputValue !== prevLinkInputValue) {
+      this.getPreviewDebounced(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;
+
+    // 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.setState({
+      show: true,
+      labelInputValue: label,
+      linkInputValue: link,
+      isUsePermanentLink: false,
+      permalink: '',
+      linkerType: type,
+      isUseRelativePath,
+    });
+  }
+
+  cancel() {
+    this.hide();
+  }
+
+  hide() {
+    this.setState({
+      show: false,
+    });
+  }
+
+  toggleIsUseRelativePath() {
+    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    this.setState({ isUseRelativePath: !this.state.isUseRelativePath, isUsePermanentLink: false });
+  }
+
+  toggleIsUsePamanentLink() {
+    if (this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink, isUseRelativePath: false });
+  }
+
+  renderPreview() {
+    return (
+      <div className="linkedit-preview">
+        <Preview
+          markdown={this.state.markdown}
+        />
+      </div>
+    );
+  }
+
+  async getPreview(path) {
+    let markdown = '';
+    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}`;
+    }
+    catch (err) {
+      markdown = `<div class="alert alert-warning" role="alert"><strong>${err.message}</strong></div>`;
+    }
+    this.setState({ markdown, permalink });
+  }
+
+  handleChangeTypeahead(selected) {
+    const page = selected[0];
+    if (page != null) {
+      this.setState({ linkInputValue: page.path });
+    }
+  }
+
+  handleChangeLabelInput(label) {
+    this.setState({ labelInputValue: label });
+  }
+
+  handleChangeLinkInput(link) {
+    let isUseRelativePath = this.state.isUseRelativePath;
+    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
+      isUseRelativePath = false;
+    }
+    this.setState({ linkInputValue: link, isUseRelativePath, isUsePermanentLink: false });
+  }
+
+  handleSelecteLinkerType(linkerType) {
+    let { isUseRelativePath, isUsePermanentLink } = this.state;
+    if (linkerType === Linker.types.growiLink) {
+      isUseRelativePath = false;
+      isUsePermanentLink = false;
+    }
+    this.setState({ linkerType, isUseRelativePath, isUsePermanentLink });
+  }
+
+  save() {
+    const output = this.generateLink();
+
+    if (this.props.onSave != null) {
+      this.props.onSave(output);
+    }
+
+    this.hide();
+  }
+
+  generateLink() {
+    const {
+      linkInputValue,
+      labelInputValue,
+      linkerType,
+      isUseRelativePath,
+      isUsePermanentLink,
+      permalink,
+    } = this.state;
+
+    let reshapedLink = linkInputValue;
+    if (isUseRelativePath) {
+      const rootPath = this.getRootPath(linkerType);
+      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
+    }
+
+    return new Linker(
+      linkerType,
+      labelInputValue,
+      reshapedLink,
+      isUsePermanentLink,
+      permalink,
+    );
+  }
+
+  getRootPath(type) {
+    const { pageContainer } = this.props;
+    const pagePath = pageContainer.state.path;
+    // rootPaths of md link and pukiwiki link are different
+    return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
+  }
+
+  render() {
+    return (
+      <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>
+
+            <div className="col d-none d-lg-block pr-0 mr-3 overflow-auto">
+              {this.renderPreview()}
+            </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>
+    );
+  }
+
+}
+
+LinkEditModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  onSave: PropTypes.func,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LinkEditModalWrapper = withUnstatedContainers(LinkEditModal, [AppContainer, PageContainer]);
+
+export default LinkEditModalWrapper;

+ 3 - 3
src/client/js/components/PageEditor/MarkdownDrawioUtil.js

@@ -139,17 +139,17 @@ class MarkdownDrawioUtil {
    */
   replaceDrawioInMarkdown(drawioData, markdown, beginLineNumber, endLineNumber) {
     const splitMarkdown = markdown.split(/\r\n|\r|\n/);
-    const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber);
+    const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber - 1);
     const markdownAfterDrawio = splitMarkdown.slice(endLineNumber);
 
     let newMarkdown = '';
     if (markdownBeforeDrawio.length > 0) {
       newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
-      newMarkdown += '::: drawio\n';
     }
+    newMarkdown += '::: drawio\n';
     newMarkdown += drawioData;
+    newMarkdown += '\n:::';
     if (markdownAfterDrawio.length > 0) {
-      newMarkdown += '\n:::';
       newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
     }
 

+ 48 - 0
src/client/js/components/PageEditor/MarkdownLinkUtil.js

@@ -0,0 +1,48 @@
+import Linker from '../../models/Linker';
+
+/**
+ * Utility for markdown link
+ */
+class MarkdownLinkUtil {
+
+  constructor() {
+    this.getMarkdownLink = this.getMarkdownLink.bind(this);
+    this.isInLink = this.isInLink.bind(this);
+    this.replaceFocusedMarkdownLinkWithEditor = this.replaceFocusedMarkdownLinkWithEditor.bind(this);
+  }
+
+  // return an instance of Linker from cursor position or selected text.
+  getMarkdownLink(editor) {
+    if (!this.isInLink(editor)) {
+      return Linker.fromMarkdownString(editor.getDoc().getSelection());
+    }
+    const curPos = editor.getCursor();
+    return Linker.fromLineWithIndex(editor.getDoc().getLine(curPos.line), curPos.ch);
+  }
+
+  isInLink(editor) {
+    const curPos = editor.getCursor();
+    const { beginningOfLink, endOfLink } = Linker.getBeginningAndEndIndexOfLink(editor.getDoc().getLine(curPos.line), curPos.ch);
+    return beginningOfLink >= 0 && endOfLink >= 0;
+  }
+
+  // replace link(link is an instance of Linker)
+  replaceFocusedMarkdownLinkWithEditor(editor, link) {
+    const curPos = editor.getCursor();
+    const linkStr = link.generateMarkdownText();
+    if (!this.isInLink(editor)) {
+      editor.getDoc().replaceSelection(linkStr);
+    }
+    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 });
+    }
+  }
+
+}
+
+// singleton pattern
+const instance = new MarkdownLinkUtil();
+Object.freeze(instance);
+export default instance;

+ 55 - 139
src/client/js/components/PageHistory.jsx

@@ -1,173 +1,89 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
-import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+import { toastError } from '../util/apiNotification';
 
+import { withLoadingSppiner } from './SuspenseUtils';
 import PageRevisionList from './PageHistory/PageRevisionList';
 
-const logger = loggerFactory('growi:PageHistory');
-class PageHistory extends React.Component {
-
-  constructor(props) {
-    super(props);
+import PageHistroyContainer from '../services/PageHistoryContainer';
+import PaginationWrapper from './PaginationWrapper';
 
-    this.state = {
-      isLoaded: false,
-      isLoading: false,
-      errorMessage: null,
-      revisions: [],
-      diffOpened: {},
-    };
 
-    this.getPreviousRevision = this.getPreviousRevision.bind(this);
-    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
-  }
+const logger = loggerFactory('growi:PageHistory');
 
-  async componentWillMount() {
-    const pageId = this.props.pageId;
 
-    if (!pageId) {
-      return;
-    }
+function PageHistory(props) {
+  const { pageHistoryContainer } = props;
 
-    let res;
+  const handlePage = useCallback(async(selectedPage) => {
     try {
-      this.setState({ isLoading: true });
-      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId });
+      await props.pageHistoryContainer.retrieveRevisions(selectedPage);
     }
     catch (err) {
+      toastError(err);
+      props.pageHistoryContainer.setState({ errorMessage: err.message });
       logger.error(err);
-      this.setState({ errorMessage: err });
-      return;
     }
-    finally {
-      this.setState({ isLoading: false });
-    }
-
-    const rev = res.revisions;
-    const diffOpened = {};
-    const lastId = rev.length - 1;
-    res.revisions.forEach((revision, i) => {
-      const user = revision.author;
-      if (user) {
-        rev[i].author = user;
-      }
+  }, [props.pageHistoryContainer]);
 
-      if (i === 0 || i === lastId) {
-        diffOpened[revision._id] = true;
-      }
-      else {
-        diffOpened[revision._id] = false;
-      }
-    });
-
-    this.setState({
-      isLoaded: true,
-      revisions: rev,
-      diffOpened,
-    });
-
-    // load 0, and last default
-    if (rev[0]) {
-      this.fetchPageRevisionBody(rev[0]);
-    }
-    if (rev[1]) {
-      this.fetchPageRevisionBody(rev[1]);
-    }
-    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-      this.fetchPageRevisionBody(rev[lastId]);
-    }
+  if (pageHistoryContainer.state.errorMessage != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{pageHistoryContainer.state.errorMessage}</div>
+      </div>
+    );
   }
 
-  getPreviousRevision(currentRevision) {
-    let cursor = null;
-    for (const revision of this.state.revisions) {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (cursor && cursor._id == currentRevision._id) {
-        cursor = revision;
-        break;
+  if (pageHistoryContainer.state.revisions === pageHistoryContainer.dummyRevisions) {
+    throw new Promise(async() => {
+      try {
+        await props.pageHistoryContainer.retrieveRevisions(1);
+      }
+      catch (err) {
+        toastError(err);
+        pageHistoryContainer.setState({ errorMessage: err.message });
+        logger.error(err);
       }
-
-      cursor = revision;
-    }
-
-    return cursor;
-  }
-
-  onDiffOpenClicked(revision) {
-    const diffOpened = this.state.diffOpened;
-    const revisionId = revision._id;
-
-    diffOpened[revisionId] = !(diffOpened[revisionId]);
-    this.setState({
-      diffOpened,
     });
-
-    this.fetchPageRevisionBody(revision);
-    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
   }
 
-  fetchPageRevisionBody(revision) {
-    if (revision.body) {
-      return;
-    }
-
-    this.props.crowi.apiGet('/revisions.get',
-      { page_id: this.props.pageId, revision_id: revision._id })
-      .then((res) => {
-        if (res.ok) {
-          this.setState({
-            revisions: this.state.revisions.map((rev) => {
-              // comparing ObjectId
-              // eslint-disable-next-line eqeqeq
-              if (rev._id == res.revision._id) {
-                return res.revision;
-              }
-
-              return rev;
-            }),
-          });
-        }
-      })
-      .catch((err) => {
-
-      });
-  }
 
-  render() {
+  function pager() {
     return (
-      <div className="mt-4">
-        { this.state.isLoading && (
-          <div className="my-5 text-center">
-            <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
-          </div>
-        ) }
-        { this.state.errorMessage && (
-          <div className="my-5">
-            <div className="text-danger">{this.state.errorMessage}</div>
-          </div>
-        ) }
-        { this.state.isLoaded && (
-          <PageRevisionList
-            t={this.props.t}
-            revisions={this.state.revisions}
-            diffOpened={this.state.diffOpened}
-            getPreviousRevision={this.getPreviousRevision}
-            onDiffOpenClicked={this.onDiffOpenClicked}
-          />
-        ) }
+      <div className="my-3">
+        <PaginationWrapper
+          activePage={pageHistoryContainer.state.activePage}
+          changePage={handlePage}
+          totalItemsCount={pageHistoryContainer.state.totalPages}
+          pagingLimit={pageHistoryContainer.state.pagingLimit}
+        />
       </div>
     );
   }
 
+
+  return (
+    <div className="mt-4">
+      {pager()}
+      <PageRevisionList
+        revisions={pageHistoryContainer.state.revisions}
+        diffOpened={pageHistoryContainer.state.diffOpened}
+        getPreviousRevision={pageHistoryContainer.getPreviousRevision}
+        onDiffOpenClicked={pageHistoryContainer.onDiffOpenClicked}
+      />
+      {pager()}
+    </div>
+  );
+
 }
 
+const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer]);
+
 PageHistory.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  pageId: PropTypes.string,
-  crowi: PropTypes.object.isRequired,
+  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
 };
 
-export default withTranslation()(PageHistory);
+export default RenderPageHistoryWrapper;

+ 6 - 3
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -1,10 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
+
 import Revision from './Revision';
 import RevisionDiff from './RevisionDiff';
 
-export default class PageRevisionList extends React.Component {
+class PageRevisionList extends React.Component {
 
   constructor(props) {
     super(props);
@@ -65,8 +67,6 @@ export default class PageRevisionList extends React.Component {
     const { t } = this.props;
 
     const revisions = this.props.revisions;
-
-
     const revisionCount = this.props.revisions.length;
 
     let hasDiffPrev;
@@ -117,7 +117,10 @@ export default class PageRevisionList extends React.Component {
 
 PageRevisionList.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
   revisions: PropTypes.array,
   diffOpened: PropTypes.object,
   onDiffOpenClicked: PropTypes.func.isRequired,
 };
+
+export default withTranslation()(PageRevisionList);

+ 0 - 2
src/client/js/components/PagePathAutoComplete.jsx

@@ -37,7 +37,6 @@ const PagePathAutoComplete = (props) => {
 
   return (
     <SearchTypeahead
-      crowi={props.crowi}
       onSubmit={submitHandler}
       onChange={inputChangeHandler}
       onInputChange={props.onInputChange}
@@ -51,7 +50,6 @@ const PagePathAutoComplete = (props) => {
 };
 
 PagePathAutoComplete.propTypes = {
-  crowi:            PropTypes.object.isRequired,
   initializedPath:  PropTypes.string,
   addTrailingSlash: PropTypes.bool,
 

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

@@ -94,7 +94,7 @@ class PageStatusAlert extends React.Component {
 
     // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
-      getContentsFunc = this.getContentsFunc;
+      getContentsFunc = this.getContentsForUpdatedAlert;
     }
     // when someone editing with HackMD
     else if (isHackmdDraftUpdatingInRealtime) {

+ 274 - 0
src/client/js/components/ShareLinkForm.jsx

@@ -0,0 +1,274 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+import parse from 'date-fns/parse';
+
+import { isInteger } from 'core-js/fn/number';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import { toastSuccess, toastError } from '../util/apiNotification';
+
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+class ShareLinkForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      expirationType: 'unlimited',
+      numberOfDays: '7',
+      description: '',
+      customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
+      customExpirationTime: dateFnsFormat(new Date(), 'hh:mm'),
+    };
+
+    this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);
+    this.handleChangeNumberOfDays = this.handleChangeNumberOfDays.bind(this);
+    this.handleChangeDescription = this.handleChangeDescription.bind(this);
+    this.handleIssueShareLink = this.handleIssueShareLink.bind(this);
+  }
+
+  /**
+   * change expirationType
+   * @param {string} expirationType
+   */
+  handleChangeExpirationType(expirationType) {
+    this.setState({ expirationType });
+  }
+
+  /**
+   * change numberOfDays
+   * @param {string} numberOfDays
+   */
+  handleChangeNumberOfDays(numberOfDays) {
+    this.setState({ numberOfDays });
+  }
+
+  /**
+   * change description
+   * @param {string} description
+   */
+  handleChangeDescription(description) {
+    this.setState({ description });
+  }
+
+  /**
+   * change customExpirationDate
+   * @param {date} customExpirationDate
+   */
+  handleChangeCustomExpirationDate(customExpirationDate) {
+    this.setState({ customExpirationDate });
+  }
+
+  /**
+   * change customExpirationTime
+   * @param {date} customExpirationTime
+   */
+  handleChangeCustomExpirationTime(customExpirationTime) {
+    this.setState({ customExpirationTime });
+  }
+
+  /**
+   * Generate expiredAt by expirationType
+   */
+  generateExpired() {
+    const { t } = this.props;
+    const { expirationType } = this.state;
+    let expiredAt;
+
+    if (expirationType === 'unlimited') {
+      return null;
+    }
+
+    if (expirationType === 'numberOfDays') {
+      if (!isInteger(Number(this.state.numberOfDays))) {
+        throw new Error(t('share_links.Invalid_Number_of_Date'));
+      }
+      const date = new Date();
+      date.setDate(date.getDate() + Number(this.state.numberOfDays));
+      expiredAt = date;
+    }
+
+    if (expirationType === 'custom') {
+      const { customExpirationDate, customExpirationTime } = this.state;
+      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm", new Date());
+    }
+
+    return expiredAt;
+  }
+
+  closeForm() {
+    const { onCloseForm } = this.props;
+
+    if (onCloseForm == null) {
+      return;
+    }
+    onCloseForm();
+  }
+
+  async handleIssueShareLink() {
+    const {
+      t, appContainer, pageContainer,
+    } = this.props;
+    const { pageId } = pageContainer.state;
+    const { description } = this.state;
+
+    let expiredAt;
+
+    try {
+      expiredAt = this.generateExpired();
+    }
+    catch (err) {
+      return toastError(err);
+    }
+
+    try {
+      await appContainer.apiv3Post('/share-links/', { relatedPage: pageId, expiredAt, description });
+      this.closeForm();
+      toastSuccess(t('toaster.issue_share_link'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  renderExpirationTypeOptions() {
+    const { expirationType } = this.state;
+    const { t } = this.props;
+
+    return (
+      <div className="form-group row">
+        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.expire')}</label>
+        <div className="col-md-7">
+
+
+          <div className="custom-control custom-radio form-group ">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio1"
+              name="expirationType"
+              value="customRadio1"
+              checked={expirationType === 'unlimited'}
+              onChange={() => { this.handleChangeExpirationType('unlimited') }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio1">{t('share_links.Unlimited')}</label>
+          </div>
+
+          <div className="custom-control custom-radio  form-group">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio2"
+              value="customRadio2"
+              checked={expirationType === 'numberOfDays'}
+              onChange={() => { this.handleChangeExpirationType('numberOfDays') }}
+              name="expirationType"
+            />
+            <label className="custom-control-label" htmlFor="customRadio2">
+              <div className="row align-items-center m-0">
+                <input
+                  type="number"
+                  min="1"
+                  className="col-4"
+                  name="expirationType"
+                  value={this.state.numberOfDays}
+                  onFocus={() => { this.handleChangeExpirationType('numberOfDays') }}
+                  onChange={e => this.handleChangeNumberOfDays(Number(e.target.value))}
+                />
+                <span className="col-auto">{t('share_links.Days')}</span>
+              </div>
+            </label>
+          </div>
+
+          <div className="custom-control custom-radio form-group text-nowrap mb-0">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio3"
+              name="expirationType"
+              value="customRadio3"
+              checked={expirationType === 'custom'}
+              onChange={() => { this.handleChangeExpirationType('custom') }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio3">
+              {t('share_links.Custom')}
+            </label>
+            <div className="d-inline-flex flex-wrap">
+              <input
+                type="date"
+                className="ml-3 mb-2"
+                name="customExpirationDate"
+                value={this.state.customExpirationDate}
+                onFocus={() => { this.handleChangeExpirationType('custom') }}
+                onChange={e => this.handleChangeCustomExpirationDate(e.target.value)}
+              />
+              <input
+                type="time"
+                className="ml-3 mb-2"
+                name="customExpiration"
+                value={this.state.customExpirationTime}
+                onFocus={() => { this.handleChangeExpirationType('custom') }}
+                onChange={e => this.handleChangeCustomExpirationTime(e.target.value)}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderDescriptionForm() {
+    const { t } = this.props;
+    return (
+      <div className="form-group row">
+        <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.description')}</label>
+        <div className="col-md-4">
+          <input
+            type="text"
+            className="form-control"
+            id="inputDesc"
+            placeholder={t('share_links.enter_desc')}
+            value={this.state.description}
+            onChange={e => this.handleChangeDescription(e.target.value)}
+          />
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    return (
+      <div className="share-link-form p-3">
+        <h3 className="grw-modal-head pb-2"> { t('share_links.share_settings') }</h3>
+        <div className=" p-3">
+          {this.renderExpirationTypeOptions()}
+          {this.renderDescriptionForm()}
+          <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={this.handleIssueShareLink}>
+            {t('share_links.Issue')}
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [AppContainer, PageContainer]);
+
+ShareLinkForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  onCloseForm: PropTypes.func,
+};
+
+export default withTranslation()(ShareLinkFormWrapper);

+ 82 - 0
src/client/js/components/ShareLinkList.jsx

@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import AppContainer from '../services/AppContainer';
+import CopyDropdown from './Page/CopyDropdown';
+
+const ShareLinkList = (props) => {
+
+  const { t } = props;
+  function deleteLinkHandler(shareLinkId) {
+    if (props.onClickDeleteButton == null) {
+      return;
+    }
+    props.onClickDeleteButton(shareLinkId);
+  }
+
+  function renderShareLinks() {
+    return (
+      <>
+        {props.shareLinks.map(shareLink => (
+          <tr key={shareLink._id}>
+            <td>
+              <div className="d-flex">
+                <span className="mr-auto my-auto">{shareLink._id}</span>
+                <CopyDropdown isShareLinkMode pagePath={shareLink.relatedPage.path} pageId={shareLink._id} />
+              </div>
+            </td>
+            {props.isAdmin && <td><a href={shareLink.relatedPage.path}>{shareLink.relatedPage.path}</a></td>}
+            <td>{shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
+            <td>{shareLink.description}</td>
+            <td>
+              <button className="btn btn-outline-warning" type="button" onClick={() => deleteLinkHandler(shareLink._id)}>
+                <i className="icon-trash"></i>{t('Delete')}
+              </button>
+            </td>
+          </tr>
+        ))}
+      </>
+    );
+  }
+
+  return (
+    <div className="table-responsive">
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('share_links.Share Link')}</th>
+            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
+            <th>{t('share_links.expire')}</th>
+            <th>{t('share_links.description')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {renderShareLinks()}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ShareLinkListWrapper = withUnstatedContainers(ShareLinkList, [AppContainer]);
+
+ShareLinkList.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  shareLinks: PropTypes.array.isRequired,
+  onClickDeleteButton: PropTypes.func,
+  isAdmin: PropTypes.bool,
+};
+
+export default withTranslation()(ShareLinkListWrapper);

+ 10 - 6
src/client/js/components/Sidebar.jsx

@@ -138,8 +138,8 @@ class Sidebar extends React.Component {
   }
 
   calcViewHeight() {
-    const containerElem = document.querySelector('#grw-sidebar-content-container');
-    return window.innerHeight - containerElem.getBoundingClientRect().top;
+    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
+    return window.innerHeight - scrollTargetElem.getBoundingClientRect().top;
   }
 
   renderGlobalNavigation = () => (
@@ -147,8 +147,7 @@ class Sidebar extends React.Component {
   );
 
   renderSidebarContents = () => {
-    // const scrollTargetSelector = 'div[data-testid="ContextualNavigation"] div[role="group"]';
-    const scrollTargetSelector = '#grw-sidebar-content-container';
+    const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
 
     return (
       <>
@@ -158,8 +157,13 @@ class Sidebar extends React.Component {
           stickyElemSelector=".grw-sidebar"
           calcViewHeightFunc={this.calcViewHeight}
         />
-        <div id="grw-sidebar-content-container" className="grw-sidebar-content-container">
-          <SidebarContents />
+
+        <div id="grw-sidebar-contents-scroll-target">
+          <div id="grw-sidebar-content-container">
+            <SidebarContents
+              isSharedUser={this.props.appContainer.isSharedUser}
+            />
+          </div>
         </div>
 
         <DrawerToggler iconClass="icon-arrow-left" />

+ 10 - 1
src/client/js/components/Sidebar/SidebarContents.jsx

@@ -10,8 +10,11 @@ import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
 
 const SidebarContents = (props) => {
+  const { navigationContainer, isSharedUser } = props;
 
-  const { navigationContainer } = props;
+  if (isSharedUser) {
+    return null;
+  }
 
   let Contents;
   switch (navigationContainer.state.sidebarContentsId) {
@@ -30,6 +33,12 @@ const SidebarContents = (props) => {
 
 SidebarContents.propTypes = {
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  isSharedUser: PropTypes.bool,
+};
+
+SidebarContents.defaultProps = {
+  isSharedUser: false,
 };
 
 /**

+ 3 - 3
src/client/js/components/Sidebar/SidebarNav.jsx

@@ -56,7 +56,7 @@ class SidebarNav extends React.Component {
   }
 
   render() {
-    const { isAdmin, currentUsername } = this.props.appContainer;
+    const { isAdmin, currentUsername, isSharedUser } = this.props.appContainer;
     const isLoggedIn = currentUsername != null;
 
     const { PrimaryItem, SecondaryItem } = this;
@@ -64,8 +64,8 @@ class SidebarNav extends React.Component {
     return (
       <div className="grw-sidebar-nav">
         <div className="grw-sidebar-nav-primary-container">
-          <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />
-          <PrimaryItem id="recent" label="Recent Changes" iconName="update" />
+          {!isSharedUser && <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />}
+          {!isSharedUser && <PrimaryItem id="recent" label="Recent Changes" iconName="update" />}
           {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
           {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         </div>

+ 33 - 23
src/client/js/components/SlackNotification.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
+
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -10,7 +12,7 @@ import PropTypes from 'prop-types';
  * @extends {React.Component}
  */
 
-export default class SlackNotification extends React.Component {
+class SlackNotification extends React.Component {
 
   constructor(props) {
     super(props);
@@ -34,32 +36,36 @@ export default class SlackNotification extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
+
     return (
-      <div className="input-group input-group-sm input-group-slack extended-setting">
-        <label className="input-group-addon bg-light">
-          <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
-          <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
+      <div className="grw-slack-notification">
+        <div className="input-group input-group-sm extended-setting">
+          <label className="input-group-addon bg-light">
+            <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
+            <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
+
+            <input
+              type="checkbox"
+              value="1"
+              checked={this.props.isSlackEnabled}
+              onChange={this.updateCheckboxHandler}
+            />
 
+          </label>
           <input
-            type="checkbox"
-            value="1"
-            checked={this.props.isSlackEnabled}
-            onChange={this.updateCheckboxHandler}
+            className="form-control"
+            type="text"
+            value={this.props.slackChannels}
+            placeholder="Input channels"
+            data-toggle="popover"
+            title={t('slack_notification.popover_title')}
+            data-content={t('slack_notification.popover_desc')}
+            data-trigger="focus"
+            data-placement="top"
+            onChange={this.updateSlackChannelsHandler}
           />
-
-        </label>
-        <input
-          className="form-control"
-          type="text"
-          value={this.props.slackChannels}
-          placeholder="slack channel name"
-          data-toggle="popover"
-          title="Slack通知"
-          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
-          data-trigger="focus"
-          data-placement="top"
-          onChange={this.updateSlackChannelsHandler}
-        />
+        </div>
       </div>
     );
   }
@@ -67,8 +73,12 @@ export default class SlackNotification extends React.Component {
 }
 
 SlackNotification.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   onEnabledFlagChange: PropTypes.func,
   onChannelChange: PropTypes.func,
 };
+
+export default withTranslation()(SlackNotification);

+ 7 - 2
src/client/js/components/StickyStretchableScroller.jsx

@@ -81,16 +81,21 @@ const StickyStretchableScroller = (props) => {
     logger.debug(`[${scrollTargetSelector}] viewHeight`, viewHeight);
     logger.debug(`[${scrollTargetSelector}] contentsHeight`, contentsHeight);
 
+    const isScrollEnabled = viewHeight === 'auto' || (viewHeight < contentsHeight);
+
     $(scrollTargetSelector).slimScroll({
       color: '#666',
       railColor: '#999',
       railVisible: true,
       position: 'right',
-      height: viewHeight,
+      height: isScrollEnabled ? viewHeight : contentsHeight,
     });
-    if (contentsHeight < viewHeight) {
+
+    // destroy
+    if (!isScrollEnabled) {
       $(scrollTargetSelector).slimScroll({ destroy: true });
     }
+
   }, [contentsElemSelector, calcViewHeightFunc, calcContentsHeightFunc, scrollTargetSelector]);
 
   const resetScrollbarDebounced = debounce(100, resetScrollbar);

+ 21 - 0
src/client/js/components/SuspenseUtils.jsx

@@ -0,0 +1,21 @@
+/* eslint-disable import/prefer-default-export */
+import React, { Suspense } from 'react';
+
+/**
+ * If you throw a Promise in the component, it will display a sppiner
+ * @param {object} Component A React.Component or functional component
+ */
+export function withLoadingSppiner(Component) {
+  return (props => (
+    // wrap with <Suspense></Suspense>
+    <Suspense
+      fallback={(
+        <div className="my-5 text-center">
+          <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <Component {...props} />
+    </Suspense>
+  ));
+}

+ 0 - 1
src/client/js/hackmd-agent.js

@@ -143,7 +143,6 @@ function connectToParentWithPenpal() {
   console.log('[HackMD] Loading GROWI agent for HackMD...');
 
   window.addEventListener('load', (event) => {
-    console.log('loaded');
     addEventListenersToCodemirror();
   });
 

+ 147 - 0
src/client/js/models/Linker.js

@@ -0,0 +1,147 @@
+export default class Linker {
+
+  constructor(
+      type,
+      label,
+      link,
+      isUsePermanentLink = false,
+      permalink = '',
+  ) {
+    this.type = type;
+    this.label = label;
+    this.link = link;
+    this.isUsePermanentLink = isUsePermanentLink;
+    this.permalink = permalink;
+
+    this.generateMarkdownText = this.generateMarkdownText.bind(this);
+  }
+
+  static types = {
+    markdownLink: 'mdLink',
+    growiLink: 'growiLink',
+    pukiwikiLink: 'pukiwikiLink',
+  }
+
+  static patterns = {
+    pukiwikiLinkWithLabel: /^\[\[(?<label>.+)>(?<link>.+)\]\]$/, // https://regex101.com/r/2fNmUN/2
+    pukiwikiLinkWithoutLabel: /^\[\[(?<label>.+)\]\]$/, // https://regex101.com/r/S7w5Xu/1
+    growiLink: /^\[(?<label>\/.+)\]$/, // https://regex101.com/r/DJfkYf/3
+    markdownLink: /^\[(?<label>.*)\]\((?<link>.*)\)$/, // https://regex101.com/r/DZCKP3/2
+  }
+
+  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.type === Linker.types.growiLink) {
+      return `[${reshapedLink}]`;
+    }
+    if (this.type === Linker.types.markdownLink) {
+      return `[${this.label}](${reshapedLink})`;
+    }
+  }
+
+  // create an instance of Linker from string
+  static fromMarkdownString(str) {
+    // if str doesn't mean a linker, create a link whose label is str
+    let label = str;
+    let link = '';
+    let type = this.types.markdownLink;
+
+    // pukiwiki with separator ">".
+    if (str.match(this.patterns.pukiwikiLinkWithLabel)) {
+      type = this.types.pukiwikiLink;
+      ({ label, link } = str.match(this.patterns.pukiwikiLinkWithLabel).groups);
+    }
+    // pukiwiki without separator ">".
+    else if (str.match(this.patterns.pukiwikiLinkWithoutLabel)) {
+      type = this.types.pukiwikiLink;
+      ({ label } = str.match(this.patterns.pukiwikiLinkWithoutLabel).groups);
+      link = label;
+    }
+    // markdown
+    else if (str.match(this.patterns.markdownLink)) {
+      type = this.types.markdownLink;
+      ({ label, link } = str.match(this.patterns.markdownLink).groups);
+    }
+    // growi
+    else if (str.match(this.patterns.growiLink)) {
+      type = this.types.growiLink;
+      ({ label } = str.match(this.patterns.growiLink).groups);
+      link = label;
+    }
+
+    const isUsePermanentLink = false;
+    const permalink = '';
+
+    return new Linker(
+      type,
+      label,
+      link,
+      isUsePermanentLink,
+      permalink,
+    );
+  }
+
+  // create an instance of Linker from text with index
+  static fromLineWithIndex(line, index) {
+    const { beginningOfLink, endOfLink } = this.getBeginningAndEndIndexOfLink(line, index);
+    // if index is in a link, extract it from line
+    let linkStr = '';
+    if (beginningOfLink >= 0 && endOfLink >= 0) {
+      linkStr = line.substring(beginningOfLink, endOfLink);
+    }
+    return this.fromMarkdownString(linkStr);
+  }
+
+  // return beginning and end indexies of link
+  // if index is not in a link, return { beginningOfLink: -1, endOfLink: -1 }
+  static getBeginningAndEndIndexOfLink(line, index) {
+    let beginningOfLink;
+    let endOfLink;
+
+    // pukiwiki link ('[[link]]')
+    [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[[', ']]');
+
+    // markdown link ('[label](link)')
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[', ')', '](');
+    }
+
+    // growi link ('[/link]')
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = this.getBeginningAndEndIndexWithPrefixAndSuffix(line, index, '[/', ']');
+    }
+
+    // return { beginningOfLink: -1, endOfLink: -1 }
+    if (beginningOfLink < 0 || endOfLink < 0 || beginningOfLink > index || endOfLink < index) {
+      [beginningOfLink, endOfLink] = [-1, -1];
+    }
+
+    return { beginningOfLink, endOfLink };
+  }
+
+  // return begin and end indexies as array only when index is between prefix and suffix and link contains containText.
+  static getBeginningAndEndIndexWithPrefixAndSuffix(line, index, prefix, suffix, containText = '') {
+    const beginningIndex = line.lastIndexOf(prefix, index);
+    const IndexOfContainText = line.indexOf(containText, beginningIndex + prefix.length);
+    const endIndex = line.indexOf(suffix, IndexOfContainText + containText.length);
+
+    if (beginningIndex < 0 || IndexOfContainText < 0 || endIndex < 0) {
+      return [-1, -1];
+    }
+    return [beginningIndex, endIndex + suffix.length];
+  }
+
+}

+ 17 - 1
src/client/js/services/AdminAppContainer.js

@@ -16,10 +16,12 @@ export default class AdminAppContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyTitle = 0;
 
     this.state = {
       retrieveError: null,
-      title: '',
+      // set dummy value tile for using suspense
+      title: this.dummyTitle,
       confidential: '',
       globalLang: '',
       fileUpload: '',
@@ -265,6 +267,20 @@ export default class AdminAppContainer extends Container {
     return mailSettingParams;
   }
 
+  /**
+   * Initialize mail 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);
+    return mailSettingParams;
+  }
+
   /**
    * Update AWS setting
    * @memberOf AdminAppContainer

+ 29 - 0
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -30,6 +30,10 @@ export default class AdminGeneralSecurityContainer extends Container {
       isGitHubEnabled: false,
       isTwitterEnabled: false,
       setupStrategies: [],
+      shareLinks: [],
+      totalshareLinks: 0,
+      shareLinksPagingLimit: Infinity,
+      shareLinksActivePage: 1,
     };
 
   }
@@ -151,6 +155,31 @@ export default class AdminGeneralSecurityContainer extends Container {
     }
   }
 
+  /**
+   * Retrieve All Sharelinks
+   */
+  async retrieveShareLinksByPagingNum(page) {
+
+    const params = {
+      page,
+    };
+
+    const { data } = await this.appContainer.apiv3.get('/security-setting/all-share-links', params);
+
+    if (data.paginateResult == null) {
+      throw new Error('data must conclude \'paginateResult\' property.');
+    }
+
+    const { docs: shareLinks, totalDocs: totalshareLinks, limit: shareLinksPagingLimit } = data.paginateResult;
+
+    this.setState({
+      shareLinks,
+      totalshareLinks,
+      shareLinksPagingLimit,
+      shareLinksActivePage: page,
+    });
+  }
+
   /**
    * Switch local enabled
    */

+ 19 - 0
src/client/js/services/AdminSocketIoContainer.js

@@ -0,0 +1,19 @@
+import SocketIoContainer from './SocketIoContainer';
+
+/**
+ * A subclass of SocketIoContainer for /admin namespace
+ */
+export default class AdminSocketIoContainer extends SocketIoContainer {
+
+  constructor(appContainer) {
+    super(appContainer, '/admin');
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminSocketIoContainer';
+  }
+
+}

+ 3 - 0
src/client/js/services/AppContainer.js

@@ -39,6 +39,9 @@ export default class AppContainer extends Container {
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
+    const isSharedPageElem = document.getElementById('is-shared-page');
+    this.isSharedUser = (isSharedPageElem != null);
+
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
 

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

@@ -68,6 +68,8 @@ export default class PageContainer extends Container {
       tags: null,
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
+      shareLinksNumber:  mainContent.getAttribute('data-share-links-number'),
+      shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
@@ -310,11 +312,11 @@ export default class PageContainer extends Container {
   }
 
   async createPage(pagePath, markdown, tmpParams) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
       path: pagePath,
       body: markdown,
     });
@@ -327,11 +329,11 @@ export default class PageContainer extends Container {
   }
 
   async updatePage(pageId, revisionId, markdown, tmpParams) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
       page_id: pageId,
       revision_id: revisionId,
       body: markdown,
@@ -345,7 +347,7 @@ export default class PageContainer extends Container {
   }
 
   deletePage(isRecursively, isCompletely) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // control flag
     const completely = isCompletely ? true : null;
@@ -356,13 +358,13 @@ export default class PageContainer extends Container {
       completely,
       page_id: this.state.pageId,
       revision_id: this.state.revisionId,
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
     });
 
   }
 
   revertRemove(isRecursively) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
 
     // control flag
     const recursively = isRecursively ? true : null;
@@ -370,12 +372,12 @@ export default class PageContainer extends Container {
     return this.appContainer.apiPost('/pages.revertRemove', {
       recursively,
       page_id: this.state.pageId,
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
   rename(pageNameInput, isRenameRecursively, isRenameRedirect, isRenameMetadata) {
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
     const isRecursively = isRenameRecursively ? true : null;
     const isRedirect = isRenameRedirect ? true : null;
     const isRemain = isRenameMetadata ? true : null;
@@ -387,7 +389,7 @@ export default class PageContainer extends Container {
       new_path: pageNameInput,
       create_redirect: isRedirect,
       remain_metadata: isRemain,
-      socketClientId: websocketContainer.getSocketClientId(),
+      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
@@ -416,12 +418,12 @@ export default class PageContainer extends Container {
 
   addWebSocketEventHandlers() {
     const pageContainer = this;
-    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
-    const socket = websocketContainer.getWebSocket();
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
+    const socket = socketIoContainer.getSocket();
 
     socket.on('page:create', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 
@@ -435,7 +437,7 @@ export default class PageContainer extends Container {
 
     socket.on('page:update', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 
@@ -456,7 +458,7 @@ export default class PageContainer extends Container {
 
     socket.on('page:delete', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 
@@ -470,7 +472,7 @@ export default class PageContainer extends Container {
 
     socket.on('page:editingWithHackmd', (data) => {
       // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === websocketContainer.getSocketClientId()) {
+      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
         return;
       }
 

+ 162 - 0
src/client/js/services/PageHistoryContainer.js

@@ -0,0 +1,162 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+const logger = loggerFactory('growi:PageHistoryContainer');
+
+/**
+ * Service container for personal settings page (PageHistory.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class PageHistoryContainer extends Container {
+
+  constructor(appContainer, pageContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.pageContainer = pageContainer;
+
+    this.dummyRevisions = 0;
+
+    this.state = {
+      errorMessage: null,
+
+      // set dummy rivisions for using suspense
+      revisions: this.dummyRevisions,
+      diffOpened: {},
+
+      totalPages: 0,
+      activePage: 1,
+      pagingLimit: Infinity,
+    };
+
+    this.retrieveRevisions = this.retrieveRevisions.bind(this);
+    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
+    this.getPreviousRevision = this.getPreviousRevision.bind(this);
+    this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PageHistoryContainer';
+  }
+
+  /**
+   * syncRevisions of selectedPage
+   * @param {number} selectedPage
+   */
+  async retrieveRevisions(selectedPage) {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+    if (!pageId) {
+      return;
+    }
+
+    const res = await this.appContainer.apiv3Get('/revisions/list', { page_id: pageId, share_link_id: shareLinkId, selectedPage });
+    const rev = res.data.docs;
+
+    // set Pagination state
+    this.setState({
+      activePage: selectedPage,
+      totalPages: res.data.totalDocs,
+      pagingLimit: res.data.limit,
+    });
+
+    const diffOpened = {};
+    const lastId = rev.length - 1;
+
+    res.data.docs.forEach((revision, i) => {
+      const user = revision.author;
+      if (user) {
+        rev[i].author = user;
+      }
+
+      if (i === 0 || i === lastId) {
+        diffOpened[revision._id] = true;
+      }
+      else {
+        diffOpened[revision._id] = false;
+      }
+    });
+
+    this.setState({ revisions: rev });
+    this.setState({ diffOpened });
+
+    // load 0, and last default
+    if (rev[0]) {
+      this.fetchPageRevisionBody(rev[0]);
+    }
+    if (rev[1]) {
+      this.fetchPageRevisionBody(rev[1]);
+    }
+    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
+      this.fetchPageRevisionBody(rev[lastId]);
+    }
+
+    return;
+  }
+
+  onDiffOpenClicked(revision) {
+    const { diffOpened } = this.state;
+    const revisionId = revision._id;
+
+    diffOpened[revisionId] = !(diffOpened[revisionId]);
+    this.setState(diffOpened);
+
+    this.fetchPageRevisionBody(revision);
+    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
+  }
+
+  getPreviousRevision(currentRevision) {
+    let cursor = null;
+    for (const revision of this.state.revisions) {
+      // comparing ObjectId
+      // eslint-disable-next-line eqeqeq
+      if (cursor && cursor._id == currentRevision._id) {
+        cursor = revision;
+        break;
+      }
+
+      cursor = revision;
+    }
+
+    return cursor;
+  }
+
+  /**
+   * fetch page revision body by revision in argument
+   * @param {object} revision
+   */
+  async fetchPageRevisionBody(revision) {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+
+    if (revision.body) {
+      return;
+    }
+
+    try {
+      const res = await this.appContainer.apiv3Get(`/revisions/${revision._id}`, { page_id: pageId, share_link_id: shareLinkId });
+      this.setState({
+        revisions: this.state.revisions.map((rev) => {
+          // comparing ObjectId
+          // eslint-disable-next-line eqeqeq
+          if (rev._id == res.data.revision._id) {
+            return res.data.revision;
+          }
+
+          return rev;
+        }),
+      });
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ errorMessage: err.message });
+      logger.error(err);
+    }
+  }
+
+
+}

+ 9 - 5
src/client/js/services/WebsocketContainer.js → src/client/js/services/SocketIoContainer.js

@@ -6,15 +6,19 @@ import io from 'socket.io-client';
  * Service container related to options for WebSocket
  * @extends {Container} unstated Container
  */
-export default class WebsocketContainer extends Container {
+export default class SocketIoContainer extends Container {
 
-  constructor(appContainer) {
+  constructor(appContainer, namespace) {
     super();
 
     this.appContainer = appContainer;
     this.appContainer.registerContainer(this);
 
-    this.socket = io();
+    const ns = namespace || '/';
+
+    this.socket = io(ns, {
+      transports: ['websocket'],
+    });
     this.socketClientId = Math.floor(Math.random() * 100000);
 
     this.state = {
@@ -26,10 +30,10 @@ export default class WebsocketContainer extends Container {
    * Workaround for the mangling in production build to break constructor.name
    */
   static getClassName() {
-    return 'WebsocketContainer';
+    return 'SocketIoContainer';
   }
 
-  getWebSocket() {
+  getSocket() {
     return this.socket;
   }
 

+ 0 - 16
src/client/js/util/interceptor/drawio-interceptor.js

@@ -18,22 +18,6 @@ export class DrawioInterceptor extends BasicInterceptor {
 
     this.previousPreviewContext = null;
     this.appContainer = appContainer;
-
-    // define callback function invoked by viewer.min.js of draw.io
-    // refs: https://github.com/jgraph/drawio/blob/v12.9.1/etc/build/build.xml#L219-L232
-    window.onDrawioViewerLoad = function() {
-      const DrawioViewer = window.GraphViewer;
-
-      if (DrawioViewer != null) {
-        // disable useResizeSensor and checkVisibleState
-        //   for preventing resize event by viewer.min.js
-        DrawioViewer.useResizeSensor = false;
-        DrawioViewer.prototype.checkVisibleState = false;
-
-        // initialize
-        DrawioViewer.processElements();
-      }
-    };
   }
 
   /**

+ 3 - 3
src/client/styles/scss/_admin.scss

@@ -28,7 +28,7 @@
 
     .ss-container img {
       padding: 0.5em;
-      background-color: #ddd;
+      background-color: $gray-300;
     }
 
     .table-user-list {
@@ -140,8 +140,8 @@
 
     // style
     .theme-option-container a {
-      background-color: #f5f5f5;
-      border: 1px solid #ccc;
+      background-color: $gray-50;
+      border: 1px solid $gray-300;
     }
     .theme-option-name {
       opacity: 0.3;

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

@@ -38,7 +38,7 @@
       justify-content: flex-end;
 
       font-size: 0.9em;
-      color: #999;
+      color: $gray-400;
     }
   }
 

+ 2 - 2
src/client/styles/scss/_comment_kibela.scss

@@ -14,7 +14,7 @@
       height: 0;
       content: '';
       border-top: 20px solid transparent;
-      border-right: 20px solid #e6e9ec;
+      border-right: 20px solid $gray-200;
       border-bottom: 20px solid transparent;
       border-left: 20px solid transparent;
       border-left-width: 0;
@@ -65,7 +65,7 @@
     .page-comment-main {
       @extend %comment-section;
       margin-left: 4.5em;
-      background: #e6e9ec;
+      background: $gray-200;
       border-radius: 0.35em;
     }
 

+ 4 - 4
src/client/styles/scss/_editor-attachment.scss

@@ -22,7 +22,7 @@
         background: rgba(200, 200, 200, 0.8);
 
         .overlay-content {
-          color: #444;
+          color: $gray-700;
         }
       }
     }
@@ -51,7 +51,7 @@
       // accepted
       &.dropzone-accepted:not(.dropzone-rejected) {
         .overlay.overlay-dropzone-active {
-          border: 4px dashed #ccc;
+          border: 4px dashed $gray-300;
 
           .overlay-content {
             // insert content
@@ -62,7 +62,7 @@
             }
 
             // style
-            color: #666;
+            color: $secondary;
             background: rgba(200, 200, 200, 0.8);
           }
         }
@@ -106,7 +106,7 @@
     padding-bottom: 3px;
     font-size: small;
     border: none;
-    border-top: 1px dotted #ccc;
+    border-top: 1px dotted $gray-300;
     border-bottom: none;
 
     &:active {

+ 1 - 1
src/client/styles/scss/_editor-overlay.scss

@@ -4,7 +4,7 @@
     .overlay-content {
       padding: $contentPadding;
       font-size: $contentFontSize;
-      color: #444;
+      color: $gray-700;
       background: rgba(200, 200, 200, 0.5);
     }
   }

+ 4 - 4
src/client/styles/scss/_hljs.scss

@@ -15,8 +15,8 @@ pre.hljs {
     padding: 0 4px;
     font-style: normal;
     font-weight: bold;
-    color: #333;
-    background: #ccc;
+    color: $gray-900;
+    background: $gray-300;
     opacity: 0.6;
   }
 }
@@ -24,12 +24,12 @@ pre.hljs {
 // styles for highlightjs-line-numbers
 .hljs-ln td.hljs-ln-numbers {
   padding-right: 5px;
-  color: #ccc;
+  color: $gray-300;
 
   text-align: center;
   vertical-align: top;
   user-select: none;
-  border-right: 1px solid #ccc;
+  border-right: 1px solid $gray-300;
 }
 
 .hljs-ln td.hljs-ln-code {

+ 5 - 5
src/client/styles/scss/_layout.scss

@@ -94,10 +94,10 @@ body {
   }
   .main {
     header {
-      border-bottom: solid 1px #666;
+      border-bottom: solid 1px $secondary;
       h1 {
         font-size: 2em;
-        color: #000;
+        color: black;
       }
     }
 
@@ -110,7 +110,7 @@ body {
       max-width: 100%;
       margin-bottom: 20px;
       font-size: 0.9em;
-      border: solid 1px #aaa;
+      border: solid 1px $gray-400;
 
       .revision-toc-head {
         display: inline-block;
@@ -125,8 +125,8 @@ body {
 
     .meta {
       margin-top: 32px;
-      color: #666;
-      border-top: solid 1px #ccc;
+      color: $secondary;
+      border-top: solid 1px $gray-300;
     }
   }
 }

+ 1 - 6
src/client/styles/scss/_layout_kibela.scss

@@ -5,11 +5,6 @@ body.kibela {
     padding-top: 10px !important;
   }
 
-  /* navbar for kibela */
-  #page-wrapper {
-    margin-top: $grw-navbar-height + $grw-navbar-border-width;
-  }
-
   /* Logo */
   .logo {
     .logo-mark {
@@ -51,7 +46,7 @@ body.kibela {
 
   .kibela-block {
     position: relative;
-    top: 10px;
+    top: 30px;
     right: 100px;
     bottom: 0px;
     left: 0px;

+ 8 - 0
src/client/styles/scss/_linkedit-preview.scss

@@ -0,0 +1,8 @@
+.modal .modal-body .linkedit-preview {
+  height: 0;
+  padding-bottom: 50%;
+
+  .page-editor-preview-body {
+    overflow-y: unset;
+  }
+}

+ 7 - 7
src/client/styles/scss/_login.scss

@@ -101,31 +101,31 @@
     ),
     'google': (
       rgba(#24292e, 0.4),
-      #444,
+      $gray-700,
     ),
     'github': (
       rgba(lighten(black, 20%), 0.4),
-      #444,
+      $gray-700,
     ),
     'facebook': (
       rgba(#29487d, 0.4),
-      #444,
+      $gray-700,
     ),
     'twitter': (
       rgba(#1da1f2, 0.4),
-      #444,
+      $gray-700,
     ),
     'oidc': (
       rgba(#24292e, 0.4),
-      #444,
+      $gray-700,
     ),
     'saml': (
       rgba(#55a79a, 0.4),
-      #444,
+      $gray-700,
     ),
     'basic': (
       rgba(#24292e, 0.4),
-      #444,
+      $gray-700,
     ),
   );
 

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

@@ -4,7 +4,7 @@
   .grw-navbar {
     height: 60px;
     background: white;
-    border-bottom: solid 1px #e6e9ec;
+    border-bottom: solid 1px $gray-200;
     .navbar-nav {
       .confidential {
         color: white;

+ 0 - 11
src/client/styles/scss/_notification.scss

@@ -1,11 +0,0 @@
-// Slack
-.input-group-slack {
-  .input-group-addon {
-    padding: 2px 8px;
-    line-height: 1em;
-    img,
-    input {
-      vertical-align: middle;
-    }
-  }
-}

+ 48 - 14
src/client/styles/scss/_on-edit.scss

@@ -122,6 +122,26 @@ body.on-edit {
   .grw-editor-navbar-bottom {
     height: $grw-editor-navbar-bottom-height;
 
+    .grw-slack-notification {
+      .input-group-addon {
+        padding: 2px 8px;
+        line-height: 1em;
+        img,
+        input {
+          vertical-align: middle;
+        }
+      }
+      .form-control {
+        width: 80px;
+        @include media-breakpoint-up(sm) {
+          width: 130px;
+        }
+        @include media-breakpoint-up(md) {
+          width: 180px;
+        }
+      }
+    }
+
     .grw-grant-selector {
       @include media-breakpoint-down(sm) {
         .btn .label {
@@ -187,19 +207,33 @@ body.on-edit {
       }
 
       // add icon on cursor
+      .markdown-table-activated,
+      .markdown-link-activated {
+        .CodeMirror-cursor {
+          &:after {
+            position: relative;
+            top: -1.1em;
+            left: 0.3em;
+            display: block;
+            width: 1em;
+            height: 1em;
+            content: ' ';
+
+            background-repeat: no-repeat;
+            background-size: 1em;
+          }
+        }
+      }
+
       .markdown-table-activated .CodeMirror-cursor {
         &:after {
-          position: relative;
-          top: -1.1em;
-          left: 0.3em;
-          display: block;
-          width: 1em;
-          height: 1em;
-          content: ' ';
-
           background-image: url(/images/icons/editor/table.svg);
-          background-repeat: no-repeat;
-          background-size: 1em;
+        }
+      }
+
+      .markdown-link-activated .CodeMirror-cursor {
+        &:after {
+          background-image: url(/images/icons/editor/link.svg);
         }
       }
 
@@ -297,12 +331,12 @@ body.on-edit {
 
 #tag-edit-button-tooltip {
   .tooltip-inner {
-    color: #000;
-    background-color: #fff;
-    border: 1px solid #ccc;
+    color: black;
+    background-color: white;
+    border: 1px solid $gray-300;
   }
 
   .tooltip-arrow {
-    border-bottom: 5px solid #ccc;
+    border-bottom: 5px solid $gray-300;
   }
 }

+ 13 - 1
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -12,6 +12,18 @@ $warning: #ffa32b !default;
 $danger: #ff0a54 !default;
 $light: #e4e7ea !default;
 $dark: #343a40 !default;
+$gray-50: lighten($light, 7%) !default;
+$gray-100: lighten($light, 4%) !default;
+$gray-200: $light !default;
+$gray-300: darken($light, 5%) !default;
+$gray-400: darken($light, 20%) !default;
+$gray-500: darken($light, 30%) !default;
+$gray-600: lighten($dark, 10%) !default;
+$gray-700: lighten($dark, 5%) !default;
+$gray-800: $dark !default;
+$gray-900: darken($dark, 5%) !default;
+$grays: ("50": $gray-50) !default;
+$red: #ff0a54 !default;
 
 //== Typography
 //
@@ -67,7 +79,7 @@ $alert-color-level: -10;
 //== Progress bar
 $progress-height: 4px;
 $progress-border-radius: $border-radius-sm;
-$progress-bg: #f0f0f0;
+$progress-bg: $gray-100;
 $progress-box-shadow: none;
 
 //== Code

+ 2 - 0
src/client/styles/scss/_override-bootstrap.scss

@@ -79,7 +79,9 @@
   // Dropdowns
   .dropdown-toggle {
     &.btn.disabled {
+      pointer-events: auto;
       cursor: not-allowed;
+      opacity: unset;
     }
 
     // hide caret

+ 4 - 4
src/client/styles/scss/_page.scss

@@ -4,7 +4,7 @@
 .main-container {
   .url-line {
     font-size: 1rem;
-    color: #999;
+    color: $gray-400;
   }
 
   h1.title {
@@ -17,7 +17,7 @@
 
     // crowi layout only
     a.last-path {
-      color: #ccc;
+      color: $gray-300;
 
       &:hover {
         color: inherit;
@@ -65,7 +65,7 @@
 
       .revision-history-diff {
         padding-left: 40px;
-        color: #333;
+        color: $gray-900;
         table-layout: fixed;
       }
     }
@@ -171,7 +171,7 @@
   left: 5%;
   width: 90%;
   height: 90%;
-  background: #000;
+  background: black;
 
   iframe {
     width: 100%;

+ 3 - 3
src/client/styles/scss/_page_list.scss

@@ -54,7 +54,7 @@ body .page-list {
 .popular-page-high {
   font-size: 1.1em;
   font-weight: bold;
-  color: #e80000;
+  color: darken($red, 5%);
 }
 
 .popular-page-mid {
@@ -67,8 +67,8 @@ body .page-list {
 }
 
 .card-timeline {
-  border: 1px solid #ccc;
+  border: 1px solid $gray-300;
   > .card-header {
-    background-color: #ccc;
+    background-color: $gray-300;
   }
 }

+ 9 - 9
src/client/styles/scss/_search.scss

@@ -1,6 +1,6 @@
 .search-listpage-icon {
   font-size: 16px;
-  color: #999;
+  color: $gray-400;
 }
 
 .search-listpage-clear {
@@ -11,7 +11,7 @@
   height: 22px;
   padding: 8px;
   font-size: 0.6em;
-  color: #ccc;
+  color: $gray-300;
 }
 
 .search-typeahead {
@@ -26,7 +26,7 @@
     width: 24px;
     height: 24px;
     padding: 0;
-    color: #999;
+    color: $gray-400;
   }
 
   .rbt-menu {
@@ -48,7 +48,7 @@
 
       .page-list-meta {
         font-size: 0.9em;
-        color: #999;
+        color: $gray-400;
 
         > span {
           margin-right: 0.3rem;
@@ -193,12 +193,12 @@
   }
 
   .search-result-content {
-    padding-bottom: 32px;
+    padding-bottom: 36px;
 
     .search-result-page {
-      padding-top: 48px;
+      padding-top: 64px;
       // adjust for anchor links by the height of fixed .search-page-input
-      margin-top: -48px;
+      margin-top: -64px;
 
       > h2 {
         margin-right: 10px;
@@ -213,7 +213,7 @@
       .wiki {
         padding: 16px;
         font-size: 13px;
-        border: solid 1px #ccc;
+        border: solid 1px $gray-300;
       }
     }
   }
@@ -221,7 +221,7 @@
 
 .search-page-input {
   position: sticky;
-  top: 65px;
+  top: 15px;
   // placed at front-most
   z-index: 15;
 

+ 12 - 0
src/client/styles/scss/_sharelink.scss

@@ -0,0 +1,12 @@
+.share-link-form {
+  /* Chrome/Safari */
+  input[type='number']::-webkit-outer-spin-button,
+  input[type='number']::-webkit-inner-spin-button {
+    -webkit-appearance: none;
+  }
+
+  /* Firefox */
+  input[type='number'] {
+    -moz-appearance: textfield;
+  }
+}

+ 3 - 3
src/client/styles/scss/_shortcuts.scss

@@ -30,15 +30,15 @@
     margin: 0px 4px;
     /*Text Properties*/
     font: 18px/36px Helvetica, serif;
-    color: #666;
+    color: $secondary;
     text-align: center;
     text-transform: uppercase;
-    background: #fff;
+    background: white;
     border-radius: 4px;
     box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.5);
     /* SVG Properties*/
     polygon {
-      fill: #666;
+      fill: $secondary;
     }
 
     &.key-longer {

Некоторые файлы не были показаны из-за большого количества измененных файлов