Taichi Masuyama 4 лет назад
Родитель
Сommit
bae97611e3
67 измененных файлов с 1616 добавлено и 853 удалено
  1. 1 0
      .stylelintrc.json
  2. 67 1
      CHANGELOG.md
  3. 3 3
      README_JP.md
  4. 5 1
      package.json
  5. 1 0
      packages/app/.stylelintrc.json
  6. 3 3
      packages/app/config/webpack.prod.js
  7. 41 36
      packages/app/docker/Dockerfile
  8. 5 6
      packages/app/docker/Dockerfile.dockerignore
  9. 2 2
      packages/app/docker/README.md
  10. 8 10
      packages/app/package.json
  11. 13 0
      packages/app/resource/locales/en_US/translation.json
  12. 13 0
      packages/app/resource/locales/ja_JP/translation.json
  13. 13 0
      packages/app/resource/locales/zh_CN/translation.json
  14. 0 27
      packages/app/src/client/legacy/crowi.js
  15. 1 0
      packages/app/src/client/services/EditorContainer.js
  16. 56 10
      packages/app/src/client/services/PageContainer.js
  17. 6 2
      packages/app/src/client/util/apiv1-client.ts
  18. 83 0
      packages/app/src/components/BookmarkButtons.tsx
  19. 9 10
      packages/app/src/components/EventListeneres/HashChanged.tsx
  20. 0 33
      packages/app/src/components/ExpandOrContractButton.jsx
  21. 37 0
      packages/app/src/components/ExpandOrContractButton.tsx
  22. 49 15
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  23. 70 0
      packages/app/src/components/Navbar/SubNavButtons.jsx
  24. 23 0
      packages/app/src/components/Page/TagLabels.jsx
  25. 35 45
      packages/app/src/components/PageEditor/AbstractEditor.tsx
  26. 4 3
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  27. 282 0
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  28. 100 83
      packages/app/src/components/PageEditor/Editor.jsx
  29. 52 1
      packages/app/src/components/PageStatusAlert.jsx
  30. 8 0
      packages/app/src/components/SavePageControls.jsx
  31. 10 7
      packages/app/src/components/Sidebar/RecentChanges.tsx
  32. 3 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  33. 2 0
      packages/app/src/components/Sidebar/SidebarNav.tsx
  34. 44 0
      packages/app/src/components/Sidebar/Tag.tsx
  35. 38 0
      packages/app/src/components/TagCloudBox.tsx
  36. 44 18
      packages/app/src/components/TagsList.jsx
  37. 58 0
      packages/app/src/components/UncontrolledCodeMirror.tsx
  38. 7 0
      packages/app/src/interfaces/bookmarks.ts
  39. 7 0
      packages/app/src/interfaces/revision.ts
  40. 1 0
      packages/app/src/interfaces/ui.ts
  41. 69 0
      packages/app/src/migrations/20210921173042-add-is-trashed-field.js
  42. 3 0
      packages/app/src/server/crowi/express-init.js
  43. 45 25
      packages/app/src/server/models/page-tag-relation.js
  44. 5 1
      packages/app/src/server/models/vo/s2c-message.js
  45. 8 2
      packages/app/src/server/routes/apiv3/bookmarks.js
  46. 2 0
      packages/app/src/server/routes/apiv3/pages.js
  47. 11 2
      packages/app/src/server/routes/page.js
  48. 16 22
      packages/app/src/server/routes/tag.js
  49. 6 0
      packages/app/src/server/service/config-loader.ts
  50. 6 0
      packages/app/src/server/service/page.js
  51. 2 1
      packages/app/src/server/service/passport.ts
  52. 2 3
      packages/app/src/server/service/search.ts
  53. 2 1
      packages/app/src/server/util/apiResponse.js
  54. 2 5
      packages/app/src/server/views/tags.html
  55. 21 0
      packages/app/src/stores/bookmarks.tsx
  56. 2 2
      packages/app/src/stores/context.tsx
  57. 2 1
      packages/app/src/stores/page.tsx
  58. 33 22
      packages/app/src/stores/ui.tsx
  59. 1 1
      packages/app/src/styles/_sidebar.scss
  60. 0 1
      packages/core/package.json
  61. 1 2
      packages/plugin-attachment-refs/package.json
  62. 1 1
      packages/plugin-lsx/package.json
  63. 0 2
      packages/plugin-pukiwiki-like-linker/package.json
  64. 49 36
      packages/slackbot-proxy/docker/Dockerfile
  65. 5 6
      packages/slackbot-proxy/docker/Dockerfile.dockerignore
  66. 0 1
      packages/ui/package.json
  67. 118 400
      yarn.lock

+ 1 - 0
.stylelintrc.json

@@ -2,6 +2,7 @@
   "extends": [
     "stylelint-config-recess-order"
   ],
+  "customSyntax": "postcss-scss",
   "rules": {
     "indentation": 2,
     "string-quotes": "single",

+ 67 - 1
CHANGELOG.md

@@ -1,9 +1,75 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.8...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.8](https://github.com/weseek/growi/compare/v4.5.7...v4.5.8) - 2022-01-12
+
+### 💎 Features
+
+- feat: Display a list of bookmarked users (#5044) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Built-in editor scroll position is reset after save (Introduced by v4.5.3) (#5074) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Bump y18n to v4.0.3 (#5071) @yuki-takei
+- support: Omit prettier-stylelint (#5070) @yuki-takei
+- support: Bump tar to 6.1.11 (#5069) @yuki-takei
+
+## [v4.5.7](https://github.com/weseek/growi/compare/v4.5.6...v4.5.7) - 2022-01-11
+
+### 🐛 Bug Fixes
+
+- fix: Subnavigation sticking initialization (#5062) @yuki-takei
+- fix: Built-in editor was broken (#5061) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Bump re2 to 1.17.2 (#5059) @yuki-takei
+
+## [v4.5.6](https://github.com/weseek/growi/compare/v4.5.5...v4.5.6) - 2022-01-07
+
+### 💎 Features
+
+- feat: Resolve conflict with 3-way merge like editor (#5012) @yuto-oweseek
+
+### 🚀 Improvement
+
+- imprv: Subnavigation behavior (#5047) @yuki-takei
+- imprv: Switching editor mode behavior (#5043) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- Bug: Error: The specified instance couldn't register because same id has already been registered (#5031) by #5043 @yuki-takei
+
+## [v4.5.5](https://github.com/weseek/growi/compare/v4.5.4...v4.5.5) - 2022-01-05
+
+### 💎 Features
+
+- feat: OIDC reconnection (#5016) @mudana-grune
+- feat: In-App Notification (#4792) @kaoritokashiki
+
+### 🚀 Improvement
+
+- imprv: Improve tags functions (#5001) @yuto-oweseek
+- imprv: Migrate editor container grant to SWR (#4957) @stevenfukase
+
+### 🐛 Bug Fixes
+
+- Bug: Error: The specified instance couldn't register because same id has already been registered (#5031) by 573216c @yuki-takei
+
+### 🧰 Maintenance
+
+- fix: dependabot alert trim-newlines (#4931) @mudana-grune
+- fix: dependabot alert dot-prop (#4921) @mudana-grune
+- ci(deps-dev): bump tsconfig-paths-webpack-plugin from 3.5.1 to 3.5.2 (#4852) @dependabot
+- ci(deps): bump ua-parser-js from 0.7.17 to 0.7.31 (#4895) @dependabot
+- support: dependabot alert ssri (#4973) @mudana-grune
+
 ## [v4.5.4](https://github.com/weseek/growi/compare/v4.5.3...v4.5.4) - 2021-12-23
 
 ### 💎 Features

+ 3 - 3
README_JP.md

@@ -38,15 +38,15 @@
 # 機能紹介
 
 - **主な機能**
-  - マークダウンを使用してページを階層構造で作成することが可能です。 -> 5 分間チュートリアルは[こちら](https://docs.growi.org/ja/guide/getting-started/five_minutes.html))
-  - HackMD(CodiMd)[https://hackmd.io/] と連携することで同時多人数編集が可能です。
+  - マークダウンを使用してページを階層構造で作成することが可能です。 -> 5 分間チュートリアルは[こちら](https://docs.growi.org/ja/guide/getting-started/five_minutes.html)。
+  - [HackMD(CodiMd)](https://hackmd.io/) と連携することで同時多人数編集が可能です。
     - [GROWI Docs: HackMD(CodiMD) 連携](https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html)
   - LDAP / Active Direcotry , OAuth 認証をサポートしています。
   - SAML を用いた Single Sign On が可能です。
   - Slack / Mattermost, IFTTT と連携することが可能です。
   - [GROWI Docs: 機能紹介](https://docs.growi.org/ja/guide/features/page_layout.html)
 - **プラグイン**
-  - [npm](https://www.npmjs.com/browse/keyword/growi-plugin) または [github](https://github.com/search?q=topic%3Agrowi-plugin) から 便利なプラグインを見つけることができます。
+  - [npm](https://www.npmjs.com/browse/keyword/growi-plugin) または [GitHub](https://github.com/search?q=topic%3Agrowi-plugin) から 便利なプラグインを見つけることができます。
 - **[Docker の準備][dockerhub]**
 - **[Docker Compose の準備][docker-compose]**
   - [GROWI Docs: 複数の GROWI を起動](https://docs.growi.org/ja/admin-guide/admin-cookbook/multi-app.html)

+ 5 - 1
package.json

@@ -67,8 +67,12 @@
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
+    "postcss": "^8.4.5",
+    "postcss-scss": "^4.0.3",
     "rewire": "^5.0.0",
-    "shipjs": "^0.23.3",
+    "shipjs": "^0.24.1",
+    "stylelint": "^14.2.0",
+    "stylelint-config-recess-order": "^3.0.0",
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",

+ 1 - 0
packages/app/.stylelintrc.json

@@ -2,6 +2,7 @@
   "extends": [
     "stylelint-config-recess-order"
   ],
+  "customSyntax": "postcss-scss",
   "ignoreFiles": [
     "src/styles/_override-bootstrap-variables.scss",
     "src/linter-checker/test.scss"

+ 3 - 3
packages/app/config/webpack.prod.js

@@ -35,10 +35,10 @@ module.exports = require('./webpack.common')({
             loader: 'postcss-loader',
             options: {
               sourceMap: false,
-              plugins: () => {
-                return [
+              postcssOptions: {
+                plugins: [
                   require('autoprefixer')(),
-                ];
+                ],
               },
             },
           },

+ 41 - 36
packages/app/docker/Dockerfile

@@ -1,36 +1,40 @@
-# syntax = docker/dockerfile:experimental
+# syntax = docker/dockerfile:1
 
 ARG flavor=default
 
 
+##
+## packages-json-picker
+##
+FROM node:14-slim AS packages-json-picker
+
+ENV optDir /opt
+
+WORKDIR ${optDir}
+COPY ["package.json", "yarn.lock", "lerna.json", "./"]
+COPY packages packages
+# Find and remove non-package.json files
+RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
+
 
 ##
 ## deps-resolver
 ##
 FROM node:14-slim AS deps-resolver
-LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
-WORKDIR ${appDir}
-COPY ./package.json .
-COPY ./yarn.lock .
-COPY ./lerna.json .
-COPY ./packages/app/package.json packages/app/
-COPY ./packages/core/package.json packages/core/
-COPY ./packages/codemirror-textlint/package.json packages/codemirror-textlint/
-COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
-COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
-COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
-COPY ./packages/slack/package.json packages/slack/
-COPY ./packages/ui/package.json packages/ui/
+WORKDIR ${optDir}
+
+# copy files
+COPY --from=packages-json-picker ${optDir} .
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN npx lerna bootstrap
+RUN npx lerna bootstrap -- --frozen-lockfile
 
 # make artifacts
-RUN tar cf node_modules.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
   packages/*/node_modules
 
@@ -40,9 +44,13 @@ RUN tar cf node_modules.tar \
 ## deps-resolver-prod
 ##
 FROM deps-resolver AS deps-resolver-prod
+
+# remove unnecessary packages
+RUN rm -rf packages/slackbot-proxy
+
 RUN npx lerna bootstrap -- --production
 # make artifacts
-RUN tar cf node_modules.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
   packages/*/node_modules
 
@@ -53,16 +61,16 @@ RUN tar cf node_modules.tar \
 ##
 FROM node:14-slim AS prebuilder-default
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
 # copy dependent packages
 COPY --from=deps-resolver \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${optDir}/
 
 # extract node_modules.tar
-RUN tar xf node_modules.tar
+RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
 
@@ -73,7 +81,7 @@ RUN rm node_modules.tar
 FROM prebuilder-default AS prebuilder-nocdn
 
 # add dotenv file for NO_CDN
-COPY packages/app/docker/nocdn/.env.production.local ${appDir}/packages/app/
+COPY packages/app/docker/nocdn/.env.production.local ${optDir}/packages/app/
 
 
 
@@ -82,14 +90,11 @@ COPY packages/app/docker/nocdn/.env.production.local ${appDir}/packages/app/
 ##
 FROM prebuilder-${flavor} AS builder
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
-COPY ./package.json ./
-COPY ./yarn.lock ./
-COPY ./lerna.json ./
-COPY ./tsconfig.base.json ./
+COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
 COPY packages/app packages/app
 COPY packages/core packages/core
@@ -104,9 +109,8 @@ COPY packages/ui packages/ui
 RUN yarn lerna run build
 
 # make artifacts
-RUN tar cf packages.tar \
+RUN tar -cf packages.tar \
   package.json \
-  yarn.lock \
   tsconfig.base.json \
   packages/app/config \
   packages/app/public \
@@ -129,7 +133,8 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production
 
-ENV appDir /opt/growi
+ENV optDir /opt
+ENV appDir ${optDir}/growi
 
 # Add gosu
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
@@ -141,15 +146,15 @@ RUN set -eux; \
 	gosu nobody true
 
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
-  ${appDir}/packages.tar ${appDir}/
+  ${optDir}/packages.tar ${appDir}/
 
 # extract artifacts as 'node' user
 USER node
 WORKDIR ${appDir}
-RUN tar xf node_modules.tar
-RUN tar xf packages.tar
+RUN tar -xf node_modules.tar
+RUN tar -xf packages.tar
 RUN rm node_modules.tar packages.tar
 
 USER root

+ 5 - 6
packages/app/docker/Dockerfile.dockerignore

@@ -1,6 +1,5 @@
-node_modules
-*/node_modules
-*/coverage
-*/dist
-*/Dockerfile
-*/*.dockerignore
+**/node_modules
+**/dist
+**/coverage
+**/Dockerfile
+**/*.dockerignore

+ 2 - 2
packages/app/docker/README.md

@@ -12,8 +12,8 @@ Supported tags and respective Dockerfile links
 
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.4`, `4.5`, `4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
-* [`4.5.4-nocdn`, `4.5-nocdn`, `4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
+* [`4.5.8`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
+* [`4.5.8-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 

+ 8 - 10
packages/app/package.json

@@ -6,7 +6,7 @@
     "//// for production": "",
     "start": "yarn build && yarn server",
     "build": "run-p build:*",
-    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
+    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
@@ -33,7 +33,7 @@
     "predev:ci": "run-p resources:*",
     "lint:typecheck": "npx tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
-    "lint:styles": "stylelint src/**/*.scss --custom-syntax postcss-scss",
+    "lint:styles": "stylelint src/**/*.scss",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
@@ -51,7 +51,6 @@
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "mongoose": "5.13.13 causes an error like 't.versions.node is undefined' about 'browser.umd.js' on browser",
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
@@ -78,6 +77,7 @@
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
+    "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
@@ -86,6 +86,7 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
+    "diff_match_patch": "^0.1.1",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
@@ -132,12 +133,12 @@
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
-    "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
+    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
@@ -234,10 +235,8 @@
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
-    "stylelint": "^14.0.1",
-    "stylelint-config-recess-order": "^2.0.1",
     "swagger2openapi": "^5.3.1",
-    "swr": "^1.0.1",
+    "swr": "^1.1.2",
     "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
@@ -246,10 +245,9 @@
     "tsc-alias": "^1.2.9",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",
-    "webpack": "^4.39.3",
+    "webpack": "^4.46.0",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.9.0",
-    "webpack-cli": "^3.3.7",
-    "webpack-merge": "^4.2.2"
+    "webpack-cli": "^4.9.1"
   }
 }

+ 13 - 0
packages/app/resource/locales/en_US/translation.json

@@ -60,6 +60,7 @@
   "The end": "The end",
   "Not available for guest": "Not available for guest",
   "No users have liked this yet.": "No users have liked this yet.",
+  "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
   "Target page": "Target page",
@@ -142,6 +143,7 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
+  "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Load latest": "Load latest",
@@ -484,6 +486,17 @@
     "enable_textlint": "Enable Textlint",
     "dont_ask_again": "Don't ask again"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
+    "resolve_conflict_message": "Please select page body",
+    "resolve_conflict": "Resolve Conflict",
+    "resolve_and_save" : "Resolve and save",
+    "select_revision" : "Select {{revision}}",
+    "requested_revision": "mine",
+    "origin_revision": "origin",
+    "latest_revision": "theirs",
+    "selected_editable_revision": "Selected Page Body (Editable)"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 13 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -60,6 +60,7 @@
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
   "File type": "ファイル形式",
@@ -141,6 +142,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
+  "Check All tags": "全てのタグをチェックする",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
@@ -484,6 +486,17 @@
     "enable_textlint": "Textlintを有効にする",
     "dont_ask_again": "常に許可する"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
+    "resolve_conflict_message": "ページ本文を選んでください",
+    "resolve_conflict": "衝突を解消",
+    "resolve_and_save" : "解消し保存する",
+    "select_revision" : "{{revision}}にする",
+    "requested_revision": "送信された本文",
+    "origin_revision": "送信する前の本文",
+    "latest_revision": "最新の本文",
+    "selected_editable_revision": "保存するページ本文(編集可能)"
+  },
   "link_edit": {
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",

+ 13 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -61,6 +61,7 @@
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
+  "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",
   "Target page": "目标页面",
@@ -150,6 +151,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
 	"Load latest": "家在最新",
@@ -462,6 +464,17 @@
     "enable_textlint": "启用Textlint",
     "dont_ask_again": "不要再问"
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
+    "resolve_conflict_message": "选择页面正文",
+    "resolve_conflict": "解决冲突",
+    "resolve_and_save" : "解决冲突并保存",
+    "select_revision" : "选择{{revision}}",
+    "requested_revision": "发送的页面正文",
+    "origin_revision": "发送前的页面正文",
+    "latest_revision": "最新页面正文",
+    "selected_editable_revision": "选定的可编辑页面正文"
+  },
   "link_edit": {
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",

+ 0 - 27
packages/app/src/client/legacy/crowi.js

@@ -112,33 +112,6 @@ Crowi.initClassesByOS = function() {
   });
 };
 
-// window.addEventListener('load', () => {
-//   const { appContainer } = window;
-//   const pageContainer = appContainer.getContainer('PageContainer');
-
-//   // Do nothing if the page does not exist
-//   // ex.) admin page,login page
-//   if (pageContainer == null) {
-//     return null;
-//   }
-//   const { isAbleToOpenPageEditor } = pageContainer;
-
-//   // hash on page
-//   if (window.location.hash) {
-//     const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-//       navigationContainer.setEditorMode('edit');
-
-//       // focus
-//       Crowi.setCaretLineAndFocusToEditor();
-//     }
-//     else if (window.location.hash === '#hackmd') {
-//       navigationContainer.setEditorMode('hackmd');
-//     }
-//   }
-// });
-
 window.addEventListener('load', () => {
   const crowi = window.crowi;
   if (crowi && crowi.users && crowi.users.length !== 0) {

+ 1 - 0
packages/app/src/client/services/EditorContainer.js

@@ -41,6 +41,7 @@ export default class EditorContainer extends Container {
 
     this.initDrafts();
 
+    this.editorOptions = null;
     this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }

+ 56 - 10
packages/app/src/client/services/PageContainer.js

@@ -4,9 +4,11 @@ import { Container } from 'unstated';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import { pagePathUtils } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
-import { toastError } from '../util/apiNotification';
+import { EditorMode } from '~/stores/ui';
 
+import { toastError } from '../util/apiNotification';
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
@@ -52,9 +54,6 @@ export default class PageContainer extends Container {
       path,
       tocHtml: '',
 
-      isBookmarked: false,
-      sumOfBookmarks: 0,
-
       seenUsers: [],
       seenUserIds: [],
       sumOfSeenUsers: [],
@@ -86,12 +85,15 @@ export default class PageContainer extends Container {
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
+      remoteRevisionBody: null,
+      remoteRevisionUpdateAt: null,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
+      isConflictDiffModalOpen: false,
     };
 
     // parse creator, lastUpdateUser and revisionAuthor
@@ -103,6 +105,7 @@ export default class PageContainer extends Container {
     }
     try {
       this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
+      this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
     }
     catch (e) {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
@@ -126,7 +129,6 @@ export default class PageContainer extends Container {
       // as it is stored in a separate collection to like and seen user
       // data so it has a separate api endpoint.
       this.initialPageLoad();
-      this.retrieveBookmarkInfo();
     }
 
     this.setTocHtml = this.setTocHtml.bind(this);
@@ -325,8 +327,12 @@ export default class PageContainer extends Container {
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt,
       revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
+      lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
     };
 
     if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
@@ -355,6 +361,7 @@ export default class PageContainer extends Container {
       revisionId: revision._id,
       revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
       remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       markdown: revision.body,
@@ -369,7 +376,7 @@ export default class PageContainer extends Container {
     // PageEditor component
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     if (pageEditor != null) {
-      if (editorMode !== 'edit') {
+      if (editorMode !== EditorMode.Editor) {
         pageEditor.updateEditorValue(newState.markdown);
       }
     }
@@ -377,13 +384,36 @@ export default class PageContainer extends Container {
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     if (pageEditorByHackmd != null) {
       // reset
-      if (editorMode !== 'hackmd') {
+      if (editorMode !== EditorMode.HackMD) {
         pageEditorByHackmd.reset();
       }
     }
 
-    // hidden input
-    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * update page meta data
+   * @param {object} page Page instance
+   * @param {object} revision Revision instance
+   * @param {String[]} tags Array of Tag
+   */
+  updatePageMetaData(page, revision, tags) {
+
+    const newState = {
+      revisionId: revision._id,
+      revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
+      remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      updatedAt: page.updatedAt,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+
+    this.setState(newState);
+
   }
 
   /**
@@ -395,7 +425,6 @@ export default class PageContainer extends Container {
   async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
-
     const options = Object.assign({}, optionsToSave);
 
     if (editorMode === 'hackmd') {
@@ -574,4 +603,21 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   }
 
+  async resolveConflict(markdown, editorMode) {
+
+    const { pageId, remoteRevisionId, path } = this.state;
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const options = editorContainer.getCurrentOptionsToSave();
+    const optionsToSave = Object.assign({}, options);
+
+    const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
+
+    editorContainer.clearDraft(path);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+
+    editorContainer.setState({ tags: res.tags });
+
+    return res;
+  }
+
 }

+ 6 - 2
packages/app/src/client/util/apiv1-client.ts

@@ -17,11 +17,15 @@ class Apiv1ErrorHandler extends Error {
 
   code;
 
-  constructor(message = '', code = '') {
+  data;
+
+  constructor(message = '', code = '', data = '') {
     super();
 
     this.message = message;
     this.code = code;
+    this.data = data;
+
   }
 
 }
@@ -35,7 +39,7 @@ export async function apiRequest(method: string, path: string, params: unknown):
 
   // Return error code if code is exist
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
     throw error;
   }
 

+ 83 - 0
packages/app/src/components/BookmarkButtons.tsx

@@ -0,0 +1,83 @@
+import React, { FC, useState } from 'react';
+
+import { Types } from 'mongoose';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserPictureList from './User/UserPictureList';
+import { toastError } from '~/client/util/apiNotification';
+import { useIsGuestUser } from '~/stores/context';
+import { useSWRxBookmarksInfo } from '~/stores/bookmarks';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
+interface Props {
+  pageId: Types.ObjectId
+}
+
+const BookmarkButton: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { pageId } = props;
+
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: bookmarksInfo, mutate } = useSWRxBookmarksInfo(pageId);
+
+  const isBookmarked = bookmarksInfo?.isBookmarked != null ? bookmarksInfo.isBookmarked : false;
+  const sumOfBookmarks = bookmarksInfo?.sumOfBookmarks != null ? bookmarksInfo.sumOfBookmarks : 0;
+  const bookmarkedUsers = bookmarksInfo?.bookmarkedUsers != null ? bookmarksInfo.bookmarkedUsers : [];
+
+  const togglePopover = () => {
+    setIsPopoverOpen(!isPopoverOpen);
+  };
+
+  const handleClick = async() => {
+    if (isGuestUser) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Put('/bookmarks', { pageId, bool: !isBookmarked });
+      if (res) {
+        mutate();
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <div className="btn-group" role="group" aria-label="Bookmark buttons">
+      <button
+        type="button"
+        id="bookmark-button"
+        onClick={handleClick}
+        className={`btn btn-bookmark border-0
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+      >
+        <i className="icon-star"></i>
+      </button>
+
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${isBookmarked ? 'active' : ''}`}>
+        {sumOfBookmarks}
+      </button>
+
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            {bookmarkedUsers.length ? <UserPictureList users={bookmarkedUsers} /> : t('No users have bookmarked yet')}
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
+
+export default BookmarkButton;

+ 9 - 10
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,23 +1,22 @@
 import { FC, useCallback, useEffect } from 'react';
 
-import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
 
+/**
+ * Change editorMode by browser forward/back operation
+ */
 const HashChanged: FC<void> = () => {
   const { data: isEditable } = useIsEditable();
-  const { mutate: mutateEditorMode } = useEditorMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
   const hashchangeHandler = useCallback(() => {
-    const { hash } = window.location;
+    const newEditorMode = determineEditorModeByHash();
 
-    if (hash == null) {
-      return;
-    }
-
-    if (hash === '#edit') {
-      mutateEditorMode(EditorMode.Editor);
+    if (editorMode !== newEditorMode) {
+      mutateEditorMode(newEditorMode);
     }
-  }, [mutateEditorMode]);
+  }, [editorMode, mutateEditorMode]);
 
   // setup effect
   useEffect(() => {

+ 0 - 33
packages/app/src/components/ExpandOrContractButton.jsx

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

+ 37 - 0
packages/app/src/components/ExpandOrContractButton.tsx

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

+ 49 - 15
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,9 +1,12 @@
-import React, { useState, useEffect, useCallback } from 'react';
-// import PropTypes from 'prop-types';
+import React, {
+  useMemo, useState, useRef, useEffect, useCallback,
+} from 'react';
 
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
+
 import loggerFactory from '~/utils/logger';
+import { useSidebarCollapsed } from '~/stores/ui';
 
 import GrowiSubNavigation from './GrowiSubNavigation';
 
@@ -21,24 +24,43 @@ const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
  */
 const GrowiSubNavigationSwitcher = (props) => {
 
+  const { data: isSidebarCollapsed } = useSidebarCollapsed();
+
   const [isVisible, setVisible] = useState(false);
+  const [width, setWidth] = useState(null);
+
+  const fixedContainerRef = useRef();
+  const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
 
-  const resetWidth = useCallback(() => {
-    const elem = document.getElementById('grw-subnav-fixed-container');
+  const initWidth = useCallback(() => {
+    const instance = fixedContainerRef.current;
 
-    if (elem == null || elem.parentNode == null) {
+    if (instance == null || instance.parentNode == null) {
       return;
     }
 
     // get parent width
-    const { clientWidth: width } = elem.parentNode;
+    const { clientWidth } = instance.parentNode;
     // update style
-    elem.style.width = `${width}px`;
+    setWidth(clientWidth);
   }, []);
 
+  const initVisible = useCallback(() => {
+    const elements = stickyEvents.stickyElements;
+
+    for (const elem of elements) {
+      const bool = stickyEvents.isSticking(elem);
+      if (bool) {
+        setVisible(bool);
+        break;
+      }
+    }
+
+  }, [stickyEvents]);
+
   // setup effect by resizing event
   useEffect(() => {
-    const resizeHandler = debounce(100, resetWidth);
+    const resizeHandler = debounce(100, initWidth);
 
     window.addEventListener('resize', resizeHandler);
 
@@ -46,7 +68,7 @@ const GrowiSubNavigationSwitcher = (props) => {
     return () => {
       window.removeEventListener('resize', resizeHandler);
     };
-  }, [resetWidth]);
+  }, [initWidth]);
 
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
@@ -57,7 +79,6 @@ const GrowiSubNavigationSwitcher = (props) => {
   useEffect(() => {
     // sticky
     // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
     const { stickySelector } = stickyEvents;
     const elem = document.querySelector(stickySelector);
     elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
@@ -66,16 +87,29 @@ const GrowiSubNavigationSwitcher = (props) => {
     return () => {
       elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
     };
-  }, [stickyChangeHandler]);
+  }, [stickyChangeHandler, stickyEvents]);
+
+  // update width when sidebar collapsing changed
+  useEffect(() => {
+    if (isSidebarCollapsed != null) {
+      setTimeout(initWidth, 300);
+    }
+  }, [isSidebarCollapsed, initWidth]);
 
-  // update width
+  // initialize
   useEffect(() => {
-    resetWidth();
-  });
+    initWidth();
+
+    // check sticky state several times
+    setTimeout(initVisible, 100);
+    setTimeout(initVisible, 300);
+    setTimeout(initVisible, 2000);
+
+  }, [initWidth, initVisible]);
 
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed">
+      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
         <GrowiSubNavigation isCompactMode />
       </div>
     </div>

+ 70 - 0
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import AppContainer from '~/client/services/AppContainer';
+import PageContainer from '~/client/services/PageContainer';
+import { useCurrentPageId } from '~/stores/context';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import BookmarkButtons from '../BookmarkButtons';
+import LikeButtons from '../LikeButtons';
+import SubscribeButton from '../SubscribeButton';
+import PageManagement from '../Page/PageManagement';
+
+const SubnavButtons = React.memo((props) => {
+  const {
+    appContainer, pageContainer, isCompactMode,
+  } = props;
+
+  const { data: pageId } = useCurrentPageId();
+  const { data: editorMode } = useEditorMode();
+
+  /* eslint-disable react/prop-types */
+  const PageReactionButtons = ({ pageContainer }) => {
+
+    return (
+      <>
+        <span>
+          <SubscribeButton pageId={pageId} />
+        </span>
+        {pageContainer.isAbleToShowLikeButtons && (
+          <span>
+            <LikeButtons />
+          </span>
+        )}
+        <span>
+          <BookmarkButtons pageId={pageId} />
+        </span>
+      </>
+    );
+  };
+  /* eslint-enable react/prop-types */
+
+  const isViewMode = editorMode === EditorMode.View;
+
+  return (
+    <>
+      {isViewMode && (
+        <>
+          {pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} />}
+          {pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} />}
+        </>
+      )}
+    </>
+  );
+});
+
+/**
+ * Wrapper component for using unstated
+ */
+const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, PageContainer]);
+
+
+SubnavButtons.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
+};
+
+export default SubnavButtonsWrapper;

+ 23 - 0
packages/app/src/components/Page/TagLabels.jsx

@@ -31,6 +31,29 @@ class TagLabels extends React.Component {
     this.setState({ isTagEditModalShown: false });
   }
 
+  async tagsUpdatedHandler(newTags) {
+    const {
+      appContainer, editorContainer, pageContainer, editorMode,
+    } = this.props;
+
+    const { pageId, revisionId } = pageContainer.state;
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === EditorMode.Editor) {
+      return editorContainer.setState({ tags: newTags });
+    }
+    try {
+      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
+        pageId, tags: newTags, revisionId,
+      });
+      editorContainer.setState({ tags });
+      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  }
+
 
   render() {
     const { appContainer, tagsUpdateInvoked, tags } = this.props;

+ 35 - 45
packages/app/src/components/PageEditor/AbstractEditor.jsx → packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -1,11 +1,20 @@
-/* eslint-disable react/no-unused-prop-types */
-
+/* eslint-disable @typescript-eslint/no-unused-vars */
 import React from 'react';
-import PropTypes from 'prop-types';
+import { ICodeMirror } from 'react-codemirror2';
+
+
+export interface AbstractEditorProps extends ICodeMirror {
+  value?: string;
+  isGfmMode?: boolean;
+  onScrollCursorIntoView?: (line: number) => void;
+  onSave?: () => Promise<void>;
+  onPasteFiles?: (event: Event) => void;
+  onCtrlEnter?: (event: Event) => void;
+}
 
-export default class AbstractEditor extends React.Component {
+export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 
-  constructor(props) {
+  constructor(props: Readonly<T>) {
     super(props);
 
     this.forceToFocus = this.forceToFocus.bind(this);
@@ -20,91 +29,87 @@ export default class AbstractEditor extends React.Component {
     this.dispatchSave = this.dispatchSave.bind(this);
   }
 
-  forceToFocus() {
-  }
+  forceToFocus(): void {}
 
   /**
    * set new value
    */
-  setValue(newValue) {
-  }
+  setValue(_newValue: string): void {}
 
   /**
    * Enable/Disable GFM mode
-   * @param {bool} bool
+   * @param {bool} _bool
    */
-  setGfmMode(bool) {
-  }
+  setGfmMode(_bool: boolean): void {}
 
   /**
    * set caret position of codemirror
    * @param {string} number
    */
-  setCaretLine(line) {
-  }
+  setCaretLine(_line: number): void {}
 
   /**
    * scroll
-   * @param {number} line
+   * @param {number} _line
    */
-  setScrollTopByLine(line) {
-  }
+  setScrollTopByLine(_line: number): void {}
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBol() {
+  getStrFromBol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from current position to EOL(end of line)
    */
-  getStrToEol() {
+  getStrToEol(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * return strings from BOL(beginning of line) to current position
    */
-  getStrFromBolToSelectedUpperPos() {
+  getStrFromBolToSelectedUpperPos(): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace Beggining Of Line to current position with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceBolToCurrentPos(text) {
+  replaceBolToCurrentPos(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * replace the current line with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
-  replaceLine(text) {
+  replaceLine(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert text
-   * @param {string} text
+   * @param {string} _text
    */
-  insertText(text) {
+  insertText(_text: string): Error {
+    throw new Error('this method should be impelemented in subclass');
   }
 
   /**
    * insert line break to the current position
    */
-  insertLinebreak() {
+  insertLinebreak(): void {
     this.insertText('\n');
   }
 
   /**
    * dispatch onSave event
    */
-  dispatchSave() {
+  dispatchSave(): void {
     if (this.props.onSave != null) {
       this.props.onSave();
     }
@@ -114,7 +119,7 @@ export default class AbstractEditor extends React.Component {
    * dispatch onPasteFiles event
    * @param {object} event
    */
-  dispatchPasteFiles(event) {
+  dispatchPasteFiles(event: Event): void {
     if (this.props.onPasteFiles != null) {
       this.props.onPasteFiles(event);
     }
@@ -123,23 +128,8 @@ export default class AbstractEditor extends React.Component {
   /**
    * returns items(an array of react elements) in navigation bar for editor
    */
-  getNavbarItems() {
+  getNavbarItems(): null {
     return null;
   }
 
 }
-
-AbstractEditor.propTypes = {
-  value: PropTypes.string,
-  isGfmMode: PropTypes.bool,
-  onChange: PropTypes.func,
-  onScroll: PropTypes.func,
-  onScrollCursorIntoView: PropTypes.func,
-  onSave: PropTypes.func,
-  onPasteFiles: PropTypes.func,
-  onDragEnter: PropTypes.func,
-  onCtrlEnter: PropTypes.func,
-};
-AbstractEditor.defaultProps = {
-  isGfmMode: true,
-};

+ 4 - 3
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
 
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
+import { UnControlled as UncontrolledCodeMirror } from 'react-codemirror2';
 
 import { Button } from 'reactstrap';
-import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
 import { JSHINT } from 'jshint';
 
@@ -32,6 +32,7 @@ import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
+// import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 // Textlint
 window.JSHINT = JSHINT;
@@ -109,7 +110,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.state = {
       value: this.props.value,
-      isGfmMode: this.props.isGfmMode,
+      isGfmMode: this.props.isGfmMode ?? true,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
@@ -924,7 +925,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return (
       <React.Fragment>
 
-        <ReactCodeMirror
+        <UncontrolledCodeMirror
           ref={(c) => { this.cm = c }}
           className={additionalClasses}
           placeholder="search"

+ 282 - 0
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -0,0 +1,282 @@
+import React, {
+  useState, useEffect, FC, useRef,
+} from 'react';
+import PropTypes from 'prop-types';
+import { UserPicture } from '@growi/ui';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+import CodeMirror from 'codemirror/lib/codemirror';
+
+import PageContainer from '../../client/services/PageContainer';
+import AppContainer from '../../client/services/AppContainer';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import { useEditorMode } from '~/stores/ui';
+
+import { IRevisionOnConflict } from '../../interfaces/revision';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
+
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/merge/merge');
+require('codemirror/addon/merge/merge.css');
+const DMP = require('diff_match_patch');
+
+Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
+
+type ConflictDiffModalProps = {
+  isOpen: boolean | null;
+  onClose?: (() => void);
+  pageContainer: PageContainer;
+  appContainer: AppContainer;
+  markdownOnEdit: string;
+};
+
+type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
+  createdAt: string
+}
+
+export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const [resolvedRevision, setResolvedRevision] = useState<string>('');
+  const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
+  const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
+  const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
+
+  const { data: editorMode } = useEditorMode();
+
+  const uncontrolledRef = useRef<CodeMirror>(null);
+
+  const { pageContainer, appContainer } = props;
+
+  const currentTime: Date = new Date();
+
+  const request: IRevisionOnConflictWithStringDate = {
+    revisionId: '',
+    revisionBody: props.markdownOnEdit,
+    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+    user: appContainer.currentUser,
+  };
+  const origin: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.revisionId || '',
+    revisionBody: pageContainer.state.markdown || '',
+    createdAt: pageContainer.state.updatedAt || '',
+    user: pageContainer.state.revisionAuthor,
+  };
+  const latest: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.remoteRevisionId || '',
+    revisionBody: pageContainer.state.remoteRevisionBody || '',
+    createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
+    user: pageContainer.state.lastUpdateUser,
+  };
+
+  useEffect(() => {
+    if (codeMirrorRef != null) {
+      CodeMirror.MergeView(codeMirrorRef, {
+        value: origin.revisionBody,
+        origLeft: request.revisionBody,
+        origRight: latest.revisionBody,
+        lineNumbers: true,
+        collapseIdentical: true,
+        showDifferences: true,
+        highlightDifferences: true,
+        connect: 'connect',
+        readOnly: true,
+        revertButtons: false,
+      });
+    }
+  }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
+
+  const onClose = () => {
+    if (props.onClose != null) {
+      props.onClose();
+    }
+  };
+
+  const onResolveConflict = async() : Promise<void> => {
+    // disable button after clicked
+    setIsRevisionSelected(false);
+
+    const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
+
+    try {
+      await pageContainer.resolveConflict(codeMirrorVal, editorMode);
+      onClose();
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      pageContainer.showErrorToastr(error);
+    }
+
+  };
+
+  const onExpandModal = () => {
+    setIsModalExpanded(true);
+  };
+
+  const onContractModal = () => {
+    setIsModalExpanded(false);
+  };
+
+  const resizeAndCloseButtons = (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isModalExpanded}
+        expandWindow={onExpandModal}
+        contractWindow={onContractModal}
+      />
+      <button type="button" className="close text-white" onClick={onClose} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      isOpen={props.isOpen || false}
+      toggle={onClose}
+      backdrop="static"
+      className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
+      size="xl"
+    >
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light align-items-center py-3" close={resizeAndCloseButtons}>
+        <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
+      </ModalHeader>
+      <ModalBody className="mx-4 my-1">
+        { props.isOpen
+        && (
+          <div className="row">
+            <div className="col-12 text-center mt-2 mb-4">
+              <h2 className="font-weight-bold">{t('modal_resolve_conflict.resolve_conflict_message')}</h2>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.requested_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={request.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {request.user.username}</p>
+                  <p className="my-0">{request.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.origin_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={origin.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {origin.user.username}</p>
+                  <p className="my-0">{origin.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.latest_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={latest.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {latest.user.username}</p>
+                  <p className="my-0">{latest.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-12" ref={(el) => { setCodeMirrorRef(el) }}></div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(request.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(origin.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'origin' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(latest.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-12">
+              <div className="border border-dark">
+                <h3 className="font-weight-bold my-2 mx-2">{t('modal_resolve_conflict.selected_editable_revision')}</h3>
+                <UncontrolledCodeMirror
+                  ref={uncontrolledRef}
+                  value={resolvedRevision}
+                  options={{
+                    placeholder: t('modal_resolve_conflict.resolve_conflict_message'),
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        )}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={onResolveConflict}
+          disabled={!isRevisionselected}
+        >
+          {t('modal_resolve_conflict.resolve_and_save')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+ConflictDiffModal.propTypes = {
+  isOpen: PropTypes.bool,
+  onClose: PropTypes.func,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markdownOnEdit: PropTypes.string.isRequired,
+};
+
+ConflictDiffModal.defaultProps = {
+  isOpen: false,
+};

+ 100 - 83
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,8 @@ import {
 import Dropzone from 'react-dropzone';
 
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import Cheatsheet from './Cheatsheet';
@@ -18,6 +20,7 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 import pasteHelper from './PasteHelper';
+import { ConflictDiffModal } from './ConflictDiffModal';
 
 class Editor extends AbstractEditor {
 
@@ -276,6 +279,7 @@ class Editor extends AbstractEditor {
     );
   }
 
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -286,88 +290,97 @@ class Editor extends AbstractEditor {
     const isMobile = this.props.isMobile;
 
     return (
-      <div style={flexContainer} className="editor-container">
-        <Dropzone
-          ref={(c) => { this.dropzone = c }}
-          accept={this.getAcceptableType()}
-          noClick
-          noKeyboard
-          multiple={false}
-          onDragLeave={this.dragLeaveHandler}
-          onDrop={this.dropHandler}
-        >
-          {({
-            getRootProps,
-            getInputProps,
-            isDragAccept,
-            isDragReject,
-          }) => {
-            return (
-              <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                { this.state.isComponentDidMount && this.renderNavbar() }
-
-                {/* for PC */}
-                { !isMobile && (
-                  <Subscribe to={[EditorContainer]}>
-                    { editorContainer => (
-                      // eslint-disable-next-line arrow-body-style
-                      <CodeMirrorEditor
-                        ref={(c) => { this.cmEditor = c }}
-                        indentSize={editorContainer.state.indentSize}
-                        editorOptions={editorContainer.state.editorOptions}
-                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
-                        textlintRules={editorContainer.state.textlintRules}
-                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
-                        onPasteFiles={this.pasteFilesHandler}
-                        onDragEnter={this.dragEnterHandler}
-                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                        {...this.props}
-                      />
-                    )}
-                  </Subscribe>
-                )}
-
-                {/* for mobile */}
-                { isMobile && (
-                  <TextAreaEditor
-                    ref={(c) => { this.taEditor = c }}
-                    onPasteFiles={this.pasteFilesHandler}
-                    onDragEnter={this.dragEnterHandler}
-                    {...this.props}
-                  />
-                )}
-
-                <input {...getInputProps()} />
-              </div>
-            );
-          }}
-        </Dropzone>
-
-        { this.props.isUploadable
-          && (
-            <button
-              type="button"
-              className="btn btn-outline-secondary btn-block btn-open-dropzone"
-              onClick={this.addAttachmentHandler}
-            >
-              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-              Attach files
-              <span className="d-none d-sm-inline">
-              &nbsp;by dragging &amp; dropping,&nbsp;
-                <span className="btn-link">selecting them</span>,&nbsp;
-                or pasting from the clipboard.
-              </span>
-
-            </button>
-          )
-        }
-
-        { this.renderCheatsheetModal() }
-
-      </div>
+      <>
+        <div style={flexContainer} className="editor-container">
+          <Dropzone
+            ref={(c) => { this.dropzone = c }}
+            accept={this.getAcceptableType()}
+            noClick
+            noKeyboard
+            multiple={false}
+            onDragLeave={this.dragLeaveHandler}
+            onDrop={this.dropHandler}
+          >
+            {({
+              getRootProps,
+              getInputProps,
+              isDragAccept,
+              isDragReject,
+            }) => {
+              return (
+                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
+
+                  { this.state.isComponentDidMount && this.renderNavbar() }
+
+                  {/* for PC */}
+                  { !isMobile && (
+                    <Subscribe to={[EditorContainer]}>
+                      { editorContainer => (
+                        // eslint-disable-next-line arrow-body-style
+                        <CodeMirrorEditor
+                          ref={(c) => { this.cmEditor = c }}
+                          indentSize={editorContainer.state.indentSize}
+                          editorOptions={editorContainer.state.editorOptions}
+                          isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                          textlintRules={editorContainer.state.textlintRules}
+                          onInitializeTextlint={editorContainer.retrieveEditorSettings}
+                          onPasteFiles={this.pasteFilesHandler}
+                          onDragEnter={this.dragEnterHandler}
+                          onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                          onAddAttachmentButtonClicked={this.addAttachmentHandler}
+                          {...this.props}
+                        />
+                      )}
+                    </Subscribe>
+                  )}
+
+                  {/* for mobile */}
+                  { isMobile && (
+                    <TextAreaEditor
+                      ref={(c) => { this.taEditor = c }}
+                      onPasteFiles={this.pasteFilesHandler}
+                      onDragEnter={this.dragEnterHandler}
+                      {...this.props}
+                    />
+                  )}
+
+                  <input {...getInputProps()} />
+                </div>
+              );
+            }}
+          </Dropzone>
+
+          { this.props.isUploadable
+            && (
+              <button
+                type="button"
+                className="btn btn-outline-secondary btn-block btn-open-dropzone"
+                onClick={this.addAttachmentHandler}
+              >
+                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+                Attach files
+                <span className="d-none d-sm-inline">
+                &nbsp;by dragging &amp; dropping,&nbsp;
+                  <span className="btn-link">selecting them</span>,&nbsp;
+                  or pasting from the clipboard.
+                </span>
+
+              </button>
+            )
+          }
+
+          { this.renderCheatsheetModal() }
+
+        </div>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          appContainer={this.props.appContainer}
+          pageContainer={this.props.pageContainer}
+          markdownOnEdit={this.props.value}
+        />
+      </>
     );
   }
 
@@ -375,6 +388,8 @@ class Editor extends AbstractEditor {
 
 Editor.propTypes = Object.assign({
   noCdn: PropTypes.bool,
+  // this value is markdown
+  value: PropTypes.string,
   isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
@@ -382,6 +397,8 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 
-export default withUnstatedContainers(Editor, [EditorContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);

+ 52 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -26,14 +26,22 @@ class PageStatusAlert extends React.Component {
     };
 
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForRevisionOutdated = this.getContentsForRevisionOutdated.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
+    this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
   }
 
   refreshPage() {
     window.location.reload();
   }
 
+  onClickResolveConflict() {
+    this.props.pageContainer.setState({
+      isConflictDiffModalOpen: true,
+    });
+  }
+
   getContentsForSomeoneEditingAlert() {
     const { t } = this.props;
     return [
@@ -49,6 +57,45 @@ class PageStatusAlert extends React.Component {
     ];
   }
 
+  getContentsForRevisionOutdated() {
+    const { t, appContainer, pageContainer } = this.props;
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
+
+    let markdownOnEdit = '';
+    let isConflictOnEdit = false;
+
+    if (pageEditor != null) {
+      markdownOnEdit = pageEditor.getMarkdown();
+      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
+    }
+
+    return [
+      ['bg-warning', 'd-hackmd-none'],
+      <>
+        <i className="icon-fw icon-pencil"></i>
+        {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+      </>,
+      <>
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
+          <i className="icon-fw icon-reload mr-1"></i>
+          {t('Load latest')}
+        </button>
+        {isConflictOnEdit
+          && (
+            <button
+              type="button"
+              onClick={this.onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <i className="fa fa-fw fa-file-text-o mr-1"></i>
+              {t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )
+        }
+      </>,
+    ];
+  }
+
   getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     return [
@@ -92,8 +139,12 @@ class PageStatusAlert extends React.Component {
 
     let getContentsFunc = null;
 
+    // when conflicting on save
+    if (isRevisionOutdated) {
+      getContentsFunc = this.getContentsForRevisionOutdated;
+    }
     // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+    else if (isHackmdDocumentOutdated && isRevisionOutdated) {
       getContentsFunc = this.getContentsForUpdatedAlert;
     }
     // when someone editing with HackMD

+ 8 - 0
packages/app/src/components/SavePageControls.jsx

@@ -66,6 +66,14 @@ class SavePageControls extends React.Component {
     catch (error) {
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
+      if (error.code === 'conflict') {
+        pageContainer.setState({
+          remoteRevisionId: error.data.revisionId,
+          remoteRevisionBody: error.data.revisionBody,
+          remoteRevisionUpdateAt: error.data.createdAt,
+          lastUpdateUser: error.data.user,
+        });
+      }
     }
   }
 

+ 10 - 7
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -54,13 +54,16 @@ function LargePageItem({ page }) {
   }
 
   const tags = page.tags;
-  const tagElements = tags.map((tag) => {
-    return (
-      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-        {tag.name}
-      </a>
-    );
-  });
+  // when tag document is deleted from database directly tags includes null
+  const tagElements = tags.includes(null)
+    ? <></>
+    : tags.map((tag) => {
+      return (
+        <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+          {tag.name}
+        </a>
+      );
+    });
 
   return (
     <li className="list-group-item py-3 px-0">

+ 3 - 0
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -5,6 +5,7 @@ import { useCurrentSidebarContents } from '~/stores/ui';
 import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
 import PageTree from './PageTree';
+import Tag from './Tag';
 
 type Props = {
 };
@@ -19,6 +20,8 @@ const SidebarContents: FC<Props> = (props: Props) => {
       break;
     case SidebarContentsType.TREE:
       Contents = PageTree;
+    case SidebarContentsType.TAG:
+      Contents = Tag;
       break;
     default:
       Contents = CustomSidebar;

+ 2 - 0
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -82,6 +82,8 @@ const SidebarNav: FC<Props> = (props: Props) => {
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />}
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} /> }
+        {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
       </div>
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}

+ 44 - 0
packages/app/src/components/Sidebar/Tag.tsx

@@ -0,0 +1,44 @@
+import React, { FC, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import TagsList from '../TagsList';
+
+const Tag: FC = () => {
+  const { t } = useTranslation('');
+  const [isOnReload, setIsOnReload] = useState<boolean>(false);
+
+  useEffect(() => {
+    setIsOnReload(false);
+  }, [isOnReload]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">{t('Tags')}</h3>
+        <button
+          type="button"
+          className="btn btn-sm ml-auto grw-btn-reload-rc"
+          onClick={() => {
+            setIsOnReload(true);
+          }}
+        >
+          <i className="icon icon-reload"></i>
+        </button>
+      </div>
+      <div className="d-flex justify-content-center">
+        <button
+          className="btn btn-primary my-4"
+          type="button"
+          onClick={() => { window.location.href = '/tags' }}
+        >
+          {t('Check All tags')}
+        </button>
+      </div>
+      <div className="grw-container-convertible mb-5 pb-5">
+        <TagsList isOnReload={isOnReload} />
+      </div>
+    </>
+  );
+
+};
+
+export default Tag;

+ 38 - 0
packages/app/src/components/TagCloudBox.tsx

@@ -0,0 +1,38 @@
+import React, { FC } from 'react';
+
+import { TagCloud } from 'react-tagcloud';
+
+type Tag = {
+  _id: string,
+  name: string,
+  count: number,
+}
+
+type Props = {
+  tags:Tag[],
+  minSize?: number,
+  maxSize?: number,
+}
+
+const MIN_FONT_SIZE = 12;
+const MAX_FONT_SIZE = 36;
+
+const TagCloudBox: FC<Props> = (props:Props) => {
+  return (
+    <>
+      <TagCloud
+        minSize={props.minSize || MIN_FONT_SIZE}
+        maxSize={props.maxSize || MAX_FONT_SIZE}
+        tags={props.tags.map((tag) => {
+          return { value: tag.name, count: tag.count };
+        })}
+        style={{ cursor: 'pointer' }}
+        className="simple-cloud"
+        onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
+      />
+    </>
+  );
+
+};
+
+export default TagCloudBox;

+ 44 - 18
packages/app/src/components/TagsList.jsx

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import PaginationWrapper from './PaginationWrapper';
+import TagCloudBox from './TagCloudBox';
+import { apiGet } from '../client/util/apiv1-client';
+import { toastError } from '../client/util/apiNotification';
 
 class TagsList extends React.Component {
 
@@ -25,6 +28,12 @@ class TagsList extends React.Component {
     await this.getTagList(1);
   }
 
+  async componentDidUpdate() {
+    if (this.props.isOnReload) {
+      await this.getTagList(this.state.activePage);
+    }
+  }
+
   async handlePage(selectedPage) {
     await this.getTagList(selectedPage);
   }
@@ -32,7 +41,14 @@ class TagsList extends React.Component {
   async getTagList(selectPageNumber) {
     const limit = this.state.pagingLimit;
     const offset = (selectPageNumber - 1) * limit;
-    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+    let res;
+
+    try {
+      res = await apiGet('/tags.list', { limit, offset });
+    }
+    catch (error) {
+      toastError(error);
+    }
 
     const totalTags = res.totalCount;
     const tagData = res.data;
@@ -67,34 +83,44 @@ class TagsList extends React.Component {
     const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
 
     return (
-      <div className="text-center">
-        <div className="tag-list">
-          <ul className="list-group text-left">
-            {this.generateTagList(this.state.tagData)}
-          </ul>
-          {messageForNoTag}
-        </div>
-        <div className="tag-list-pagination">
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePage}
-            totalItemsCount={this.state.totalTags}
-            pagingLimit={this.state.pagingLimit}
-            size="sm"
-          />
+      <>
+        <header className="py-0">
+          <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${this.state.totalTags})`}</h1>
+        </header>
+        <div className="row text-center">
+          <div className="col-12 mb-5 px-5">
+            <TagCloudBox tags={this.state.tagData} minSize={20} />
+          </div>
+          <div className="col-12 tag-list mb-4">
+            <ul className="list-group text-left">
+              {this.generateTagList(this.state.tagData)}
+            </ul>
+            {messageForNoTag}
+          </div>
+          <div className="col-12 tag-list-pagination">
+            <PaginationWrapper
+              activePage={this.state.activePage}
+              changePage={this.handlePage}
+              totalItemsCount={this.state.totalTags}
+              pagingLimit={this.state.pagingLimit}
+              align="center"
+              size="md"
+            />
+          </div>
         </div>
-      </div>
+      </>
     );
   }
 
 }
 
 TagsList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  isOnReload: PropTypes.bool,
   t: PropTypes.func.isRequired, // i18next
 };
 
 TagsList.defaultProps = {
+  isOnReload: false,
 };
 
 export default withTranslation()(TagsList);

+ 58 - 0
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -0,0 +1,58 @@
+import React, { forwardRef, ReactNode, Ref } from 'react';
+import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
+import { Container, Subscribe } from 'unstated';
+import EditorContainer from '~/client/services/EditorContainer';
+import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
+
+window.CodeMirror = require('codemirror');
+require('codemirror/addon/display/placeholder');
+require('~/client/util/codemirror/gfm-growi.mode');
+
+export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
+  value: string;
+  options?: ICodeMirror['options'];
+  isGfmMode?: boolean;
+  indentSize?: number;
+  lineNumbers?: boolean;
+}
+
+interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
+  editorContainer: Container<EditorContainer>;
+  forwardedRef: Ref<UncontrolledCodeMirrorCore>;
+}
+
+class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+
+  render(): ReactNode {
+
+    const {
+      value, isGfmMode, indentSize, lineNumbers, editorContainer, options, forwardedRef, ...rest
+    } = this.props;
+
+    const { editorOptions } = editorContainer.state;
+
+    return (
+      <CodeMirror
+        ref={forwardedRef}
+        value={value}
+        options={{
+          lineNumbers: lineNumbers ?? true,
+          mode: isGfmMode ? 'gfm-growi' : undefined,
+          theme: editorOptions.theme,
+          styleActiveLine: editorOptions.styleActiveLine,
+          tabSize: 4,
+          indentUnit: indentSize,
+          ...options,
+        }}
+        {...rest}
+      />
+    );
+  }
+
+}
+
+export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => (
+  <Subscribe to={[EditorContainer]}>
+    {(EditorContainer: Container<EditorContainer>) => <UncontrolledCodeMirrorCore {...props} forwardedRef={ref} editorContainer={EditorContainer} />}
+  </Subscribe>
+));

+ 7 - 0
packages/app/src/interfaces/bookmarks.ts

@@ -0,0 +1,7 @@
+import { IUser } from '~/interfaces/user';
+
+export interface IBookmarksInfo {
+  isBookmarked: boolean
+  sumOfBookmarks: number
+  bookmarkedUsers: IUser[]
+}

+ 7 - 0
packages/app/src/interfaces/revision.ts

@@ -7,3 +7,10 @@ export type IRevision = {
   createdAt: Date,
   updatedAt: Date,
 }
+
+export type IRevisionOnConflict = {
+  revisionId: string,
+  revisionBody: string,
+  createdAt: Date,
+  user: IUser
+}

+ 1 - 0
packages/app/src/interfaces/ui.ts

@@ -2,6 +2,7 @@ export const SidebarContentsType = {
   CUSTOM: 'custom',
   RECENT: 'recent',
   TREE: 'tree',
+  TAG: 'tag',
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 69 - 0
packages/app/src/migrations/20210921173042-add-is-trashed-field.js

@@ -0,0 +1,69 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:add-column-is-trashed');
+const Page = require('~/server/models/page')();
+
+const LIMIT = 1000;
+
+/**
+ * set isPageTrashed of pagetagrelations included in updateIdList as true
+ */
+const updateIsPageTrashed = async(db, updateIdList) => {
+  await db.collection('pagetagrelations').updateMany(
+    { relatedPage: { $in: updateIdList } },
+    { $set: { isPageTrashed: true } },
+  );
+};
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    let updateDeletedPageIds = [];
+
+    // set isPageTrashed as false temporarily
+    await db.collection('pagetagrelations').updateMany(
+      {},
+      { $set: { isPageTrashed: false } },
+    );
+
+    for await (const deletedPage of Page.find({ status: Page.STATUS_DELETED }).select('_id').cursor()) {
+      updateDeletedPageIds.push(deletedPage._id);
+      // excute updateMany by one thousand ids
+      if (updateDeletedPageIds.length === LIMIT) {
+        await updateIsPageTrashed(db, updateDeletedPageIds);
+        updateDeletedPageIds = [];
+      }
+    }
+
+    // use ids that have not been updated
+    if (updateDeletedPageIds.length > 0) {
+      await updateIsPageTrashed(db, updateDeletedPageIds);
+    }
+
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await db.collection('pagetagrelations').updateMany(
+        {},
+        { $unset: { isPageTrashed: '' } },
+      );
+      logger.info('Migration has been successfully rollbacked');
+    }
+    catch (err) {
+      logger.error(err);
+      logger.info('Migration has failed');
+    }
+
+  },
+};

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
   const express = require('express');
+  const compression = require('compression');
   const helmet = require('helmet');
   const bodyParser = require('body-parser');
   const cookieParser = require('cookie-parser');
@@ -53,6 +54,8 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
     });
 
+  app.use(compression());
+
   app.use(helmet({
     contentSecurityPolicy: false,
     expectCt: false,

+ 45 - 25
packages/app/src/server/models/page-tag-relation.js

@@ -24,6 +24,13 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     ref: 'Tag',
     required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
   },
 });
 // define unique compound index
@@ -39,27 +46,34 @@ schema.plugin(uniqueValidator);
 class PageTagRelation {
 
   static async createTagListWithCount(option) {
-    const Tag = mongoose.model('Tag');
     const opt = option || {};
     const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset || 0;
-    const limit = opt.limit || 50;
+    const offset = opt.offset;
+    const limit = opt.limit;
 
-    const existTagIds = await Tag.find().distinct('_id');
     const tags = await this.aggregate()
-      .match({ relatedTag: { $in: existTagIds } })
-      .group({ _id: '$relatedTag', count: { $sum: 1 } })
-      .sort(sortOpt);
-
-    const list = tags.slice(offset, offset + limit);
-    const totalCount = tags.length;
-
-    return { list, totalCount };
+      .match({ isPageTrashed: false })
+      .lookup({
+        from: 'tags',
+        localField: 'relatedTag',
+        foreignField: '_id',
+        as: 'tag',
+      })
+      .unwind('$tag')
+      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+    return { data: tags, totalCount };
   }
 
-  static async findByPageId(pageId) {
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
-    return relations.filter((relation) => { return relation.relatedTag !== null });
+  static async findByPageId(pageId, options = {}) {
+    const isAcceptRelatedTagNull = options.nullable || null;
+    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
   }
 
   static async listTagNamesByPage(pageId) {
@@ -125,17 +139,23 @@ class PageTagRelation {
     const Tag = mongoose.model('Tag');
 
     // get relations for this page
-    const relations = await this.findByPageId(pageId);
-
-    // unlink relations
-    const unlinkTagRelations = relations.filter((relation) => { return !tags.includes(relation.relatedTag.name) });
-    const bulkDeletePromise = this.deleteMany({
-      relatedPage: pageId,
-      relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
+    const relations = await this.findByPageId(pageId, { nullable: true });
+
+    const unlinkTagRelationIds = [];
+    const relatedTagNames = [];
+
+    relations.forEach((relation) => {
+      if (relation.relatedTag == null) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+      else {
+        relatedTagNames.push(relation.relatedTag.name);
+        if (!tags.includes(relation.relatedTag.name)) {
+          unlinkTagRelationIds.push(relation._id);
+        }
+      }
     });
-
-    // filter tags to create
-    const relatedTagNames = relations.map((relation) => { return relation.relatedTag.name });
+    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
     // find or create tags
     const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
     const tagEntities = await Tag.findOrCreateMany(tagsToCreate);

+ 5 - 1
packages/app/src/server/models/vo/s2c-message.js

@@ -10,15 +10,19 @@ class S2cMessagePageUpdated {
     const serializedPage = serializePageSecurely(page);
 
     const {
-      _id, revision, revisionHackmdSynced, hasDraftOnHackmd,
+      _id, revision, updatedAt, revisionHackmdSynced, hasDraftOnHackmd,
     } = serializedPage;
 
     this.pageId = _id;
     this.revisionId = revision;
+    this.revisionBody = page.revision.body;
+    this.revisionUpdateAt = updatedAt;
     this.revisionIdHackmdSynced = revisionHackmdSynced;
     this.hasDraftOnHackmd = hasDraftOnHackmd;
 
     if (user != null) {
+      this.remoteLastUpdateUser = user;
+      // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       this.lastUpdateUsername = user.name;
     }
   }

+ 8 - 2
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -113,10 +113,16 @@ module.exports = (crowi) => {
     const responsesParams = {};
 
     try {
-      responsesParams.sumOfBookmarks = await Bookmark.countByPageId(pageId);
+      const bookmarks = await Bookmark.find({ page: pageId }).populate('user');
+      let users = [];
+      if (bookmarks.length > 0) {
+        users = bookmarks.map(bookmark => serializeUserSecurely(bookmark.user));
+      }
+      responsesParams.sumOfBookmarks = bookmarks.length;
+      responsesParams.bookmarkedUsers = users;
     }
     catch (err) {
-      logger.error('get-bookmark-count-failed', err);
+      logger.error('get-bookmark-document-failed', err);
       return res.apiv3Err(err, 500);
     }
 

+ 2 - 0
packages/app/src/server/routes/apiv3/pages.js

@@ -200,7 +200,9 @@ module.exports = (crowi) => {
 
   async function saveTagsAction({ createdPage, pageTags }) {
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
       return PageTagRelation.listTagNamesByPage(createdPage.id);
     }
 

+ 11 - 2
packages/app/src/server/routes/page.js

@@ -905,9 +905,17 @@ module.exports = function(crowi, app) {
     }
 
     // check revision
+    const Revision = crowi.model('Revision');
     let page = await Page.findByIdAndViewer(pageId, req.user);
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
+      const latestRevision = await Revision.findById(page.revision).populate('author');
+      const returnLatestRevision = {
+        revisionId: latestRevision._id.toString(),
+        revisionBody: xss.process(latestRevision.body),
+        createdAt: latestRevision.createdAt,
+        user: serializeUserSecurely(latestRevision.author),
+      };
+      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
 
     const options = { isSyncRevisionToHackmd };
@@ -916,7 +924,6 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const Revision = crowi.model('Revision');
     const previousRevision = await Revision.findById(revisionId);
     try {
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
@@ -928,8 +935,10 @@ module.exports = function(crowi, app) {
 
     let savedTags;
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(pageId, pageTags);
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
+      tagEvent.emit('update', page, savedTags);
     }
 
     const result = {

+ 16 - 22
packages/app/src/server/routes/tag.js

@@ -136,15 +136,27 @@ module.exports = function(crowi, app) {
    */
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
+    const User = crowi.model('User');
     const PageTagRelation = crowi.model('PageTagRelation');
+    const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const tags = req.body.tags;
+    const userId = req.user._id;
+    const revisionId = req.body.revisionId;
 
     const result = {};
     try {
       // TODO GC-1921 consider permission
       const page = await Page.findById(pageId);
+      const user = await User.findById(userId);
+
+      if (!await Page.isAccessiblePageByViewer(page._id, user)) {
+        return res.json(ApiResponse.error("You don't have permission to update this page."));
+      }
+
+      const previousRevision = await Revision.findById(revisionId);
+      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
@@ -203,32 +215,14 @@ module.exports = function(crowi, app) {
   api.list = async function(req, res) {
     const limit = +req.query.limit || 50;
     const offset = +req.query.offset || 0;
-    const sortOpt = { count: -1 };
+    const sortOpt = { count: -1, _id: -1 };
     const queryOptions = { offset, limit, sortOpt };
-    const result = {};
 
     try {
-      // get tag list contains id and count properties
-      const listData = await PageTagRelation.createTagListWithCount(queryOptions);
-      const ids = listData.list.map((obj) => { return obj._id });
-
-      // get tag documents for add name data to the list
-      const tags = await Tag.find({ _id: { $in: ids } });
-
-      // add name property
-      result.data = listData.list.map((elm) => {
-        const data = {};
-        const tag = tags.find((tag) => { return (tag.id === elm._id.toString()) });
-
-        data._id = elm._id;
-        data.name = tag.name;
-        data.count = elm.count; // the number of related pages
-        return data;
-      });
-
-      result.totalCount = listData.totalCount;
+      // get tag list contains id name and count properties
+      const tagsWithCount = await PageTagRelation.createTagListWithCount(queryOptions);
 
-      return res.json(ApiResponse.success(result));
+      return res.json(ApiResponse.success(tagsWithCount));
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -403,6 +403,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     default: 3,
   },
+  OIDC_CLIENT_CLOCK_TOLERANCE: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcClientClockTolerance',
+    type: ValueType.NUMBER,
+    default: 10,
+  },
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',
     key:     'aws:referenceFileWithRelayMode',

+ 6 - 0
packages/app/src/server/service/page.js

@@ -27,6 +27,7 @@ class PageService {
   constructor(crowi) {
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
+    this.tagEvent = crowi.event('tag');
 
     // init
     this.initPageEvent();
@@ -384,6 +385,7 @@ class PageService {
     if (originTags != null) {
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+      this.tagEvent.emit('update', createdPage, savedTags);
     }
 
     const result = serializePageSecurely(createdPage);
@@ -518,6 +520,7 @@ class PageService {
 
   async deletePage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getDeletedPageName(page.path);
@@ -542,6 +545,7 @@ class PageService {
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
     const body = `redirect ${newPath}`;
     await Page.create(page.path, body, user, { redirectTo: newPath });
 
@@ -760,6 +764,7 @@ class PageService {
 
   async revertDeletedPage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getRevertDeletedPageName(page.path);
@@ -788,6 +793,7 @@ class PageService {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
     await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
 
     return updatedPage;

+ 2 - 1
packages/app/src/server/service/passport.ts

@@ -677,7 +677,8 @@ class PassportService implements S2sMessageHandlable {
       });
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
-      client.CLOCK_TOLERANCE = 5;
+      const OIDC_CLIENT_CLOCK_TOLERANCE = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcClientClockTolerance');
+      client.CLOCK_TOLERANCE = OIDC_CLIENT_CLOCK_TOLERANCE;
       passport.use('oidc', new OidcStrategy(
         {
           client,

+ 2 - 3
packages/app/src/server/service/search.ts

@@ -1,4 +1,3 @@
-import RE2 from 're2';
 import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
@@ -189,8 +188,8 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
 
   async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
-    const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
-    const replaceRegexp = new RE2(/\[nq:|\]/g);
+    const regexp = new RegExp(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
+    const replaceRegexp = new RegExp(/\[nq:|\]/g);
 
     const queryString = normalizeQueryString(_queryString);
 

+ 2 - 1
packages/app/src/server/util/apiResponse.js

@@ -1,11 +1,12 @@
 function ApiResponse() {
 }
 
-ApiResponse.error = function(err, code) {
+ApiResponse.error = function(err, code, data) {
   const result = {};
 
   result.ok = false;
   result.code = code;
+  result.data = data;
 
   if (err instanceof Error) {
     result.error = err.toString();

+ 2 - 5
packages/app/src/server/views/tags.html

@@ -5,11 +5,8 @@
 {% block html_base_css %}tags-page{% endblock %}
 
 {% block layout_main %}
-<header class="py-0">
-  <h1 class="title">{{ t('Tags') }}</h1>
-</header>
-
-<div class="container-fluid">
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+<div class="grw-container-convertible">
   <div class="row">
     <div id="main" class="main mt-3 col-md-12 tags-page">
       <div class="" id="tags-page"></div>

+ 21 - 0
packages/app/src/stores/bookmarks.tsx

@@ -0,0 +1,21 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { Types } from 'mongoose';
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IBookmarksInfo } from '~/interfaces/bookmarks';
+
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxBookmarksInfo = <Data, Error>(pageId: Types.ObjectId):SWRResponse<IBookmarksInfo, Error> => {
+  return useSWR(
+    ['/bookmarks/info', pageId],
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then((response) => {
+      return {
+        isBookmarked: response.data.isBookmarked,
+        sumOfBookmarks: response.data.sumOfBookmarks,
+        bookmarkedUsers: response.data.bookmarkedUsers,
+      };
+    }),
+  );
+};

+ 2 - 2
packages/app/src/stores/context.tsx

@@ -24,8 +24,8 @@ export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<
 };
 
 
-export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('currentPageId', initialData ?? null);
+export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData ?? null);
 };
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {

+ 2 - 1
packages/app/src/stores/page.tsx

@@ -68,6 +68,7 @@ export const useSWRTagsInfo = (pageId: string): SWRResponse<IPageTagsInfo, Error
     };
   }));
 };
+type GetSubscriptionStatusResult = { subscribing: boolean };
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRResponse<{status: boolean | null}, Error> => {
@@ -76,7 +77,7 @@ export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRRespo
   const key = isGuestUser === false ? ['/page/subscribe', pageId] : null;
   return useSWR(
     key,
-    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then((response) => {
+    (endpoint, pageId) => apiv3Get<GetSubscriptionStatusResult>(endpoint, { pageId }).then((response) => {
       return {
         status: response.data.subscribing,
       };

+ 33 - 22
packages/app/src/stores/ui.tsx

@@ -51,15 +51,13 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   return useStaticSWR(key, null, configuration);
 };
 
-
-const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
+const updateBodyClassesByEditorMode = (newEditorMode: EditorMode) => {
   switch (newEditorMode) {
     case EditorMode.View:
       $('body').removeClass('on-edit');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('hackmd');
       $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
       break;
     case EditorMode.Editor:
       $('body').addClass('on-edit');
@@ -69,38 +67,50 @@ const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
       if (window.location.pathname === '/Sidebar') {
         $('body').addClass('pathname-sidebar');
       }
-      window.location.hash = '#edit';
       break;
     case EditorMode.HackMD:
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
       break;
   }
 };
 
-export const useEditorModeByHash = (): SWRResponse<EditorMode, Error> => {
-  return useSWRImmutable(
-    ['initialEditorMode', window.location.hash],
-    (key: Key, hash: string) => {
-      switch (hash) {
-        case '#edit':
-          return EditorMode.Editor;
-        case '#hackmd':
-          return EditorMode.HackMD;
-        default:
-          return EditorMode.View;
-      }
-    },
-  );
+const updateHashByEditorMode = (newEditorMode: EditorMode) => {
+  const { pathname } = window.location;
+
+  switch (newEditorMode) {
+    case EditorMode.View:
+      window.history.replaceState(null, '', pathname);
+      break;
+    case EditorMode.Editor:
+      window.history.replaceState(null, '', `${pathname}#edit`);
+      break;
+    case EditorMode.HackMD:
+      window.history.replaceState(null, '', `${pathname}#hackmd`);
+      break;
+  }
+};
+
+export const determineEditorModeByHash = (): EditorMode => {
+  const { hash } = window.location;
+
+  switch (hash) {
+    case '#edit':
+      return EditorMode.Editor;
+    case '#hackmd':
+      return EditorMode.HackMD;
+    default:
+      return EditorMode.View;
+  }
 };
 
 let isEditorModeLoaded = false;
 export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   const { data: _isEditable } = useIsEditable();
-  const { data: editorModeByHash } = useEditorModeByHash();
+
+  const editorModeByHash = determineEditorModeByHash();
 
   const isLoading = _isEditable === undefined;
   const isEditable = !isLoading && _isEditable;
@@ -115,7 +125,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   // initial updating
   if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
     if (isEditable) {
-      updateBodyClassesForEditorMode(swrResponse.data);
+      updateBodyClassesByEditorMode(swrResponse.data);
     }
     isEditorModeLoaded = true;
   }
@@ -128,7 +138,8 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
       if (!isEditable) {
         return Promise.resolve(EditorMode.View); // fixed if not editable
       }
-      updateBodyClassesForEditorMode(editorMode);
+      updateBodyClassesByEditorMode(editorMode);
+      updateHashByEditorMode(editorMode);
       return swrResponse.mutate(editorMode, shouldRevalidate);
     },
   };

+ 1 - 1
packages/app/src/styles/_sidebar.scss

@@ -109,7 +109,7 @@
         width: 240px;
         height: 100%;
         &:not(.dragging) {
-          transition: width 300ms cubic-bezier(0.2, 0, 0, 1) 0s;
+          transition: width 200ms cubic-bezier(0.2, 0, 0, 1) 0s;
         }
         will-change: width;
         .grw-contextual-navigation-child {

+ 0 - 1
packages/core/package.json

@@ -16,7 +16,6 @@
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "lint:js": "eslint **/*.{js,ts}",
-    "lint:styles": "stylelint src/styles/scss/**/*.scss",
     "lint": "npm-run-all -p lint:*",
     "test": "jest --verbose"
   },

+ 1 - 2
packages/plugin-attachment-refs/package.json

@@ -17,7 +17,7 @@
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/**/*.scss",
+    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
     "lint": "run-p lint:*",
     "test": ""
   },
@@ -31,7 +31,6 @@
   },
   "devDependencies": {
     "npm-run-all": "^4.1.5",
-    "prettier-stylelint": "^0.4.2",
     "react": "^16.8.3",
     "react-dom": "^16.8.3"
   }

+ 1 - 1
packages/plugin-lsx/package.json

@@ -17,7 +17,7 @@
     "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/**/*.scss",
+    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
     "lint": "run-p lint:*",
     "test": ""
   },

+ 0 - 2
packages/plugin-pukiwiki-like-linker/package.json

@@ -22,8 +22,6 @@
   },
   "devDependencies": {
     "browser-bunyan": "^1.6.3",
-    "stylelint": "^14.0.1",
-    "stylelint-config-recess-order": "^2.0.1",
     "tsc-alias": "^1.2.9"
   }
 }

+ 49 - 36
packages/slackbot-proxy/docker/Dockerfile

@@ -1,46 +1,60 @@
-# syntax = docker/dockerfile:1.2
+# syntax = docker/dockerfile:1
 
 ##
-## deps-resolver-base
+## packages-json-picker
 ##
-FROM node:14-slim AS deps-resolver-base
+FROM node:14-slim AS packages-json-picker
 
-ENV appDir /opt
-
-WORKDIR ${appDir}
-COPY ./package.json ./
-COPY ./yarn.lock ./
-COPY ./lerna.json ./
-COPY ./packages/slack/package.json ./packages/slack/package.json
-COPY ./packages/slackbot-proxy/package.json ./packages/slackbot-proxy/package.json
-
-# setup
-RUN yarn config set network-timeout 300000
+ENV optDir /opt
 
+WORKDIR ${optDir}
+COPY ["package.json", "yarn.lock", "lerna.json", "./"]
+COPY packages packages
+# Find and remove non-package.json files
+RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
 
 
 ##
 ## deps-resolver-dev
 ##
-FROM deps-resolver-base AS deps-resolver-dev
-RUN npx lerna bootstrap
+FROM node:14-slim AS deps-resolver-dev
+
+ENV optDir /opt
+
+WORKDIR ${optDir}
+
+# copy files
+COPY --from=packages-json-picker ${optDir} .
+
+# setup
+RUN yarn config set network-timeout 300000
+RUN npx lerna bootstrap -- --frozen-lockfile
 
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/slackbot-proxy/node_modules \
-  packages/slack/node_modules
+RUN tar -cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
+
 
 
 ##
 ## deps-resolver-prod
 ##
-FROM deps-resolver-base AS deps-resolver-prod
+FROM node:14-slim AS deps-resolver-prod
+
+ENV optDir /opt
+
+WORKDIR ${optDir}
+COPY ["package.json", "yarn.lock", "lerna.json", "./"]
+COPY ./packages/slack/package.json ./packages/slack/package.json
+COPY ./packages/slackbot-proxy/package.json ./packages/slackbot-proxy/package.json
+
 RUN npx lerna bootstrap -- --production
 # make artifacts
-RUN tar cf dependencies.tar \
+RUN tar -cf dependencies.tar \
   node_modules \
-  packages/slack/node_modules \
-  packages/slackbot-proxy/node_modules
+  packages/*/node_modules
+
 
 
 ##
@@ -48,21 +62,19 @@ RUN tar cf dependencies.tar \
 ##
 FROM node:14-slim AS builder
 
-ENV appDir /opt
+ENV optDir /opt
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
 # copy dependent packages
 COPY --from=deps-resolver-dev \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${optDir}/
 
 # extract node_modules.tar
-RUN tar xf node_modules.tar
+RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
-COPY ./package.json ./
-COPY ./lerna.json ./
-COPY ./tsconfig.base.json ./
+COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
 COPY packages/slack packages/slack
 COPY packages/slackbot-proxy packages/slackbot-proxy
@@ -71,7 +83,7 @@ COPY packages/slackbot-proxy packages/slackbot-proxy
 RUN yarn lerna run build
 
 # make artifacts
-RUN tar cf packages.tar \
+RUN tar -cf packages.tar \
   packages/slack/package.json \
   packages/slack/dist \
   packages/slackbot-proxy/package.json \
@@ -88,13 +100,14 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production
 
-ENV appDir /opt
+ENV optDir /opt
+ENV appDir ${optDir}
 
 # copy artifacts
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${appDir}/dependencies.tar ${appDir}/
+  ${optDir}/dependencies.tar ${appDir}/
 COPY --from=builder --chown=node:node \
-  ${appDir}/packages.tar ${appDir}/
+  ${optDir}/packages.tar ${appDir}/
 
 RUN chown node:node ${appDir}
 
@@ -102,8 +115,8 @@ USER node
 
 # extract artifacts
 WORKDIR ${appDir}
-RUN tar xf dependencies.tar
-RUN tar xf packages.tar
+RUN tar -xf dependencies.tar
+RUN tar -xf packages.tar
 RUN rm dependencies.tar packages.tar
 
 WORKDIR ${appDir}/packages/slackbot-proxy

+ 5 - 6
packages/slackbot-proxy/docker/Dockerfile.dockerignore

@@ -1,6 +1,5 @@
-node_modules
-*/node_modules
-*/coverage
-*/dist
-*/Dockerfile
-*/*.dockerignore
+**/node_modules
+**/dist
+**/coverage
+**/Dockerfile
+**/*.dockerignore

+ 0 - 1
packages/ui/package.json

@@ -12,7 +12,6 @@
   ],
   "scripts": {
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/styles/scss/**/*.scss",
     "lint": "npm-run-all -p lint:*",
     "test": "jest --verbose"
   },

Разница между файлами не показана из-за своего большого размера
+ 118 - 400
yarn.lock


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