Bladeren bron

Merge branch 'feat/add-stuff-credit' into feat/add-keydown-event-listener

Kazuya_Nagase 7 jaren geleden
bovenliggende
commit
7788175c68
100 gewijzigde bestanden met toevoegingen van 1841 en 601 verwijderingen
  1. 1 0
      .vscode/extensions.json
  2. 1 0
      .vscode/launch.json
  3. 24 1
      CHANGES.md
  4. 2 1
      README.md
  5. 1 0
      config/env.dev.js
  6. 10 0
      config/swagger-definition.js
  7. 1 2
      config/webpack.dev.dll.js
  8. 17 16
      package.json
  9. 1 0
      public/images/icons/editor/bold.svg
  10. 1 0
      public/images/icons/editor/check.svg
  11. 1 0
      public/images/icons/editor/code.svg
  12. 1 0
      public/images/icons/editor/header.svg
  13. 1 0
      public/images/icons/editor/italic.svg
  14. 1 0
      public/images/icons/editor/link.svg
  15. 1 0
      public/images/icons/editor/list-ol.svg
  16. 1 0
      public/images/icons/editor/list-ul.svg
  17. 1 0
      public/images/icons/editor/picture.svg
  18. 1 0
      public/images/icons/editor/quote.svg
  19. 1 0
      public/images/icons/editor/strikethrough.svg
  20. 1 1
      public/images/icons/editor/table.svg
  21. 7 0
      resource/cdn-manifests.js
  22. 26 8
      resource/locales/en-US/translation.json
  23. 43 10
      resource/locales/ja/translation.json
  24. 1 1
      resource/search/mappings.json
  25. 40 6
      src/client/js/app.js
  26. 0 67
      src/client/js/components/CopyButton.js
  27. 252 0
      src/client/js/components/MyDraftList/MyDraftList.jsx
  28. 118 0
      src/client/js/components/Page/CopyDropdown.jsx
  29. 17 13
      src/client/js/components/Page/RevisionPath.jsx
  30. 0 46
      src/client/js/components/Page/RevisionUrl.js
  31. 108 0
      src/client/js/components/Page/TagEditor.jsx
  32. 94 0
      src/client/js/components/Page/TagLabels.jsx
  33. 0 107
      src/client/js/components/Page/TagViewer.jsx
  34. 97 0
      src/client/js/components/Page/TagsInput.jsx
  35. 4 2
      src/client/js/components/PageAttachment/Attachment.js
  36. 3 2
      src/client/js/components/PageAttachment/DeleteAttachmentModal.js
  37. 3 5
      src/client/js/components/PageComment/Comment.jsx
  38. 1 4
      src/client/js/components/PageComment/CommentForm.jsx
  39. 0 0
      src/client/js/components/PageComment/CommentPreview.jsx
  40. 2 1
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  41. 23 3
      src/client/js/components/PageEditor.js
  42. 13 24
      src/client/js/components/PageEditor/CodeMirrorEditor.js
  43. 0 0
      src/client/js/components/PageHistory.jsx
  44. 3 2
      src/client/js/components/PageHistory/Revision.jsx
  45. 0 0
      src/client/js/components/PageHistory/RevisionDiff.jsx
  46. 165 0
      src/client/js/components/PageList/Draft.jsx
  47. 0 70
      src/client/js/components/PageTagForm.jsx
  48. 1 0
      src/client/js/components/SavePageControls.jsx
  49. 8 0
      src/client/js/components/SearchForm.js
  50. 4 1
      src/client/js/components/SearchPage/SearchResultList.js
  51. 5 1
      src/client/js/components/SearchTypeahead.js
  52. 21 0
      src/client/js/components/StaffCredit/StaffCredit.jsx
  53. 197 0
      src/client/js/components/TagsList.jsx
  54. 0 40
      src/client/js/components/User/User.js
  55. 0 0
      src/client/js/components/User/UserDate.jsx
  56. 9 1
      src/client/js/components/User/UserPicture.jsx
  57. 7 9
      src/client/js/components/User/UserPictureList.jsx
  58. 22 0
      src/client/js/components/User/Username.jsx
  59. 13 0
      src/client/js/util/Crowi.js
  60. 13 12
      src/client/styles/agile-admin/inverse/colors/future.scss
  61. 5 0
      src/client/styles/scss/_attachments.scss
  62. 0 2
      src/client/styles/scss/_editor-attachment.scss
  63. 4 6
      src/client/styles/scss/_layout_kibela.scss
  64. 12 22
      src/client/styles/scss/_on-edit.scss
  65. 16 12
      src/client/styles/scss/_page.scss
  66. 7 5
      src/client/styles/scss/_search.scss
  67. 32 0
      src/client/styles/scss/_tag.scss
  68. 30 11
      src/client/styles/scss/_user.scss
  69. 4 0
      src/client/styles/scss/style-app.scss
  70. 0 1
      src/server/crowi/dev.js
  71. 10 7
      src/server/crowi/express-init.js
  72. 3 2
      src/server/crowi/index.js
  73. 13 0
      src/server/events/tag.js
  74. 16 0
      src/server/models/page-tag-relation.js
  75. 12 3
      src/server/models/page.js
  76. 2 1
      src/server/models/tag.js
  77. 12 6
      src/server/routes/admin.js
  78. 43 0
      src/server/routes/apiv3/docs.js
  79. 35 0
      src/server/routes/apiv3/healthcheck.js
  80. 6 1
      src/server/routes/index.js
  81. 4 2
      src/server/routes/login.js
  82. 32 4
      src/server/routes/page.js
  83. 9 1
      src/server/routes/search.js
  84. 74 0
      src/server/routes/tag.js
  85. 6 0
      src/server/service/config-loader.js
  86. 11 12
      src/server/service/file-uploader/gridfs.js
  87. 13 2
      src/server/util/search.js
  88. 1 1
      src/server/views/admin/app.html
  89. 2 2
      src/server/views/admin/customize.html
  90. 16 16
      src/server/views/admin/importer.html
  91. 2 2
      src/server/views/admin/security.html
  92. 0 4
      src/server/views/layout-crowi/base/layout.html
  93. 3 1
      src/server/views/layout-crowi/forbidden.html
  94. 3 1
      src/server/views/layout-crowi/not_creatable.html
  95. 3 1
      src/server/views/layout-crowi/not_found.html
  96. 3 1
      src/server/views/layout-crowi/page.html
  97. 4 4
      src/server/views/layout-crowi/page_list.html
  98. 0 4
      src/server/views/layout-growi/base/layout.html
  99. 3 3
      src/server/views/layout-growi/widget/header.html
  100. 1 5
      src/server/views/layout-kibela/base/layout.html

+ 1 - 0
.vscode/extensions.json

@@ -11,6 +11,7 @@
     "eg2.vscode-npm-script",
     "eg2.vscode-npm-script",
     "christian-kohler.npm-intellisense",
     "christian-kohler.npm-intellisense",
     "esbenp.prettier-vscode",
     "esbenp.prettier-vscode",
+    "shinnn.stylelint",
 	],
 	],
 	// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
 	// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
 	"unwantedRecommendations": [
 	"unwantedRecommendations": [

+ 1 - 0
.vscode/launch.json

@@ -14,6 +14,7 @@
           "server:debug"
           "server:debug"
         ],
         ],
         "port": 9229,
         "port": 9229,
+        "timeout": 30000,
         "restart": true,
         "restart": true,
         "console": "integratedTerminal",
         "console": "integratedTerminal",
         "internalConsoleOptions": "neverOpen"
         "internalConsoleOptions": "neverOpen"

+ 24 - 1
CHANGES.md

@@ -1,6 +1,29 @@
 # CHANGES
 # CHANGES
 
 
-## 3.4.5-RC
+## 3.4.7-RC
+
+* Fix: Searching with `tag:xxx` syntax doesn't work
+* I18n: Import data page
+
+## 3.4.6
+
+* Feature: Tags
+* Feature: Dropdown to copy page path/URL/MarkdownLink
+* Feature: List of drafts
+* Improvement: Replace icons of Editor Tool Bar
+* Improvement: Show display name when mouse hover to user image
+* Fix: URL in slack message is broken on Safari
+* Fix: Registration does not work when basic auth is enabled
+* Support: Publish API docs with swagger-jsdoc and ReDoc
+* Support: Upgrade libs
+    * cmd-env
+    * elasticsearch
+    * mongoose-gridfs
+    * node-dev
+    * null-loader
+    * react-codemirror
+
+## 3.4.5
 
 
 * Improvement: Pass autolink through the XSS filter according to CommonMark Spec
 * Improvement: Pass autolink through the XSS filter according to CommonMark Spec
 * Fix: Update ElasticSearch index when deleting/duplicating pages
 * Fix: Update ElasticSearch index when deleting/duplicating pages

+ 2 - 1
README.md

@@ -9,7 +9,7 @@
 </p>
 </p>
 
 
 <p align="center">
 <p align="center">
-  <a href="https://demo.growi.org">Demo Site</a>
+  <a href="https://docs.growi.org">Documentation</a> / <a href="https://demo.growi.org">Demo</a>
 </p>
 </p>
 <p align="center">
 <p align="center">
   <a href="https://heroku.com/deploy"><img src="https://www.herokucdn.com/deploy/button.png"></a>
   <a href="https://heroku.com/deploy"><img src="https://www.herokucdn.com/deploy/button.png"></a>
@@ -171,6 +171,7 @@ Environment Variables
     * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
     * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
     * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
     * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
+    * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
 * **Option to integrate with external systems**
 * **Option to integrate with external systems**
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
     * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
         * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**
         * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**

+ 1 - 0
config/env.dev.js

@@ -10,6 +10,7 @@ module.exports = {
     // 'growi-plugin-lsx',
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',
     // 'growi-plugin-pukiwiki-like-linker',
   ],
   ],
+  // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // DEV_HTTPS: true,
   // PUBLIC_WIKI_ONLY: true,
   // PUBLIC_WIKI_ONLY: true,

+ 10 - 0
config/swagger-definition.js

@@ -0,0 +1,10 @@
+const pkg = require('../package.json');
+
+module.exports = {
+  openapi: '3.0.1',
+  info: {
+    title: 'GROWI REST API v3',
+    version: pkg.version,
+  },
+  basePath: '/api/v3/',
+};

+ 1 - 2
config/webpack.dev.dll.js

@@ -14,7 +14,6 @@ module.exports = {
       'babel-polyfill',
       'babel-polyfill',
       'browser-bunyan', 'bunyan-format',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',
       'codemirror', 'react-codemirror2',
-      'clipboard',
       'date-fns',
       'date-fns',
       'diff2html',
       'diff2html',
       'debug',
       'debug',
@@ -24,7 +23,7 @@ module.exports = {
       'lodash', 'pako',
       'lodash', 'pako',
       'markdown-it', 'csv-to-markdown-table',
       'markdown-it', 'csv-to-markdown-table',
       'react', 'react-dom',
       'react', 'react-dom',
-      'react-bootstrap', 'react-bootstrap-typeahead', 'react-i18next', 'react-dropzone',
+      'react-bootstrap', 'react-bootstrap-typeahead', 'react-i18next', 'react-dropzone', 'react-copy-to-clipboard',
       'socket.io-client',
       'socket.io-client',
       'toastr',
       'toastr',
       'xss',
       'xss',

+ 17 - 16
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.4.5-RC",
+  "version": "3.4.7-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -21,12 +21,12 @@
   },
   },
   "scripts": {
   "scripts": {
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
-    "build:dev:app": "env-cmd config/env.dev.js webpack --config config/webpack.dev.js --progress",
+    "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
     "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
     "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
     "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
     "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
     "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
-    "build:prod": "env-cmd config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
+    "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
     "build": "npm run build:dev:watch",
     "clean:app": "rimraf -- public/js public/styles",
     "clean:app": "rimraf -- public/js public/styles",
     "clean:report": "rimraf -- report",
     "clean:report": "rimraf -- report",
@@ -44,15 +44,15 @@
     "migrate:down": "migrate-mongo down -f config/migrate.js",
     "migrate:down": "migrate-mongo down -f config/migrate.js",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "prebuild:dev:watch": "npm run prebuild:dev",
     "prebuild:dev:watch": "npm run prebuild:dev",
-    "prebuild:dev": "npm run clean:app && env-cmd config/env.dev.js npm run plugin:def && env-cmd config/env.dev.js npm run resource",
-    "prebuild:prod": "npm run clean && env-cmd config/env.prod.js npm run plugin:def && env-cmd config/env.prod.js npm run resource",
+    "prebuild:dev": "npm run clean:app && env-cmd -f config/env.dev.js npm run plugin:def && env-cmd -f config/env.dev.js npm run resource",
+    "prebuild:prod": "npm run clean && env-cmd -f config/env.prod.js npm run plugin:def && env-cmd -f config/env.prod.js npm run resource",
     "preserver:prod": "npm run migrate",
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "prestart": "npm run build:prod",
     "resource": "node bin/download-cdn-resources.js",
     "resource": "node bin/download-cdn-resources.js",
-    "server:debug": "env-cmd config/env.dev.js node-dev --inspect src/server/app.js",
-    "server:dev": "env-cmd config/env.dev.js node-dev --respawn src/server/app.js",
+    "server:debug": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
+    "server:dev": "env-cmd -f config/env.dev.js node-dev --respawn src/server/app.js",
     "server:prod:ci": "npm run server:prod -- --ci",
     "server:prod:ci": "npm run server:prod -- --ci",
-    "server:prod": "env-cmd config/env.prod.js node src/server/app.js",
+    "server:prod": "env-cmd -f config/env.prod.js node src/server/app.js",
     "server": "npm run server:dev",
     "server": "npm run server:dev",
     "start": "npm run server:prod",
     "start": "npm run server:prod",
     "test": "mocha --timeout 10000 --exit -r src/test/bootstrap.js src/test/**/*.js",
     "test": "mocha --timeout 10000 --exit -r src/test/bootstrap.js src/test/**/*.js",
@@ -76,9 +76,9 @@
     "cross-env": "^5.0.5",
     "cross-env": "^5.0.5",
     "csrf": "^3.1.0",
     "csrf": "^3.1.0",
     "diff": "^4.0.1",
     "diff": "^4.0.1",
-    "elasticsearch": "^15.0.0",
+    "elasticsearch": "^16.0.0",
     "entities": "^1.1.1",
     "entities": "^1.1.1",
-    "env-cmd": "^8.0.1",
+    "env-cmd": "^9.0.1",
     "esa-nodejs": "^0.0.7",
     "esa-nodejs": "^0.0.7",
     "escape-string-regexp": "^2.0.0",
     "escape-string-regexp": "^2.0.0",
     "express": "^4.16.1",
     "express": "^4.16.1",
@@ -101,7 +101,7 @@
     "mkdirp": "~0.5.1",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "module-alias": "^2.0.6",
     "mongoose": "^5.4.4",
     "mongoose": "^5.4.4",
-    "mongoose-gridfs": "^0.6.2",
+    "mongoose-gridfs": "^1.0.1",
     "mongoose-paginate": "^5.0.3",
     "mongoose-paginate": "^5.0.3",
     "mongoose-unique-validator": "^2.0.2",
     "mongoose-unique-validator": "^2.0.2",
     "multer": "~1.4.0",
     "multer": "~1.4.0",
@@ -184,11 +184,11 @@
     "mini-css-extract-plugin": "^0.6.0",
     "mini-css-extract-plugin": "^0.6.0",
     "mocha": "^6.0.1",
     "mocha": "^6.0.1",
     "morgan": "^1.9.0",
     "morgan": "^1.9.0",
-    "node-dev": "^3.1.3",
+    "node-dev": "^4.0.0",
     "node-sass": "^4.11.0",
     "node-sass": "^4.11.0",
     "nodelist-foreach-polyfill": "^1.2.0",
     "nodelist-foreach-polyfill": "^1.2.0",
     "normalize-path": "^3.0.0",
     "normalize-path": "^3.0.0",
-    "null-loader": "^0.1.1",
+    "null-loader": "^1.0.0",
     "on-headers": "^1.0.1",
     "on-headers": "^1.0.1",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "optimize-css-assets-webpack-plugin": "^5.0.0",
     "penpal": "^4.0.0",
     "penpal": "^4.0.0",
@@ -197,9 +197,9 @@
     "prettier-stylelint": "^0.4.2",
     "prettier-stylelint": "^0.4.2",
     "react": "^16.8.3",
     "react": "^16.8.3",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap": "^0.32.1",
-    "react-bootstrap-typeahead": "^3.3.4",
-    "react-clipboard.js": "^2.0.0",
-    "react-codemirror2": "^5.1.0",
+    "react-bootstrap-typeahead": "^3.4.2",
+    "react-codemirror2": "^6.0.0",
+    "react-copy-to-clipboard": "^5.0.1",
     "react-dom": "^16.8.3",
     "react-dom": "^16.8.3",
     "react-frame-component": "^4.0.0",
     "react-frame-component": "^4.0.0",
     "react-i18next": "^10.6.1",
     "react-i18next": "^10.6.1",
@@ -213,6 +213,7 @@
     "socket.io-client": "^2.0.3",
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.23.0",
     "style-loader": "^0.23.0",
     "stylelint-config-recess-order": "^2.0.1",
     "stylelint-config-recess-order": "^2.0.1",
+    "swagger-jsdoc": "^3.2.9",
     "terser-webpack-plugin": "^1.2.2",
     "terser-webpack-plugin": "^1.2.2",
     "throttle-debounce": "^2.0.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",

+ 1 - 0
public/images/icons/editor/bold.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="109" height="140" viewBox="0 0 10.9 14"><path d="M0 0h5.6c3 0 4.7 1.1 4.7 3.4a3.1 3.1 0 0 1-2.5 3.1 3.7 3.7 0 0 1 3.1 3.5c0 2.9-1.4 4-4.2 4H0zm5.2 6.5c2.7 0 2.6-1.4 2.6-3.1S7.9.7 5.6.7H2.3v5.8zm-2.9 6.6h3.4c2.1 0 2.7-1.1 2.7-3.1s0-2.8-3.2-2.8H2.3z"/></svg>

+ 1 - 0
public/images/icons/editor/check.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="144" height="160" viewBox="0 0 14.4 16"><path d="M13.9 5.5a.5.5 0 0 1 .5.5v9a1.1 1.1 0 0 1-1.1 1H1a1.1 1.1 0 0 1-1-1V2.6a1.1 1.1 0 0 1 1-1h7.1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5H1V15h12.3V6a.6.6 0 0 1 .6-.5zM3.6 8.3a.5.5 0 0 0 0 .7l2.5 2.5a.8.8 0 0 0 1.1 0h.1l7-10.7c.1-.2.1-.6-.2-.7a.5.5 0 0 0-.7.1L6.6 10.6 4.3 8.3a.5.5 0 0 0-.7 0z"/></svg>

+ 1 - 0
public/images/icons/editor/code.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="181" height="140" viewBox="0 0 18.1 14"><path d="M17.8 7.9l-4 3.8a.5.5 0 0 1-.8 0 .5.5 0 0 1 0-.8L16.8 7 13 3.2a.6.6 0 0 1 0-.9.5.5 0 0 1 .8 0l4 3.8a1.3 1.3 0 0 1 0 1.8zM5.2 2.3a.7.7 0 0 1 0 .9L1.3 7l3.9 3.9a.6.6 0 0 1 0 .8.6.6 0 0 1-.9 0L.4 7.9a1.3 1.3 0 0 1 0-1.8l3.9-3.8a.6.6 0 0 1 .9 0zM11.5.8L7.8 13.6a.6.6 0 0 1-.7.4.6.6 0 0 1-.5-.8L10.3.4a.7.7 0 0 1 .8-.4.6.6 0 0 1 .4.8z"/></svg>

+ 1 - 0
public/images/icons/editor/header.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="137" height="140" viewBox="0 0 13.7 14"><path d="M10.2 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v11.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-2.9a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.8V7.2H2.7v5.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6H.6a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.7V1.2H.6A.6.6 0 0 1 0 .6.6.6 0 0 1 .6 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v4.9H11V1.2h-.8a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6z"/></svg>

+ 1 - 0
public/images/icons/editor/italic.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="86" height="139" viewBox="0 0 8.6 13.9"><path d="M8.1 0a.6.6 0 0 1 .5.6c0 .3-.2.6-.7.6H6.2L3.8 12.8h1.8c.2 0 .4.3.4.5a.7.7 0 0 1-.7.6H.5c-.3 0-.5-.4-.5-.6s.4-.6.7-.6h1.7L4.9 1.2H3.1a.5.5 0 0 1-.5-.5c0-.3.1-.7.8-.7z"/></svg>

+ 1 - 0
public/images/icons/editor/link.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 16 16"><path d="M4.6 11.4l.4.2h.2v-.2l6.1-6.1a.4.4 0 0 0 .1-.3.5.5 0 0 0-.5-.5h-.3l-6 6.2a.6.6 0 0 0-.1.4c0 .1 0 .3.1.3zm2.8-1a2 2 0 0 1 0 1.1 4.1 4.1 0 0 1-.5.9l-2.1 1.9a1.9 1.9 0 0 1-1.5.7 2 2 0 0 1-1.6-.7 1.9 1.9 0 0 1-.7-1.5 2 2 0 0 1 .7-1.6l1.9-2.1a2 2 0 0 1 2.2-.5l.8-.8a3.2 3.2 0 0 0-1.4-.3 3.3 3.3 0 0 0-2.3.9L1 10.5A3.2 3.2 0 0 0 .9 15H1a2.9 2.9 0 0 0 2.3 1 3.2 3.2 0 0 0 2.3-1l2-1.9a4.6 4.6 0 0 0 .9-1.7 2.9 2.9 0 0 0-.3-1.8zM15 1a2.5 2.5 0 0 0-1-.8 3.1 3.1 0 0 0-3.5.8L8.4 2.9a3.1 3.1 0 0 0-.9 1.8 3.2 3.2 0 0 0 .3 1.9l.8-.8a2 2 0 0 1 0-1.1 2.2 2.2 0 0 1 .5-1.1l2.1-1.9.3-.3.4-.2.4-.2h.5a1.9 1.9 0 0 1 1.5.7 2 2 0 0 1 .7 1.6 1.9 1.9 0 0 1-.7 1.5l-2 2.1-.7.4a1.5 1.5 0 0 1-.9.2h-.4l-.8.8H12l1-.7 2-2.1A3 3 0 0 0 15 1z"/></svg>

+ 1 - 0
public/images/icons/editor/list-ol.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="237" height="160" viewBox="0 0 23.7 16"><path d="M23.7 2a.8.8 0 0 1-.8.8H6.6a.8.8 0 0 1-.7-.8.7.7 0 0 1 .7-.7h16.3a.7.7 0 0 1 .8.7zM6.6 8.7h16.3a.7.7 0 0 0 .8-.7.8.8 0 0 0-.8-.8H6.6a.8.8 0 0 0-.7.8.7.7 0 0 0 .7.7zm0 5.9h16.3a.7.7 0 0 0 .8-.7.7.7 0 0 0-.8-.7H6.6a.7.7 0 0 0-.7.7.7.7 0 0 0 .7.7zM1.5.5V4h.6V0h-.5L.7.5v.4l.8-.4zM.9 9.6l.3-.3c.9-.9 1.4-1.5 1.4-2.2a1.2 1.2 0 0 0-1.3-1.2h-.1a1.4 1.4 0 0 0-1.2.6l.3.4a1.2 1.2 0 0 1 .9-.5.6.6 0 0 1 .8.6v.2c0 .6-.4 1.1-1.5 2.1l-.4.4v.3h2.6v-.4zm.9 4.1a1 1 0 0 0 .7-.9 1 1 0 0 0-1.1-1 2 2 0 0 0-1.1.3v.4l.8-.2c.5 0 .8.2.8.6s-.5.7-.9.7H.7v.4H1c.6 0 1.1.2 1.1.8a.8.8 0 0 1-.9.8l-.9-.3-.2.4a2 2 0 0 0 1.1.3c1 0 1.5-.6 1.5-1.2a1.2 1.2 0 0 0-.9-1.1z"/></svg>

+ 1 - 0
public/images/icons/editor/list-ul.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="216" height="135" viewBox="0 0 21.6 13.5"><path d="M6.4 1.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM.9 1.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.7h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.7z"/></svg>

+ 1 - 0
public/images/icons/editor/picture.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="190" height="160" viewBox="0 0 19 16"><path d="M17.8 0H1.2A1.2 1.2 0 0 0 0 1.2v13.6A1.2 1.2 0 0 0 1.2 16h16.6a1.2 1.2 0 0 0 1.2-1.2V1.2a1.4 1.4 0 0 0-.2-.6.8.8 0 0 0-.4-.4zm0 14.8H1.2v-3.5l4.7-4.6 5 4.9.3.2.5-.2 2.1-1.9 3.9 4h.1v1.1zm0-2.8l-3.5-3.5-.4-.2h-.4l-2.2 2-4.9-4.8-.4-.2c-.2 0-.4 0-.5.2L1.2 9.7V1.2h16.6V12zm-4.2-6.1h.6a1.1 1.1 0 0 0 .6-1.1 1.2 1.2 0 0 0-1.2-1.1 1.3 1.3 0 0 0-1.2 1.2 1.2 1.2 0 0 0 .4.8 1.1 1.1 0 0 0 .8.3z"/></svg>

+ 1 - 0
public/images/icons/editor/quote.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="170" height="120" viewBox="0 0 17 12"><path d="M5 0H2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1H3v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6H2a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1zm10-6h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1h-2v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6h-3a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1z"/></svg>

+ 1 - 0
public/images/icons/editor/strikethrough.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="195" height="140" viewBox="0 0 19.5 14"><path d="M5.8 6.2H9C7.2 5.7 6.3 5 6.3 3.8a2.2 2.2 0 0 1 .9-1.9 4.3 4.3 0 0 1 2.5-.7 4.3 4.3 0 0 1 2.5.7 3.1 3.1 0 0 1 1.1 1.6.7.7 0 0 0 .6.4h.3a.7.7 0 0 0 .4-.8A3.6 3.6 0 0 0 13.1 1a6.7 6.7 0 0 0-6-.5 3.1 3.1 0 0 0-1.7 1.3 3.6 3.6 0 0 0-.6 2 2.9 2.9 0 0 0 1 2.3zm7 2.5a2 2 0 0 1 .6 1.4 2.4 2.4 0 0 1-1 1.9 3.7 3.7 0 0 1-2.5.7 4.6 4.6 0 0 1-3-.8 3.7 3.7 0 0 1-1.2-2 .6.6 0 0 0-.6-.5h-.2a.7.7 0 0 0-.5.8 4.1 4.1 0 0 0 1.5 2.5A6 6 0 0 0 9.8 14a7.5 7.5 0 0 0 2.6-.5 4.9 4.9 0 0 0 1.8-1.4 4.3 4.3 0 0 0 .6-2.2 5 5 0 0 0-.2-1.2zM.4 7.9a.7.7 0 0 1-.4-.5.4.4 0 0 1 .4-.4h18.8a.4.4 0 0 1 .3.6c0 .1-.1.2-.2.3z"/></svg>

+ 1 - 1
public/images/icons/editor/table.svg

@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M0 19.7v216.6h256V19.7H0zm78.8 196.9H19.7v-39.4h59.1v39.4zm0-59.1H19.7v-39.3h59.1v39.3zm0-59H19.7V59.1h59.1v39.4zm78.7 118.1h-59v-39.4h59v39.4zm0-59.1h-59v-39.3h59v39.3zm0-59h-59V59.1h59v39.4zm78.8 118.1h-59.1v-39.4h59.1v39.4zm0-59.1h-59.1v-39.3h59.1v39.3zm0-59h-59.1V59.1h59.1v39.4z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="203" height="160" viewBox="0 0 20.3 16"><path d="M19.1 16H1.2A1.2 1.2 0 0 1 0 14.8V1.2A1.2 1.2 0 0 1 1.2 0h17.9a1.2 1.2 0 0 1 1.2 1.2v13.6a1.2 1.2 0 0 1-1.2 1.2zm-5.2-4.3v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm12.8-4.2v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm12.8-4.3v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2z"/></svg>

+ 7 - 0
resource/cdn-manifests.js

@@ -70,6 +70,13 @@ module.exports = {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
+    {
+      name: 'redoc-standalone',
+      url: 'https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js',
+      args: {
+        integrity: '',
+      },
+    },
   ],
   ],
   style: [
   style: [
     {
     {

+ 26 - 8
resource/locales/en-US/translation.json

@@ -3,6 +3,8 @@
   "Edit": "Edit",
   "Edit": "Edit",
   "Delete": "Delete",
   "Delete": "Delete",
   "Duplicate": "Duplicate",
   "Duplicate": "Duplicate",
+  "Copy": "Copy",
+  "Click to copy": "Click to copy",
   "Move": "Move",
   "Move": "Move",
   "Moved": "Moved",
   "Moved": "Moved",
   "Unlinked": "Unlinked",
   "Unlinked": "Unlinked",
@@ -11,10 +13,17 @@
   "Cancel": "Cancel",
   "Cancel": "Cancel",
   "Create": "Create",
   "Create": "Create",
   "Admin": "Admin",
   "Admin": "Admin",
+  "Tag": "Tag",
+  "Tags": "Tags",
   "New": "New",
   "New": "New",
   "Shortcuts": "Shortcuts",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",
   "eg": "e.g.",
   "Undo": "Undo",
   "Undo": "Undo",
+  "Article": "Article",
+  "Page": "Page",
+  "Page Path": "Page Path",
+  "Category": "Category",
+  "User": "User",
 
 
   "Update": "Update",
   "Update": "Update",
   "Update Page": "Update Page",
   "Update Page": "Update Page",
@@ -77,7 +86,8 @@
   "Hide": "Hide",
   "Hide": "Hide",
   "Disclose E-mail": "Disclose E-mail",
   "Disclose E-mail": "Disclose E-mail",
 
 
-
+  "page exists": "this page already exists",
+  "Error occurred": "Error occurred",
 
 
   "Create today's": "Create today's ...",
   "Create today's": "Create today's ...",
   "Memo": "memo",
   "Memo": "memo",
@@ -100,6 +110,7 @@
   "Load latest": "Load latest",
   "Load latest": "Load latest",
   "edited this page": "edited this page.",
   "edited this page": "edited this page.",
 
 
+  "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "Sign out": "Logout",
 
 
@@ -114,6 +125,11 @@
     "unavaliable_user_id": "This 'User ID' is unavailable."
     "unavaliable_user_id": "This 'User ID' is unavailable."
   },
   },
 
 
+  "breaking_changes": {
+    "v346_passport_is_not_enabled": "Crowi Classic Authentication mechanism currently in use will <strong>no longer be supported</strong> in the near future. Switch to Passport from %s",
+    "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
+  },
+
   "page_register": {
   "page_register": {
     "notice": {
     "notice": {
       "restricted": "Admin approval required.",
       "restricted": "Admin approval required.",
@@ -401,10 +417,10 @@
 		"for_instance": " For instance, if you use growi within a company, you can write ",
 		"for_instance": " For instance, if you use growi within a company, you can write ",
 		"only_those": " Only those whose e-mail address including the company address can register.",
 		"only_those": " Only those whose e-mail address including the company address can register.",
     "insert_single": "Please insert single e-mail address per line.",
     "insert_single": "Please insert single e-mail address per line.",
-    "page_listing_1": "Page listing<br>restricted by 'Just Me'",
-    "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing",
-    "page_listing_2": "Page listing<br>restricted by User Group",
-    "page_listing_2_desc": "Show pages that are restricted by User Group when listing",
+    "page_listing_1": "Page listing/searching<br>restricted by 'Just Me'",
+    "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing/searching",
+    "page_listing_2": "Page listing/searching<br>restricted by User Group",
+    "page_listing_2_desc": "Show pages that are restricted by User Group when listing/searching",
 
 
 		"Authentication mechanism settings": "Authentication mechanism settings",
 		"Authentication mechanism settings": "Authentication mechanism settings",
     "note": "Note",
     "note": "Note",
@@ -617,8 +633,8 @@
     "write_java": "You can write Javascript that is applied to whole system.",
     "write_java": "You can write Javascript that is applied to whole system.",
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
-    "recent_created_page_num": "Recent Created Paging num",
-    "recent_created_page_num_desc": "The number of pages to show in Recent Created Page List on the user's home"
+    "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
+    "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page"
   },
   },
 
 
   "user_management": {
   "user_management": {
@@ -666,6 +682,8 @@
       "access_token": "Access token",
       "access_token": "Access token",
       "test_connection": "Test connection to qiita:team"
       "test_connection": "Test connection to qiita:team"
     },
     },
-    "import": "Import"
+    "import": "Import",
+    "page_skip": "Pages with a name that already exists on GROWI are not imported",
+    "Directory_hierarchy_tag": "Directory Hierarchy Tag"
   }
   }
 }
 }

+ 43 - 10
resource/locales/ja/translation.json

@@ -3,6 +3,8 @@
   "Edit": "編集",
   "Edit": "編集",
   "Delete": "削除",
   "Delete": "削除",
   "Duplicate": "複製",
   "Duplicate": "複製",
+  "Copy": "コピー",
+  "Click to copy": "クリックでコピー",
   "Move": "移動",
   "Move": "移動",
   "Moved": "移動しました",
   "Moved": "移動しました",
   "Unlinked": "リダイレクト削除",
   "Unlinked": "リダイレクト削除",
@@ -11,10 +13,17 @@
   "Cancel": "キャンセル",
   "Cancel": "キャンセル",
   "Create": "作成",
   "Create": "作成",
   "Admin": "管理",
   "Admin": "管理",
+  "Tag": "タグ",
+  "Tags": "タグ",
   "New": "作成",
   "New": "作成",
   "Shortcuts": "ショートカット",
   "Shortcuts": "ショートカット",
   "eg": "例:",
   "eg": "例:",
   "Undo": "元に戻す",
   "Undo": "元に戻す",
+  "Article": "記事",
+  "Page": "ページ",
+  "Page Path": "ページパス",
+  "Category": "カテゴリー",
+  "User": "ユーザー",
 
 
   "Update": "更新",
   "Update": "更新",
   "Update Page": "ページを更新",
   "Update Page": "ページを更新",
@@ -75,8 +84,8 @@
   "Hide": "非公開",
   "Hide": "非公開",
   "Disclose E-mail": "メールアドレスの公開",
   "Disclose E-mail": "メールアドレスの公開",
 
 
-
-
+  "page exists": "このページはすでに存在しています",
+  "Error occurred":"エラーが発生しました",
 
 
   "Create today's": "今日の◯◯を作成",
   "Create today's": "今日の◯◯を作成",
   "Memo": "メモ",
   "Memo": "メモ",
@@ -111,13 +120,16 @@
   "Shareable link": "このページの共有用URL",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Selecting authentication mechanism": "認証機構選択",
   "Selecting authentication mechanism": "認証機構選択",
-
+  "Add tags for this page": "タグを付ける",
+  "Edit tags for this page": "タグを編集する",
+  "You have no tag, You can set tags on pages": "使用中のタグがありません",
 
 
 
 
   "Show latest": "最新のページを表示",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
   "Load latest": "最新版を読み込む",
   "edited this page": "さんがこのページを編集しました。",
   "edited this page": "さんがこのページを編集しました。",
 
 
+  "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "Sign out": "ログアウト",
 
 
@@ -132,6 +144,11 @@
     "unavaliable_user_id": "このユーザーIDは利用できません。"
     "unavaliable_user_id": "このユーザーIDは利用できません。"
   },
   },
 
 
+  "breaking_changes": {
+    "v346_passport_is_not_enabled": "現在利用中の Crowi Classic Authentication mechanism は、近い将来<strong>サポートされなくなります</strong>。%s から Passport に切り替えてください。",
+    "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
+  },
+
   "page_register": {
   "page_register": {
     "notice": {
     "notice": {
        "restricted": "この Wiki への新規登録は制限されています。",
        "restricted": "この Wiki への新規登録は制限されています。",
@@ -190,6 +207,14 @@
     }
     }
   },
   },
 
 
+  "copy_to_clipboard": {
+    "Copy to clipboard": "クリップボードにコピー",
+    "Page path": "ページ名",
+    "Parmanent link": "パーマリンク",
+    "Page path and parmanent link": "ページ名とパーマリンク",
+    "Markdown link": "マークダウン形式のリンク"
+  },
+
   "search_help": {
   "search_help": {
     "title": "検索のヘルプ",
     "title": "検索のヘルプ",
     "and": {
     "and": {
@@ -208,6 +233,12 @@
     },
     },
     "exclude_prefix": {
     "exclude_prefix": {
       "desc": "ページ名が {{path}} から始まるページを除外"
       "desc": "ページ名が {{path}} から始まるページを除外"
+    },
+    "tag": {
+      "desc": "{{tag}} というタグを含むページを検索"
+    },
+    "exclude_tag": {
+      "desc": "{{tag}} というタグを含むページを除外"
     }
     }
   },
   },
   "search": {
   "search": {
@@ -417,10 +448,10 @@
     "for_instance":"例えば、",
     "for_instance":"例えば、",
     "only_those":"と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
     "only_those":"と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
     "insert_single":"1行に1メールアドレス入力してください。",
     "insert_single":"1行に1メールアドレス入力してください。",
-    "page_listing_1": "ページのリスト表示<br>'自分のみ'に閲覧制限しているページ",
-    "page_listing_1_desc": "ページのリスト表示、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
-    "page_listing_2": "ページのリスト表示<br>特定グループに閲覧制限しているページ",
-    "page_listing_2_desc": "ページのリスト表示、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "page_listing_1": "ページのリスト表示と検索<br>'自分のみ'に閲覧制限しているページ",
+    "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
+    "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
 
 
     "Authentication mechanism settings":"認証機構設定",
     "Authentication mechanism settings":"認証機構設定",
     "note": "メモ",
     "note": "メモ",
@@ -631,8 +662,8 @@
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
-    "recent_created_page_num": "Recent Created ページングサイズ",
-    "recent_created_page_num_desc": "ホーム画面の Recent Created で、1ページに表示する件数を設定します。"
+    "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
+    "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。"
   },
   },
 
 
   "user_management": {
   "user_management": {
@@ -680,6 +711,8 @@
       "access_token": "アクセストークン",
       "access_token": "アクセストークン",
       "test_connection": "接続テスト"
       "test_connection": "接続テスト"
     },
     },
-    "import": "インポート"
+    "import": "インポート",
+    "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
+    "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   }
   }
 }
 }

+ 1 - 1
resource/search/mappings.json

@@ -95,7 +95,7 @@
           "format": "dateOptionalTime"
           "format": "dateOptionalTime"
         },
         },
         "tag_names": {
         "tag_names": {
-          "type": "text"
+          "type": "keyword"
         }
         }
       }
       }
     }
     }

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

@@ -17,6 +17,7 @@ import GrowiRenderer from './util/GrowiRenderer';
 
 
 import HeaderSearchBox from './components/HeaderSearchBox';
 import HeaderSearchBox from './components/HeaderSearchBox';
 import SearchPage from './components/SearchPage';
 import SearchPage from './components/SearchPage';
+import TagsList from './components/TagsList';
 import PageEditor from './components/PageEditor';
 import PageEditor from './components/PageEditor';
 // eslint-disable-next-line import/no-duplicates
 // eslint-disable-next-line import/no-duplicates
 import OptionsSelector from './components/PageEditor/OptionsSelector';
 import OptionsSelector from './components/PageEditor/OptionsSelector';
@@ -31,13 +32,14 @@ import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment from './components/PageAttachment';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
 import RevisionPath from './components/Page/RevisionPath';
-import TagViewer from './components/Page/TagViewer';
-import RevisionUrl from './components/Page/RevisionUrl';
+import TagLabels from './components/Page/TagLabels';
 import BookmarkButton from './components/BookmarkButton';
 import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
 import LikeButton from './components/LikeButton';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import PagePathAutoComplete from './components/PagePathAutoComplete';
 import RecentCreated from './components/RecentCreated/RecentCreated';
 import RecentCreated from './components/RecentCreated/RecentCreated';
-import UserPictureList from './components/Common/UserPictureList';
+import StaffCredit from './components/StaffCredit/StaffCredit';
+import MyDraftList from './components/MyDraftList/MyDraftList';
+import UserPictureList from './components/User/UserPictureList';
 
 
 import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -70,6 +72,7 @@ let pageContent = '';
 let markdown = '';
 let markdown = '';
 let slackChannels;
 let slackChannels;
 let pageTags = [];
 let pageTags = [];
+let templateTagData = '';
 if (mainContent !== null) {
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id') || null;
   pageId = mainContent.getAttribute('data-page-id') || null;
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
@@ -79,6 +82,7 @@ if (mainContent !== null) {
   hasDraftOnHackmd = !!mainContent.getAttribute('data-page-has-draft-on-hackmd');
   hasDraftOnHackmd = !!mainContent.getAttribute('data-page-has-draft-on-hackmd');
   pagePath = mainContent.attributes['data-path'].value;
   pagePath = mainContent.attributes['data-path'].value;
   slackChannels = mainContent.getAttribute('data-slack-channels') || '';
   slackChannels = mainContent.getAttribute('data-slack-channels') || '';
+  templateTagData = mainContent.getAttribute('data-template-tags') || '';
   const rawText = document.getElementById('raw-text-original');
   const rawText = document.getElementById('raw-text-original');
   if (rawText) {
   if (rawText) {
     pageContent = rawText.innerHTML;
     pageContent = rawText.innerHTML;
@@ -297,6 +301,8 @@ const componentMappings = {
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
 
+  'tags-page': <I18nextProvider i18n={i18n}><TagsList crowi={crowi} /></I18nextProvider>,
+
   'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
   'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
   'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
@@ -308,9 +314,8 @@ if (pageId) {
 }
 }
 if (pagePath) {
 if (pagePath) {
   componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
   componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
-  componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
-  componentMappings['tag-viewer'] = <TagViewer crowi={crowi} pageId={pageId} sendTagData={setTagData} />;
-  componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
+  componentMappings['revision-path'] = <I18nextProvider i18n={i18n}><RevisionPath pageId={pageId} pagePath={pagePath} crowi={crowi} /></I18nextProvider>;
+  componentMappings['tag-label'] = <I18nextProvider i18n={i18n}><TagLabels crowi={crowi} pageId={pageId} sendTagData={setTagData} templateTagData={templateTagData} /></I18nextProvider>;
 }
 }
 
 
 Object.keys(componentMappings).forEach((key) => {
 Object.keys(componentMappings).forEach((key) => {
@@ -398,6 +403,25 @@ if (recentCreatedControlsElem) {
   );
   );
 }
 }
 
 
+const myDraftControlsElem = document.getElementById('user-draft-list');
+if (myDraftControlsElem) {
+  let limit = crowi.getConfig().recentCreatedLimit;
+  if (limit == null) {
+    limit = 10;
+  }
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <MyDraftList
+        limit={limit}
+        crowi={crowi}
+        crowiOriginRenderer={crowiRenderer}
+      />
+    </I18nextProvider>,
+    myDraftControlsElem,
+  );
+}
+
 /*
 /*
  * HackMD Editor
  * HackMD Editor
  */
  */
@@ -645,6 +669,16 @@ socket.on('page:editingWithHackmd', (data) => {
   }
   }
 });
 });
 
 
+// render for stuff credit
+const pageStuffCreditElem = document.getElementById('staff-credit');
+if (pageStuffCreditElem) {
+  ReactDOM.render(
+    <StaffCredit></StaffCredit>,
+    pageStuffCreditElem,
+  );
+
+}
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
   ReactDOM.render(

+ 0 - 67
src/client/js/components/CopyButton.js

@@ -1,67 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ClipboardButton from 'react-clipboard.js';
-
-export default class CopyButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.showToolTip = this.showToolTip.bind(this);
-
-    // retrieve xss library from window
-    this.xss = window.xss;
-  }
-
-  showToolTip() {
-    const buttonId = `#${this.props.buttonId}`;
-    $(buttonId).tooltip('show');
-    setTimeout(() => {
-      $(buttonId).tooltip('hide');
-    }, 1000);
-  }
-
-  render() {
-    const containerStyle = {
-      lineHeight: 0,
-    };
-    const style = Object.assign({
-      padding: '0 2px',
-      verticalAlign: 'text-top',
-    }, this.props.buttonStyle);
-
-    const text = this.xss.process(this.props.text);
-
-    return (
-      <span className="btn-copy-container" style={containerStyle}>
-        <ClipboardButton
-          className={this.props.buttonClassName}
-          button-id={this.props.buttonId}
-          button-data-toggle="tooltip"
-          button-data-container="body"
-          button-title="copied!"
-          button-data-placement="bottom"
-          button-data-trigger="manual"
-          button-style={style}
-          data-clipboard-text={text}
-          onSuccess={this.showToolTip}
-        >
-
-          <i className={this.props.iconClassName} />
-        </ClipboardButton>
-      </span>
-    );
-  }
-
-}
-
-CopyButton.propTypes = {
-  text: PropTypes.string.isRequired,
-  buttonId: PropTypes.string.isRequired,
-  buttonClassName: PropTypes.string.isRequired,
-  buttonStyle: PropTypes.object,
-  iconClassName: PropTypes.string.isRequired,
-};
-CopyButton.defaultProps = {
-  buttonStyle: {},
-};

+ 252 - 0
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -0,0 +1,252 @@
+import React from 'react';
+
+import PropTypes from 'prop-types';
+import Pagination from 'react-bootstrap/lib/Pagination';
+import Draft from '../PageList/Draft';
+
+export default class MyDraftList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      drafts: [],
+      currentDrafts: [],
+      activePage: 1,
+      paginationNumbers: {},
+    };
+
+    this.getDraftsFromLocalStorage = this.getDraftsFromLocalStorage.bind(this);
+    this.getCurrentDrafts = this.getCurrentDrafts.bind(this);
+    this.clearDraft = this.clearDraft.bind(this);
+    this.clearAllDrafts = this.clearAllDrafts.bind(this);
+    this.calculatePagination = this.calculatePagination.bind(this);
+  }
+
+  async componentWillMount() {
+    await this.getDraftsFromLocalStorage();
+    this.getCurrentDrafts(1);
+  }
+
+  async getDraftsFromLocalStorage() {
+    const draftsAsObj = JSON.parse(this.props.crowi.localStorage.getItem('draft') || '{}');
+
+    const res = await this.props.crowi.apiGet('/pages.exist', {
+      pages: draftsAsObj,
+    });
+
+    // {'/a': '#a', '/b': '#b'} => [{path: '/a', markdown: '#a'}, {path: '/b', markdown: '#b'}]
+    const drafts = Object.entries(draftsAsObj).map((d) => {
+      const path = d[0];
+      return {
+        path,
+        markdown: d[1],
+        isExist: res.pages[path],
+      };
+    });
+
+    this.setState({ drafts });
+  }
+
+  getCurrentDrafts(selectPageNumber) {
+    const limit = this.props.limit;
+    const totalCount = this.state.drafts.length;
+    const activePage = selectPageNumber;
+    const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
+
+    const currentDrafts = this.state.drafts.slice((activePage - 1) * limit, activePage * limit);
+
+    this.setState({
+      currentDrafts,
+      activePage,
+      paginationNumbers,
+    });
+  }
+
+  /**
+   * generate Elements of Draft
+   *
+   * @param {any} drafts Array of pages Model Obj
+   *
+   */
+  generateDraftList(drafts) {
+    return drafts.map((draft) => {
+      return (
+        <Draft
+          key={draft.path}
+          crowi={this.props.crowi}
+          crowiOriginRenderer={this.props.crowiOriginRenderer}
+          path={draft.path}
+          markdown={draft.markdown}
+          isExist={draft.isExist}
+          clearDraft={this.clearDraft}
+        />
+      );
+    });
+  }
+
+  clearDraft(path) {
+    this.props.crowi.clearDraft(path);
+
+    this.setState((prevState) => {
+      return {
+        drafts: prevState.drafts.filter((draft) => { return draft.path !== path }),
+        currentDrafts: prevState.drafts.filter((draft) => { return draft.path !== path }),
+      };
+    });
+  }
+
+  clearAllDrafts() {
+    this.props.crowi.clearAllDrafts();
+
+    this.setState({
+      drafts: [],
+      currentDrafts: [],
+      activePage: 1,
+      paginationNumbers: {},
+    });
+  }
+
+  calculatePagination(limit, totalCount, activePage) {
+    // calc totalPageNumber
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+
+    let paginationStart = activePage - 2;
+    let maxViewPageNum = activePage + 2;
+    // pagiNation Number area size = 5 , pageNuber calculate in here
+    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
+    if (paginationStart < 1) {
+      const diff = 1 - paginationStart;
+      paginationStart += diff;
+      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
+    }
+    if (maxViewPageNum > totalPage) {
+      const diff = maxViewPageNum - totalPage;
+      maxViewPageNum -= diff;
+      paginationStart = Math.max(1, paginationStart - diff);
+    }
+
+    return {
+      totalPage,
+      paginationStart,
+      maxViewPageNum,
+    };
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  generateFirstPrev(activePage) {
+    const paginationItems = [];
+    if (activePage !== 1) {
+      paginationItems.push(
+        <Pagination.First key="first" onClick={() => { return this.getCurrentDrafts(1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" onClick={() => { return this.getCurrentDrafts(this.state.activePage - 1) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.First key="first" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
+   * this function set  numbers
+   */
+  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+    const paginationItems = [];
+    for (let number = paginationStart; number <= maxViewPageNum; number++) {
+      paginationItems.push(
+        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getCurrentDrafts(number) }}>{number}</Pagination.Item>,
+      );
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set > & >>
+   */
+  generateNextLast(activePage, totalPage) {
+    const paginationItems = [];
+    if (totalPage !== activePage) {
+      paginationItems.push(
+        <Pagination.Next key="next" onClick={() => { return this.getCurrentDrafts(this.state.activePage + 1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" onClick={() => { return this.getCurrentDrafts(totalPage) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.Next key="next" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" disabled />,
+      );
+
+    }
+    return paginationItems;
+
+  }
+
+  render() {
+    const draftList = this.generateDraftList(this.state.currentDrafts);
+
+    const paginationItems = [];
+
+    const activePage = this.state.activePage;
+    const totalPage = this.state.paginationNumbers.totalPage;
+    const paginationStart = this.state.paginationNumbers.paginationStart;
+    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
+    const firstPrevItems = this.generateFirstPrev(activePage);
+    paginationItems.push(firstPrevItems);
+    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
+    paginationItems.push(paginations);
+    const nextLastItems = this.generateNextLast(activePage, totalPage);
+    paginationItems.push(nextLastItems);
+
+    return (
+      <div className="page-list-container-create">
+
+        { draftList.length === 0
+          && <span>No drafts yet.</span>
+        }
+
+        { draftList.length > 0
+          && (
+            <React.Fragment>
+              <button type="button" className="btn-danger mb-3" onClick={this.clearAllDrafts}>Delete All</button>
+              <div className="tab-pane m-t-30 accordion" id="draft-list">
+                {draftList}
+              </div>
+              <Pagination bsSize="small">{paginationItems}</Pagination>
+            </React.Fragment>
+          )
+        }
+
+      </div>
+    );
+  }
+
+}
+
+
+MyDraftList.propTypes = {
+  limit: PropTypes.number,
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+};

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

@@ -0,0 +1,118 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Dropdown from 'react-bootstrap/es/Dropdown';
+import MenuItem from 'react-bootstrap/es/MenuItem';
+
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+export default class CopyDropdown extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    // retrieve xss library from window
+    this.xss = window.xss;
+
+    this.generatePageUrl = this.generatePageUrl.bind(this);
+  }
+
+  showToolTip() {
+    const buttonId = '#copyPagePathDropdown';
+    $(buttonId).tooltip('show');
+    setTimeout(() => {
+      $(buttonId).tooltip('hide');
+    }, 1000);
+  }
+
+  generatePageUrl() {
+    return (this.props.pageId == null)
+      ? decodeURIComponent(window.location.pathname + window.location.search)
+      : `${window.location.origin}/${this.props.pageId}`;
+  }
+
+  generateMarkdownLink() {
+    return;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    const safePagePath = this.xss.process(this.props.pagePath);
+    const url = this.generatePageUrl();
+
+    return (
+      <Dropdown id="copyPagePathDropdown">
+
+        <Dropdown.Toggle
+          className="btn-copy"
+          style={this.props.buttonStyle}
+          data-toggle="tooltip"
+          data-placement="bottom"
+          data-trigger="manual"
+          title="copied!"
+        >
+          <i className="ti-clipboard"></i>
+        </Dropdown.Toggle>
+
+        <Dropdown.Menu>
+          <h5 className="ml-3 my-0 text-muted">{ t('copy_to_clipboard.Copy to clipboard') }</h5>
+          <MenuItem divider></MenuItem>
+
+          {/* Page path */}
+          <CopyToClipboard text={this.props.pagePath} onCopy={this.showToolTip}>
+            <MenuItem>
+              <div className="d-inline-flex flex-column">
+                <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Page path') }</strong></h6>
+                <span className="small">{safePagePath}</span>
+              </div>
+            </MenuItem>
+          </CopyToClipboard>
+          {/* Parmanent Link */}
+          { this.props.pageId && (
+            <CopyToClipboard text={url} onCopy={this.showToolTip}>
+              <MenuItem>
+                <div className="d-inline-flex flex-column">
+                  <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Parmanent link') }</strong></h6>
+                  <span className="small">{url}</span>
+                </div>
+              </MenuItem>
+            </CopyToClipboard>
+          )}
+          {/* Page path + Parmanent Link */}
+          { this.props.pageId && (
+            <CopyToClipboard text={`${this.props.pagePath}\n${url}`} onCopy={this.showToolTip}>
+              <MenuItem>
+                <div className="d-inline-flex flex-column">
+                  <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Page path and parmanent link') }</strong></h6>
+                  <span className="small mb-1">{safePagePath}</span><br></br>
+                  <span className="small">{url}</span>
+                </div>
+              </MenuItem>
+            </CopyToClipboard>
+          )}
+          {/* Markdown Link */}
+          { this.props.pageId && (
+            <CopyToClipboard text={`[${this.props.pagePath}](${url})`} onCopy={this.showToolTip}>
+              <MenuItem>
+                <div className="d-inline-flex flex-column">
+                  <h6 className="mt-1 mb-2"><strong>{ t('copy_to_clipboard.Markdown link') }</strong></h6>
+                  <span className="small">{`[${safePagePath}](${url})`}</span>
+                </div>
+              </MenuItem>
+            </CopyToClipboard>
+          )}
+        </Dropdown.Menu>
+
+      </Dropdown>
+    );
+  }
+
+}
+
+CopyDropdown.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  pagePath: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
+  buttonStyle: PropTypes.object,
+};

+ 17 - 13
src/client/js/components/Page/RevisionPath.js → src/client/js/components/Page/RevisionPath.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import CopyButton from '../CopyButton';
+import { withTranslation } from 'react-i18next';
 
 
-export default class RevisionPath extends React.Component {
+import CopyDropdown from './CopyDropdown';
+
+class RevisionPath extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -49,9 +51,10 @@ export default class RevisionPath extends React.Component {
   }
   }
 
 
   showToolTip() {
   showToolTip() {
-    $('#btnCopy').tooltip('show');
+    const buttonId = '#copyPagePathDropdown';
+    $(buttonId).tooltip('show');
     setTimeout(() => {
     setTimeout(() => {
-      $('#btnCopy').tooltip('hide');
+      $(buttonId).tooltip('hide');
     }, 1000);
     }, 1000);
   }
   }
 
 
@@ -78,7 +81,7 @@ export default class RevisionPath extends React.Component {
       marginLeft: '0.2em',
       marginLeft: '0.2em',
       marginRight: '0.2em',
       marginRight: '0.2em',
     };
     };
-    const editButtonStyle = {
+    const buttonStyle = {
       marginLeft: '0.5em',
       marginLeft: '0.5em',
       padding: '0 2px',
       padding: '0 2px',
     };
     };
@@ -110,13 +113,10 @@ export default class RevisionPath extends React.Component {
           <a href="/">/</a>
           <a href="/">/</a>
         </span>
         </span>
         {afterElements}
         {afterElements}
-        <CopyButton
-          buttonId="btnCopyRevisionPath"
-          text={this.props.pagePath}
-          buttonClassName="btn btn-default btn-copy"
-          iconClassName="ti-clipboard"
-        />
-        <a href="#edit" className="btn btn-default btn-edit" style={editButtonStyle}>
+
+        <CopyDropdown t={this.props.t} pagePath={this.props.pagePath} pageId={this.props.pageId} buttonStyle={buttonStyle}></CopyDropdown>
+
+        <a href="#edit" className="btn btn-default btn-edit" style={buttonStyle}>
           <i className="icon-note" />
           <i className="icon-note" />
         </a>
         </a>
       </span>
       </span>
@@ -126,6 +126,10 @@ export default class RevisionPath extends React.Component {
 }
 }
 
 
 RevisionPath.propTypes = {
 RevisionPath.propTypes = {
-  pagePath: PropTypes.string.isRequired,
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
 };
 };
+
+export default withTranslation()(RevisionPath);

+ 0 - 46
src/client/js/components/Page/RevisionUrl.js

@@ -1,46 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import CopyButton from '../CopyButton';
-
-export default class RevisionUrl extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    // retrieve xss library from window
-    this.xss = window.xss;
-  }
-
-  render() {
-    const buttonStyle = {
-      fontSize: '1em',
-    };
-
-    const pagePath = this.xss.process(this.props.pagePath);
-
-    const url = (this.props.pageId == null)
-      ? decodeURIComponent(window.location.href)
-      : `${window.location.origin}/${this.props.pageId}`;
-    const copiedText = `${pagePath}\n${url}`;
-
-    return (
-      <span>
-        {url}
-        <CopyButton
-          buttonId="btnCopyRevisionUrl"
-          text={copiedText}
-          buttonClassName="btn btn-default btn-copy-link"
-          buttonStyle={buttonStyle}
-          iconClassName="ti-clipboard"
-        />
-      </span>
-    );
-  }
-
-}
-
-RevisionUrl.propTypes = {
-  pageId: PropTypes.string,
-  pagePath: PropTypes.string.isRequired,
-};

+ 108 - 0
src/client/js/components/Page/TagEditor.jsx

@@ -0,0 +1,108 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import * as toastr from 'toastr';
+import Button from 'react-bootstrap/es/Button';
+import Modal from 'react-bootstrap/es/Modal';
+import TagsInput from './TagsInput';
+
+class TagEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      tags: [],
+      isOpenModal: false,
+      isEditorMode: null,
+    };
+
+    this.show = this.show.bind(this);
+    this.onTagsUpdatedByFormHandler = this.onTagsUpdatedByFormHandler.bind(this);
+    this.closeModalHandler = this.closeModalHandler.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.apiSuccessHandler = this.apiSuccessHandler.bind(this);
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+  }
+
+  show(tags) {
+    const isEditorMode = this.props.crowi.getCrowiForJquery().getCurrentEditorMode();
+    this.setState({ isOpenModal: true, isEditorMode, tags });
+  }
+
+  onTagsUpdatedByFormHandler(tags) {
+    this.setState({ tags });
+  }
+
+  closeModalHandler() {
+    this.setState({ isOpenModal: false });
+  }
+
+  async handleSubmit() {
+
+    if (!this.state.isEditorMode) {
+      try {
+        await this.props.crowi.apiPost('/tags.update', { pageId: this.props.pageId, tags: this.state.tags });
+        this.apiSuccessHandler();
+      }
+      catch (err) {
+        this.apiErrorHandler(err);
+        return;
+      }
+    }
+
+    this.props.onTagsUpdated(this.state.tags);
+
+    // close modal
+    this.setState({ isOpenModal: false });
+  }
+
+  apiSuccessHandler() {
+    toastr.success(undefined, 'updated tags successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
+  }
+
+  apiErrorHandler(err) {
+    toastr.error(err.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  render() {
+    return (
+      <Modal show={this.state.isOpenModal} onHide={this.closeModalHandler} id="editTagModal">
+        <Modal.Header closeButton className="bg-primary">
+          <Modal.Title className="text-white">Edit Tags</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <TagsInput crowi={this.props.crowi} tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByFormHandler} />
+        </Modal.Body>
+        <Modal.Footer>
+          <Button variant="primary" onClick={this.handleSubmit}>
+            Done
+          </Button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+TagEditor.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  onTagsUpdated: PropTypes.func.isRequired,
+};
+
+export default TagEditor;

+ 94 - 0
src/client/js/components/Page/TagLabels.jsx

@@ -0,0 +1,94 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import TagEditor from './TagEditor';
+
+class TagLabels extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      tags: [],
+    };
+
+    this.showEditor = this.showEditor.bind(this);
+    this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
+  }
+
+  async componentWillMount() {
+    // set pageTag on button
+    const pageId = this.props.pageId;
+
+    if (pageId) {
+      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
+      this.setState({ tags: res.tags });
+      this.props.sendTagData(res.tags);
+    }
+    else if (this.props.templateTagData) {
+      const templateTags = this.props.templateTagData.split(',');
+      this.setState({ tags: templateTags });
+      this.props.sendTagData(templateTags);
+    }
+  }
+
+  showEditor() {
+    this.tagEditor.show(this.state.tags);
+  }
+
+  tagsUpdatedHandler(tags) {
+    this.setState({ tags });
+    this.props.sendTagData(tags);
+  }
+
+  render() {
+    const tagElements = [];
+    const { t, pageId } = this.props;
+
+    for (let i = 0; i < this.state.tags.length; i++) {
+      tagElements.push(
+        <span key={`${pageId}_${i}`} className="text-muted">
+          <i className="tag-icon icon-tag mr-1"></i>
+          <a className="tag-name mr-2" href={`/_search?q=tag:${this.state.tags[i]}`} key={i.toString()}>{this.state.tags[i]}</a>
+        </span>,
+      );
+
+    }
+
+    return (
+      <div className={`tag-viewer ${this.props.pageId ? 'existed-page' : 'new-page'}`}>
+        {this.state.tags.length === 0 && (
+          <a className="btn btn-link btn-edit-tags no-tags p-0" onClick={this.showEditor}>
+            { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
+          </a>
+        )}
+        {tagElements}
+        {this.state.tags.length > 0 && (
+          <a className="btn btn-link btn-edit-tags p-0" onClick={this.showEditor}>
+            <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
+          </a>
+        )}
+
+        <TagEditor
+          ref={(c) => { this.tagEditor = c }}
+          crowi={this.props.crowi}
+          pageId={this.props.pageId}
+          onTagsUpdated={this.tagsUpdatedHandler}
+        >
+        </TagEditor>
+      </div>
+    );
+  }
+
+}
+
+TagLabels.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  crowi: PropTypes.object.isRequired,
+  pageId: PropTypes.string,
+  sendTagData: PropTypes.func.isRequired,
+  templateTagData: PropTypes.string,
+};
+
+export default withTranslation()(TagLabels);

+ 0 - 107
src/client/js/components/Page/TagViewer.jsx

@@ -1,107 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Button from 'react-bootstrap/es/Button';
-import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
-import Tooltip from 'react-bootstrap/es/Tooltip';
-import Modal from 'react-bootstrap/es/Modal';
-import PageTagForm from '../PageTagForm';
-
-/**
- * show tag labels on view and edit tag button on edit
- * tag labels on view is not implemented yet(GC-1391)
- */
-export default class TagViewer extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      currentPageTags: [],
-      newPageTags: [],
-      isOpenModal: false,
-    };
-
-    this.addNewTag = this.addNewTag.bind(this);
-    this.handleShowModal = this.handleShowModal.bind(this);
-    this.handleCloseModal = this.handleCloseModal.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-  }
-
-  async componentWillMount() {
-    // set pageTag on button
-    const pageId = this.props.pageId;
-    if (pageId) {
-      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
-      this.setState({ currentPageTags: res.tags });
-      this.props.sendTagData(res.tags);
-    }
-  }
-
-  // receive new tag from PageTagForm component
-  addNewTag(newPageTags) {
-    this.setState({ newPageTags });
-  }
-
-  handleCloseModal() {
-    this.setState({ isOpenModal: false });
-  }
-
-  handleShowModal() {
-    this.setState({ isOpenModal: true });
-  }
-
-  handleSubmit() {
-    this.props.sendTagData(this.state.newPageTags);
-    this.setState({ currentPageTags: this.state.newPageTags, isOpenModal: false });
-  }
-
-  render() {
-    const tagEditorButtonStyle = {
-      marginLeft: '0.2em',
-      padding: '0 2px',
-    };
-
-    return (
-      <span className="btn-tag-container">
-        <OverlayTrigger
-          key="tooltip"
-          placement="bottom"
-          overlay={(
-            <Tooltip id="tag-edit-button-tooltip" className="tag-tooltip">
-              {this.state.currentPageTags.length !== 0 ? this.state.currentPageTags.join() : 'tag is not set' }
-            </Tooltip>
-          )}
-        >
-          <Button
-            variant="primary"
-            onClick={this.handleShowModal}
-            className="btn btn-default btn-tag"
-            style={tagEditorButtonStyle}
-          >
-            <i className="fa fa-tags"></i>{this.state.currentPageTags.length}
-          </Button>
-        </OverlayTrigger>
-        <Modal show={this.state.isOpenModal} onHide={this.handleCloseModal} id="editTagModal">
-          <Modal.Header closeButton className="bg-primary">
-            <Modal.Title className="text-white">Page Tag</Modal.Title>
-          </Modal.Header>
-          <Modal.Body>
-            <PageTagForm crowi={this.props.crowi} currentPageTags={this.state.currentPageTags} addNewTag={this.addNewTag} />
-          </Modal.Body>
-          <Modal.Footer>
-            <Button variant="primary" onClick={this.handleSubmit}>
-              Done
-            </Button>
-          </Modal.Footer>
-        </Modal>
-      </span>
-    );
-  }
-
-}
-
-TagViewer.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
-  sendTagData: PropTypes.func.isRequired,
-};

+ 97 - 0
src/client/js/components/Page/TagsInput.jsx

@@ -0,0 +1,97 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class TagsInput
+ * @extends {React.Component}
+ */
+
+export default class TagsInput extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      resultTags: [],
+      isLoading: false,
+      selected: this.props.tags,
+      defaultPageTags: this.props.tags,
+    };
+    this.crowi = this.props.crowi;
+
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSearch = this.handleSearch.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
+
+  componentDidMount() {
+    this.typeahead.getInstance().focus();
+  }
+
+  handleChange(selected) {
+    // send tags to TagLabel Component when user add tag to form everytime
+    this.setState({ selected }, () => {
+      this.props.onTagsUpdated(this.state.selected);
+    });
+  }
+
+  async handleSearch(query) {
+    this.setState({ isLoading: true });
+    const res = await this.crowi.apiGet('/tags.search', { q: query });
+    res.tags.unshift(query); // selectable new tag whose name equals query
+    this.setState({
+      resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
+      isLoading: false,
+    });
+  }
+
+  handleSelect(e) {
+    if (e.keyCode === 32) { // '32' means ASCII code of 'space'
+      e.preventDefault();
+      const instance = this.typeahead.getInstance();
+      const { initialItem } = instance.state;
+
+      if (initialItem) {
+        instance._handleMenuItemSelect(initialItem, e);
+      }
+    }
+  }
+
+  render() {
+    return (
+      <div className="tag-typeahead">
+        <AsyncTypeahead
+          id="tag-typeahead-asynctypeahead"
+          ref={(typeahead) => { this.typeahead = typeahead }}
+          caseSensitive={false}
+          defaultSelected={this.state.defaultPageTags}
+          isLoading={this.state.isLoading}
+          minLength={1}
+          multiple
+          newSelectionPrefix=""
+          onChange={this.handleChange}
+          onSearch={this.handleSearch}
+          onKeyDown={this.handleSelect}
+          options={this.state.resultTags} // Search result (Some tag names)
+          placeholder="tag name"
+          selectHintOnEnter
+        />
+      </div>
+    );
+  }
+
+}
+
+TagsInput.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  tags: PropTypes.array.isRequired,
+  onTagsUpdated: PropTypes.func.isRequired,
+};
+
+TagsInput.defaultProps = {
+};

+ 4 - 2
src/client/js/components/PageAttachment/Attachment.js

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import User from '../User/User';
+import UserPicture from '../User/UserPicture';
 
 
 export default class Attachment extends React.Component {
 export default class Attachment extends React.Component {
 
 
@@ -53,7 +53,9 @@ export default class Attachment extends React.Component {
 
 
     return (
     return (
       <li className="attachment">
       <li className="attachment">
-        <User user={attachment.creator} />
+        <span className="mr-1 attachment-userpicture">
+          <UserPicture user={attachment.creator} size="sm"></UserPicture>
+        </span>
 
 
         <a href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
         <a href={attachment.filePathProxied}><i className={formatIcon}></i> {attachment.originalName}</a>
 
 

+ 3 - 2
src/client/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -3,7 +3,8 @@ import React from 'react';
 import Button from 'react-bootstrap/es/Button';
 import Button from 'react-bootstrap/es/Button';
 import Modal from 'react-bootstrap/es/Modal';
 import Modal from 'react-bootstrap/es/Modal';
 
 
-import User from '../User/User';
+import UserPicture from '../User/UserPicture';
+import Username from '../User/Username';
 
 
 export default class DeleteAttachmentModal extends React.Component {
 export default class DeleteAttachmentModal extends React.Component {
 
 
@@ -37,7 +38,7 @@ export default class DeleteAttachmentModal extends React.Component {
           <i className={this.iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
           <i className={this.iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
         </p>
         </p>
         <p>
         <p>
-          uploaded by <User user={attachment.creator} username />
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
         </p>
         </p>
         {content}
         {content}
       </div>
       </div>

+ 3 - 5
src/client/js/components/PageComment/Comment.js → src/client/js/components/PageComment/Comment.jsx

@@ -7,6 +7,7 @@ import RevisionBody from '../Page/RevisionBody';
 
 
 import ReactUtils from '../ReactUtils';
 import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
+import Username from '../User/Username';
 
 
 /**
 /**
  *
  *
@@ -120,19 +121,16 @@ export default class Comment extends React.Component {
     const rootClassName = this.getRootClassName();
     const rootClassName = this.getRootClassName();
     const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
     const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
     const commentBody = isMarkdown ? this.renderRevisionBody() : ReactUtils.nl2br(comment.comment);
     const commentBody = isMarkdown ? this.renderRevisionBody() : ReactUtils.nl2br(comment.comment);
-    const creatorsPage = `/user/${creator.username}`;
     const revHref = `?revision=${comment.revision}`;
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
 
     return (
     return (
       <div className={rootClassName}>
       <div className={rootClassName}>
-        <a href={creatorsPage}>
-          <UserPicture user={creator} />
-        </a>
+        <UserPicture user={creator} />
         <div className="page-comment-main">
         <div className="page-comment-main">
           <div className="page-comment-creator">
           <div className="page-comment-creator">
-            <a href={creatorsPage}>{creator.username}</a>
+            <Username user={creator} />
           </div>
           </div>
           <div className="page-comment-body">{commentBody}</div>
           <div className="page-comment-body">{commentBody}</div>
           <div className="page-comment-meta">
           <div className="page-comment-meta">

+ 1 - 4
src/client/js/components/PageComment/CommentForm.jsx

@@ -237,7 +237,6 @@ export default class CommentForm extends React.Component {
     const crowi = this.props.crowi;
     const crowi = this.props.crowi;
     const username = crowi.me;
     const username = crowi.me;
     const user = crowi.findUser(username);
     const user = crowi.findUser(username);
-    const creatorsPage = `/user/${username}`;
     const comment = this.state.comment;
     const comment = this.state.comment;
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : ReactUtils.nl2br(comment);
     const emojiStrategy = this.props.crowi.getEmojiStrategy();
     const emojiStrategy = this.props.crowi.getEmojiStrategy();
@@ -261,9 +260,7 @@ export default class CommentForm extends React.Component {
               { isLayoutTypeGrowi
               { isLayoutTypeGrowi
                 && (
                 && (
                 <div className="comment-form-user">
                 <div className="comment-form-user">
-                  <a href={creatorsPage}>
-                    <UserPicture user={user} />
-                  </a>
+                  <UserPicture user={user} />
                 </div>
                 </div>
                 )
                 )
               }
               }

+ 0 - 0
src/client/js/components/PageComment/CommentPreview.js → src/client/js/components/PageComment/CommentPreview.jsx


+ 2 - 1
src/client/js/components/PageComment/DeleteCommentModal.js → src/client/js/components/PageComment/DeleteCommentModal.jsx

@@ -8,6 +8,7 @@ import dateFnsFormat from 'date-fns/format';
 
 
 import ReactUtils from '../ReactUtils';
 import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
+import Username from '../User/Username';
 
 
 export default class DeleteCommentModal extends React.Component {
 export default class DeleteCommentModal extends React.Component {
 
 
@@ -43,7 +44,7 @@ export default class DeleteCommentModal extends React.Component {
           </Modal.Title>
           </Modal.Title>
         </Modal.Header>
         </Modal.Header>
         <Modal.Body>
         <Modal.Body>
-          <UserPicture user={comment.creator} size="xs" /> <strong>{comment.creator.username}</strong> wrote on {commentDate}:
+          <UserPicture user={comment.creator} size="xs" /> <strong><Username user={comment.creator}></Username></strong> wrote on {commentDate}:
           <p className="well well-sm comment-body m-t-5">{commentBody}</p>
           <p className="well well-sm comment-body m-t-5">{commentBody}</p>
         </Modal.Body>
         </Modal.Body>
         <Modal.Footer>
         <Modal.Footer>

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

@@ -38,6 +38,7 @@ export default class PageEditor extends React.Component {
     this.setCaretLine = this.setCaretLine.bind(this);
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
+    this.onSave = this.onSave.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
@@ -45,6 +46,7 @@ export default class PageEditor extends React.Component {
     this.saveDraft = this.saveDraft.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
+    this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
 
 
     // for scrolling
     // for scrolling
     this.lastScrolledDateWithCursor = null;
     this.lastScrolledDateWithCursor = null;
@@ -62,6 +64,20 @@ export default class PageEditor extends React.Component {
   componentWillMount() {
   componentWillMount() {
     // initial rendering
     // initial rendering
     this.renderPreview(this.state.markdown);
     this.renderPreview(this.state.markdown);
+
+    this.props.crowi.window.addEventListener('beforeunload', this.showUnsavedWarning);
+  }
+
+  componentWillUnmount() {
+    this.props.crowi.window.removeEventListener('beforeunload', this.showUnsavedWarning);
+  }
+
+  showUnsavedWarning(e) {
+    if (!this.props.crowi.getIsDocSaved()) {
+      // display browser default message
+      e.returnValue = '';
+      return '';
+    }
   }
   }
 
 
   getMarkdown() {
   getMarkdown() {
@@ -111,6 +127,12 @@ export default class PageEditor extends React.Component {
   onMarkdownChanged(value) {
   onMarkdownChanged(value) {
     this.renderPreviewWithDebounce(value);
     this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
     this.saveDraftWithDebounce();
+    this.props.crowi.setIsDocSaved(false);
+  }
+
+  onSave() {
+    this.props.onSaveWithShortcut(this.state.markdown);
+    this.props.crowi.setIsDocSaved(true);
   }
   }
 
 
   /**
   /**
@@ -330,9 +352,7 @@ export default class PageEditor extends React.Component {
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
             onChange={this.onMarkdownChanged}
             onUpload={this.onUpload}
             onUpload={this.onUpload}
-            onSave={() => {
-              this.props.onSaveWithShortcut(this.state.markdown);
-            }}
+            onSave={this.onSave}
           />
           />
         </div>
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">

+ 13 - 24
src/client/js/components/PageEditor/CodeMirrorEditor.js

@@ -615,11 +615,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   getNavbarItems() {
   getNavbarItems() {
-    // The following styles will be removed after creating icons for the editor navigation bar.
-    const paddingTopBottom54 = { paddingTop: '6px', paddingBottom: '5px' };
-    const paddingBottom6 = { paddingBottom: '7px' };
-    const fontSize18 = { fontSize: '18px' };
-
     return [
     return [
       <Button
       <Button
         key="nav-item-bold"
         key="nav-item-bold"
@@ -627,7 +622,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Bold"
         title="Bold"
         onClick={this.createReplaceSelectionHandler('**', '**')}
         onClick={this.createReplaceSelectionHandler('**', '**')}
       >
       >
-        <i className="fa fa-bold"></i>
+        <img src="/images/icons/editor/bold.svg" alt="icon-bold" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-italic"
         key="nav-item-italic"
@@ -635,15 +630,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Italic"
         title="Italic"
         onClick={this.createReplaceSelectionHandler('*', '*')}
         onClick={this.createReplaceSelectionHandler('*', '*')}
       >
       >
-        <i className="fa fa-italic"></i>
+        <img src="/images/icons/editor/italic.svg" alt="icon-italic" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
-        key="nav-item-strikethough"
+        key="nav-item-strikethrough"
         bsSize="small"
         bsSize="small"
         title="Strikethrough"
         title="Strikethrough"
         onClick={this.createReplaceSelectionHandler('~~', '~~')}
         onClick={this.createReplaceSelectionHandler('~~', '~~')}
       >
       >
-        <i className="fa fa-strikethrough"></i>
+        <img src="/images/icons/editor/strikethrough.svg" alt="icon-strikethrough" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-header"
         key="nav-item-header"
@@ -651,7 +646,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Heading"
         title="Heading"
         onClick={this.makeHeaderHandler}
         onClick={this.makeHeaderHandler}
       >
       >
-        <i className="fa fa-header"></i>
+        <img src="/images/icons/editor/header.svg" alt="icon-header" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-code"
         key="nav-item-code"
@@ -659,61 +654,55 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Inline Code"
         title="Inline Code"
         onClick={this.createReplaceSelectionHandler('`', '`')}
         onClick={this.createReplaceSelectionHandler('`', '`')}
       >
       >
-        <i className="fa fa-code"></i>
+        <img src="/images/icons/editor/code.svg" alt="icon-code" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-quote"
         key="nav-item-quote"
         bsSize="small"
         bsSize="small"
         title="Quote"
         title="Quote"
         onClick={this.createAddPrefixToEachLinesHandler('> ')}
         onClick={this.createAddPrefixToEachLinesHandler('> ')}
-        style={paddingBottom6}
       >
       >
-        <i className="ti-quote-right"></i>
+        <img src="/images/icons/editor/quote.svg" alt="icon-quote" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-ul"
         key="nav-item-ul"
         bsSize="small"
         bsSize="small"
         title="List"
         title="List"
         onClick={this.createAddPrefixToEachLinesHandler('- ')}
         onClick={this.createAddPrefixToEachLinesHandler('- ')}
-        style={paddingTopBottom54}
       >
       >
-        <i className="ti-list" style={fontSize18}></i>
+        <img src="/images/icons/editor/list-ul.svg" alt="icon-list-ul" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-ol"
         key="nav-item-ol"
         bsSize="small"
         bsSize="small"
         title="Numbered List"
         title="Numbered List"
         onClick={this.createAddPrefixToEachLinesHandler('1. ')}
         onClick={this.createAddPrefixToEachLinesHandler('1. ')}
-        style={paddingTopBottom54}
       >
       >
-        <i className="ti-list-ol" style={fontSize18}></i>
+        <img src="/images/icons/editor/list-ol.svg" alt="icon-list-ol" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-checkbox"
         key="nav-item-checkbox"
         bsSize="small"
         bsSize="small"
         title="Check List"
         title="Check List"
         onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
         onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
-        style={paddingBottom6}
       >
       >
-        <i className="ti-check-box"></i>
+        <img src="/images/icons/editor/check.svg" alt="icon-check" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-link"
         key="nav-item-link"
         bsSize="small"
         bsSize="small"
         title="Link"
         title="Link"
         onClick={this.createReplaceSelectionHandler('[', ']()')}
         onClick={this.createReplaceSelectionHandler('[', ']()')}
-        style={paddingBottom6}
       >
       >
-        <i className="icon-link"></i>
+        <img src="/images/icons/editor/link.svg" alt="icon-link" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-image"
         key="nav-item-image"
         bsSize="small"
         bsSize="small"
         title="Image"
         title="Image"
         onClick={this.createReplaceSelectionHandler('![', ']()')}
         onClick={this.createReplaceSelectionHandler('![', ']()')}
-        style={paddingBottom6}
       >
       >
-        <i className="icon-picture"></i>
+        <img src="/images/icons/editor/picture.svg" alt="icon-picture" height="13" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-table"
         key="nav-item-table"
@@ -721,7 +710,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Table"
         title="Table"
         onClick={this.showHandsonTableHandler}
         onClick={this.showHandsonTableHandler}
       >
       >
-        <img src="/images/icons/editor/table.svg" alt="icon-table" width="14" height="14" />
+        <img src="/images/icons/editor/table.svg" alt="icon-table" height="13" />
       </Button>,
       </Button>,
     ];
     ];
   }
   }

+ 0 - 0
src/client/js/components/PageHistory.js → src/client/js/components/PageHistory.jsx


+ 3 - 2
src/client/js/components/PageHistory/Revision.jsx

@@ -1,7 +1,8 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import UserDate from '../Common/UserDate';
+import UserDate from '../User/UserDate';
+import Username from '../User/Username';
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 
 
 export default class Revision extends React.Component {
 export default class Revision extends React.Component {
@@ -63,7 +64,7 @@ export default class Revision extends React.Component {
         </div>
         </div>
         <div className="m-l-10">
         <div className="m-l-10">
           <div className="revision-history-author">
           <div className="revision-history-author">
-            <strong>{author.username}</strong>
+            <strong><Username user={author}></Username></strong>
           </div>
           </div>
           <div className="revision-history-meta">
           <div className="revision-history-meta">
             <p>
             <p>

+ 0 - 0
src/client/js/components/PageHistory/RevisionDiff.js → src/client/js/components/PageHistory/RevisionDiff.jsx


+ 165 - 0
src/client/js/components/PageList/Draft.jsx

@@ -0,0 +1,165 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import RevisionBody from '../Page/RevisionBody';
+
+class Draft extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+      isOpen: false,
+    };
+
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'draft' });
+
+    this.renderHtml = this.renderHtml.bind(this);
+    this.toggleContent = this.toggleContent.bind(this);
+    this.copyMarkdownToClipboard = this.copyMarkdownToClipboard.bind(this);
+    this.renderAccordionTitle = this.renderAccordionTitle.bind(this);
+  }
+
+  copyMarkdownToClipboard() {
+    navigator.clipboard.writeText(this.props.markdown);
+  }
+
+  async toggleContent(e) {
+    const target = e.currentTarget.getAttribute('data-target');
+
+    if (!this.state.html) {
+      await this.renderHtml();
+    }
+
+    if (this.state.isOpen) {
+      $(target).collapse('hide');
+      this.setState({ isOpen: false });
+    }
+    else {
+      $(target).collapse('show');
+      this.setState({ isOpen: true });
+    }
+  }
+
+  async renderHtml() {
+    const context = {
+      markdown: this.props.markdown,
+    };
+
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    await interceptorManager.process('prePreProcess', context)
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      });
+  }
+
+  renderAccordionTitle(isExist) {
+    if (isExist) {
+      return (
+        <Fragment>
+          <span>{this.props.path}</span>
+          <span className="mx-2">({this.props.t('page exists')})</span>
+        </Fragment>
+      );
+    }
+
+    return (
+      <Fragment>
+        <a href={`${this.props.path}#edit`} target="_blank" rel="noopener noreferrer">{this.props.path}</a>
+        <span className="mx-2">
+          <span className="label-draft label label-default">draft</span>
+        </span>
+      </Fragment>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    const id = this.props.path.replace('/', '-');
+
+    return (
+      <div className="timeline-body">
+        <div className="panel panel-timeline">
+          <div className="panel-heading d-flex justify-content-between">
+            <div className="panel-title" onClick={this.toggleContent} data-target={`#${id}`}>
+              {this.renderAccordionTitle(this.props.isExist)}
+            </div>
+            <div>
+              {this.props.isExist
+                ? null
+                : (
+                  <a
+                    href={`${this.props.path}#edit`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="draft-edit"
+                    data-toggle="tooltip"
+                    data-placement="bottom"
+                    title={this.props.t('Edit')}
+                  >
+                    <i className="icon-note" />
+                  </a>
+                )
+              }
+              <a
+                className="draft-copy"
+                data-toggle="tooltip"
+                data-placement="bottom"
+                title={this.props.t('Copy')}
+                onClick={this.copyMarkdownToClipboard}
+              >
+                <i className="icon-doc" />
+              </a>
+              <a
+                className="text-danger draft-delete"
+                data-toggle="tooltip"
+                data-placement="top"
+                title={t('Delete')}
+                onClick={() => { return this.props.clearDraft(this.props.path) }}
+              >
+                <i className="icon-trash" />
+              </a>
+            </div>
+          </div>
+          <div className="panel-body collapse" id={id} aria-labelledby={id} data-parent="#draft-list">
+            <div className="revision-body wiki">
+              <RevisionBody html={this.state.html} />
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Draft.propTypes = {
+  t: PropTypes.func.isRequired,
+  crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
+  path: PropTypes.string.isRequired,
+  markdown: PropTypes.string.isRequired,
+  isExist: PropTypes.bool.isRequired,
+  clearDraft: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(Draft);

+ 0 - 70
src/client/js/components/PageTagForm.jsx

@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageTagForm
- * @extends {React.Component}
- */
-
-export default class PageTagForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      resultTags: [],
-      isLoading: false,
-      selected: this.props.currentPageTags,
-    };
-    this.crowi = this.props.crowi;
-  }
-
-  render() {
-    return (
-      <div className="tag-typeahead">
-        <AsyncTypeahead
-          allowNew
-          caseSensitive={false}
-          defaultSelected={this.props.currentPageTags}
-          emptyLabel=""
-          isLoading={this.state.isLoading}
-          minLength={1}
-          multiple
-          newSelectionPrefix=""
-          onChange={(selected) => {
-            this.setState({ selected }, () => {
-              this.props.addNewTag(this.state.selected);
-            });
-          }}
-          onSearch={async(query) => {
-            this.setState({ isLoading: true });
-            const res = await this.crowi.apiGet('/tags.search', { q: query });
-            res.tags.unshift(query); // selectable new tag whose name equals query
-            this.setState({
-              resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
-              isLoading: false,
-            });
-          }}
-          options={this.state.resultTags} // Search result (Some tag names)
-          placeholder="tag name"
-          selectHintOnEnter
-        />
-      </div>
-    );
-  }
-
-}
-
-PageTagForm.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  currentPageTags: PropTypes.array.isRequired,
-  addNewTag: PropTypes.func.isRequired,
-};
-
-PageTagForm.defaultProps = {
-};

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

@@ -47,6 +47,7 @@ class SavePageControls extends React.PureComponent {
   }
   }
 
 
   submit() {
   submit() {
+    this.props.crowi.setIsDocSaved(true);
     this.props.onSubmit();
     this.props.onSubmit();
   }
   }
 
 

+ 8 - 0
src/client/js/components/SearchForm.js

@@ -72,6 +72,14 @@ export default class SearchForm extends React.Component {
             <th className="text-right pt-2"><code>-prefix:/user/</code></th>
             <th className="text-right pt-2"><code>-prefix:/user/</code></th>
             <td><h6 className="m-0 pt-1">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
             <td><h6 className="m-0 pt-1">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
           </tr>
           </tr>
+          <tr>
+            <th className="text-right pt-2"><code>tag:wiki</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
+          </tr>
+          <tr>
+            <th className="text-right pt-2"><code>-tag:wiki</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.exclude_tag.desc', { tag: 'wiki' }) }</h6></td>
+          </tr>
         </tbody>
         </tbody>
       </table>
       </table>
     );
     );

+ 4 - 1
src/client/js/components/SearchPage/SearchResultList.js

@@ -17,7 +17,10 @@ export default class SearchResultList extends React.Component {
     const resultList = this.props.pages.map((page) => {
     const resultList = this.props.pages.map((page) => {
       return (
       return (
         <div id={page._id} key={page._id} className="search-result-page">
         <div id={page._id} key={page._id} className="search-result-page">
-          <h2><a href={page.path}>{page.path}</a></h2>
+          <h2 className="inline"><a href={page.path}>{page.path}</a></h2>
+          { page.tags.length > 0 && (
+            <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
+          )}
           <RevisionLoader
           <RevisionLoader
             crowi={this.props.crowi}
             crowi={this.props.crowi}
             crowiRenderer={this.growiRenderer}
             crowiRenderer={this.growiRenderer}

+ 5 - 1
src/client/js/components/SearchTypeahead.js

@@ -41,6 +41,9 @@ export default class SearchTypeahead extends React.Component {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
+    // **MEMO** This doesn't work at this time -- 2019.05.13 Yuki Takei
+    // It is needed to use Modal component of react-bootstrap when showing Move/Duplicate/CreateNewPage modals
+    // this.typeahead.getInstance().focus();
   }
   }
 
 
   componentWillUnmount() {
   componentWillUnmount() {
@@ -151,7 +154,7 @@ export default class SearchTypeahead extends React.Component {
     const page = option;
     const page = option;
     return (
     return (
       <span>
       <span>
-        <UserPicture user={page.lastUpdateUser} size="sm" />
+        <UserPicture user={page.lastUpdateUser} size="sm" withoutLink />
         <PagePath page={page} />
         <PagePath page={page} />
         <PageListMeta page={page} />
         <PageListMeta page={page} />
       </span>
       </span>
@@ -173,6 +176,7 @@ export default class SearchTypeahead extends React.Component {
       <div className="search-typeahead">
       <div className="search-typeahead">
         <AsyncTypeahead
         <AsyncTypeahead
           {...this.props}
           {...this.props}
+          id="search-typeahead-asynctypeahead"
           ref={(c) => { this.typeahead = c }}
           ref={(c) => { this.typeahead = c }}
           inputProps={inputProps}
           inputProps={inputProps}
           isLoading={this.state.isLoading}
           isLoading={this.state.isLoading}

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

@@ -0,0 +1,21 @@
+import React from 'react';
+
+/**
+ * Page staff credit component
+ *
+ * @export
+ * @class StaffCredit
+ * @extends {React.Component}
+ */
+export default class StaffCredit extends React.Component {
+
+  render() {
+    return (
+      <div>スタッフロール</div>
+    );
+  }
+
+}
+
+StaffCredit.propTypes = {
+};

+ 197 - 0
src/client/js/components/TagsList.jsx

@@ -0,0 +1,197 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+import Pagination from 'react-bootstrap/lib/Pagination';
+
+class TagsList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      tagData: [],
+      activePage: 1,
+      paginationNumbers: {},
+    };
+
+    this.calculatePagination = this.calculatePagination.bind(this);
+  }
+
+  async componentWillMount() {
+    await this.getTagList(1);
+  }
+
+  async getTagList(selectPageNumber) {
+    const limit = 10;
+    const offset = (selectPageNumber - 1) * limit;
+    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+
+    const totalCount = res.totalCount;
+    const tagData = res.data;
+    const activePage = selectPageNumber;
+    const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
+
+    this.setState({
+      tagData,
+      activePage,
+      paginationNumbers,
+    });
+  }
+
+  calculatePagination(limit, totalCount, activePage) {
+    // calc totalPageNumber
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+
+    let paginationStart = activePage - 2;
+    let maxViewPageNum = activePage + 2;
+    // pagination Number area size = 5 , pageNumber calculate in here
+    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
+    if (paginationStart < 1) {
+      const diff = 1 - paginationStart;
+      paginationStart += diff;
+      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
+    }
+    if (maxViewPageNum > totalPage) {
+      const diff = maxViewPageNum - totalPage;
+      maxViewPageNum -= diff;
+      paginationStart = Math.max(1, paginationStart - diff);
+    }
+
+    return {
+      totalPage,
+      paginationStart,
+      maxViewPageNum,
+    };
+  }
+
+  /**
+   * generate Elements of Tag
+   *
+   * @param {any} pages Array of pages Model Obj
+   *
+   */
+  generateTagList(tagData) {
+    return tagData.map((data) => {
+      return (
+        <a key={data.name} href={`/_search?q=tag:${data.name}`} className="list-group-item">
+          <i className="icon-tag mr-2"></i>{data.name}
+          <span className="ml-4 list-tag-count label label-default text-muted">{data.count}</span>
+        </a>
+      );
+    });
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  generateFirstPrev(activePage) {
+    const paginationItems = [];
+    if (activePage !== 1) {
+      paginationItems.push(
+        <Pagination.First key="first" onClick={() => { return this.getTagList(1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" onClick={() => { return this.getTagList(this.state.activePage - 1) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.First key="first" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
+   * this function set  numbers
+   */
+  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+    const paginationItems = [];
+    for (let number = paginationStart; number <= maxViewPageNum; number++) {
+      paginationItems.push(
+        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getTagList(number) }}>{number}</Pagination.Item>,
+      );
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set > & >>
+   */
+  generateNextLast(activePage, totalPage) {
+    const paginationItems = [];
+    if (totalPage !== activePage) {
+      paginationItems.push(
+        <Pagination.Next key="next" onClick={() => { return this.getTagList(this.state.activePage + 1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" onClick={() => { return this.getTagList(totalPage) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.Next key="next" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  render() {
+    const { t } = this.props;
+    const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
+
+    const paginationItems = [];
+
+    const activePage = this.state.activePage;
+    const totalPage = this.state.paginationNumbers.totalPage;
+    const paginationStart = this.state.paginationNumbers.paginationStart;
+    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
+    const firstPrevItems = this.generateFirstPrev(activePage);
+    paginationItems.push(firstPrevItems);
+    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
+    paginationItems.push(paginations);
+    const nextLastItems = this.generateNextLast(activePage, totalPage);
+    paginationItems.push(nextLastItems);
+    const pagination = this.state.tagData.length ? <Pagination>{paginationItems}</Pagination> : null;
+
+    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">
+          {pagination}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+TagsList.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  t: PropTypes.func.isRequired, // i18next
+};
+
+TagsList.defaultProps = {
+};
+
+export default withTranslation()(TagsList);

+ 0 - 40
src/client/js/components/User/User.js

@@ -1,40 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import UserPicture from './UserPicture';
-
-export default class User extends React.Component {
-
-  render() {
-    const user = this.props.user;
-    const userLink = `/user/${user.username}`;
-
-    const username = this.props.username;
-    const name = this.props.name;
-
-    return (
-      <span className="user-component">
-        <a href={userLink}>
-          <UserPicture user={user} />
-
-          {username
-              && <span className="user-component-username">@{user.username}</span>
-          }
-          {name
-              && <span className="user-component-name">({user.name})</span>
-          }
-        </a>
-      </span>
-    );
-  }
-
-}
-
-User.propTypes = {
-  user: PropTypes.object.isRequired,
-  name: PropTypes.bool,
-  username: PropTypes.bool,
-};
-
-User.defaultProps = {
-};

+ 0 - 0
src/client/js/components/Common/UserDate.js → src/client/js/components/User/UserDate.jsx


+ 9 - 1
src/client/js/components/User/UserPicture.js → src/client/js/components/User/UserPicture.jsx

@@ -40,14 +40,21 @@ export default class UserPicture extends React.Component {
 
 
   render() {
   render() {
     const user = this.props.user;
     const user = this.props.user;
+    const href = `/user/${user.username}`;
 
 
-    return (
+    const imgElem = (
       <img
       <img
         src={this.getUserPicture(user)}
         src={this.getUserPicture(user)}
         alt={user.username}
         alt={user.username}
         className={this.getClassName()}
         className={this.getClassName()}
       />
       />
     );
     );
+
+    return (
+      (this.props.withoutLink)
+        ? <span>{imgElem}</span>
+        : <a href={href}>{imgElem}</a>
+    );
   }
   }
 
 
 }
 }
@@ -55,6 +62,7 @@ export default class UserPicture extends React.Component {
 UserPicture.propTypes = {
 UserPicture.propTypes = {
   user: PropTypes.object.isRequired,
   user: PropTypes.object.isRequired,
   size: PropTypes.string,
   size: PropTypes.string,
+  withoutLink: PropTypes.bool,
 };
 };
 
 
 UserPicture.defaultProps = {
 UserPicture.defaultProps = {

+ 7 - 9
src/client/js/components/Common/UserPictureList.jsx → src/client/js/components/User/UserPictureList.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 
 
-import UserPicture from '../User/UserPicture';
+import UserPicture from './UserPicture';
 
 
 export default class UserPictureList extends React.Component {
 export default class UserPictureList extends React.Component {
 
 
@@ -27,16 +27,14 @@ export default class UserPictureList extends React.Component {
   render() {
   render() {
     const users = this.state.users.map((user) => {
     const users = this.state.users.map((user) => {
       // create Tooltip
       // create Tooltip
-      const tooltip = <Tooltip id={`tooltip-${user._id}`}>{user.username}</Tooltip>;
+      const tooltip = <Tooltip id={`tooltip-${user._id}`}>@{user.username}<br />{user.name}</Tooltip>;
 
 
       return (
       return (
-        <a key={user._id} data-user-id={user._id} href={`/user/${user.username}`}>
-          <OverlayTrigger overlay={tooltip} placement="bottom">
-            <span key={`span-${user._id}`}>{/* workaround from https://github.com/react-bootstrap/react-bootstrap/issues/2208#issuecomment-301737531 */}
-              <UserPicture user={user} size="xs" ref={`userPicture-${user._id}`} />
-            </span>
-          </OverlayTrigger>
-        </a>
+        <OverlayTrigger key={user._id} overlay={tooltip} placement="bottom">
+          <span key={`span-${user._id}`}>{/* workaround from https://github.com/react-bootstrap/react-bootstrap/issues/2208#issuecomment-301737531 */}
+            <UserPicture user={user} size="xs" ref={`userPicture-${user._id}`} />
+          </span>
+        </OverlayTrigger>
       );
       );
     });
     });
 
 

+ 22 - 0
src/client/js/components/User/Username.jsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class Username extends React.Component {
+
+  render() {
+    const { user } = this.props;
+
+    const name = user.name || '(no name)';
+    const username = user.username;
+    const href = `/user/${user.username}`;
+
+    return (
+      <a href={href}>{name} (@{username})</a>
+    );
+  }
+
+}
+
+Username.propTypes = {
+  user: PropTypes.object.isRequired,
+};

+ 13 - 0
src/client/js/util/Crowi.js

@@ -30,6 +30,7 @@ export default class Crowi {
     this.socketClientId = Math.floor(Math.random() * 100000);
     this.socketClientId = Math.floor(Math.random() * 100000);
     this.page = undefined;
     this.page = undefined;
     this.pageEditor = undefined;
     this.pageEditor = undefined;
+    this.isDocSaved = true;
 
 
     this.fetchUsers = this.fetchUsers.bind(this);
     this.fetchUsers = this.fetchUsers.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiGet = this.apiGet.bind(this);
@@ -79,6 +80,14 @@ export default class Crowi {
     this.pageEditor = pageEditor;
     this.pageEditor = pageEditor;
   }
   }
 
 
+  setIsDocSaved(isSaved) {
+    this.isDocSaved = isSaved;
+  }
+
+  getIsDocSaved() {
+    return this.isDocSaved;
+  }
+
   getWebSocket() {
   getWebSocket() {
     return this.socket;
     return this.socket;
   }
   }
@@ -164,6 +173,10 @@ export default class Crowi {
     this.localStorage.setItem('draft', JSON.stringify(this.draft));
     this.localStorage.setItem('draft', JSON.stringify(this.draft));
   }
   }
 
 
+  clearAllDrafts() {
+    this.localStorage.removeItem('draft');
+  }
+
   saveDraft(path, body) {
   saveDraft(path, body) {
     this.draft[path] = body;
     this.draft[path] = body;
     this.localStorage.setItem('draft', JSON.stringify(this.draft));
     this.localStorage.setItem('draft', JSON.stringify(this.draft));

+ 13 - 12
src/client/styles/agile-admin/inverse/colors/future.scss

@@ -1,21 +1,20 @@
 @import '../variables';
 @import '../variables';
 
 
-$basecolor: #16282D;
-$themecolor:rgba(11, 79, 104, 0.616);
+$basecolor: #16282d;
+$themecolor: rgba(11, 79, 104, 0.616);
 
 
-$topbar:#011414;
-$sidebar:#fff;
-$bodycolor:$basecolor;
-$headingtext: #D9A364;
+$topbar: #011414;
+$sidebar: #fff;
+$bodycolor: $basecolor;
+$headingtext: #d9a364;
 $bodytext: lighten($basecolor, 35%);
 $bodytext: lighten($basecolor, 35%);
 $linktext: lighten($basecolor, 45%);
 $linktext: lighten($basecolor, 45%);
 $linktext-hover: lighten($linktext, 80%);
 $linktext-hover: lighten($linktext, 80%);
-$sidebar-text:rgb(65, 133, 124);
-$dark-themecolor:#4F5467;
-
+$sidebar-text: rgb(65, 133, 124);
+$dark-themecolor: #4f5467;
 
 
 $primary: $themecolor;
 $primary: $themecolor;
-$info: lighten($themecolor,20%);
+$info: lighten($themecolor, 20%);
 
 
 $logo-mark-fill: rgb(170, 245, 237);
 $logo-mark-fill: rgb(170, 245, 237);
 $wikilinktext: saturate($bodytext, 20%);
 $wikilinktext: saturate($bodytext, 20%);
@@ -31,6 +30,8 @@ $inline-code-bg: darken($bodycolor, 5%);
 @import 'apply-colors';
 @import 'apply-colors';
 @import 'apply-colors-dark';
 @import 'apply-colors-dark';
 
 
-.bg-title{
-  border-bottom: 1px solid rgb(131, 228, 215);
+.main-container:not(.on-edit) {
+  .bg-title {
+    border-bottom: 1px solid rgb(131, 228, 215);
+  }
 }
 }

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

@@ -6,6 +6,11 @@
   li.attachment {
   li.attachment {
     list-style: none;
     list-style: none;
   }
   }
+
+  .attachment-userpicture {
+    line-height: 1.7em;
+    vertical-align: bottom;
+  }
 }
 }
 
 
 .page-attachments,
 .page-attachments,

+ 0 - 2
src/client/styles/scss/_editor-attachment.scss

@@ -1,7 +1,6 @@
 @import 'editor-overlay';
 @import 'editor-overlay';
 
 
 .editor-container {
 .editor-container {
-
   // for Dropzone
   // for Dropzone
   .dropzone {
   .dropzone {
     @mixin insertSimpleLineIcons($code) {
     @mixin insertSimpleLineIcons($code) {
@@ -49,7 +48,6 @@
 
 
     // uploadable
     // uploadable
     &.dropzone-uploadable {
     &.dropzone-uploadable {
-
       // accepted
       // accepted
       &.dropzone-accepted:not(.dropzone-rejected) {
       &.dropzone-accepted:not(.dropzone-rejected) {
         .overlay.overlay-dropzone-active {
         .overlay.overlay-dropzone-active {

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

@@ -1,5 +1,4 @@
 body.kibela {
 body.kibela {
-
   .icon-link,
   .icon-link,
   .CodeMirror-hint-active,
   .CodeMirror-hint-active,
   .nav-main-left-tab,
   .nav-main-left-tab,
@@ -131,8 +130,7 @@ body.kibela {
   .nav.nav-tabs {
   .nav.nav-tabs {
     border-bottom-color: #f4f5f6;
     border-bottom-color: #f4f5f6;
 
 
-    >li>a {
-
+    > li > a {
       &,
       &,
       &:hover,
       &:hover,
       &:focus {
       &:focus {
@@ -143,7 +141,7 @@ body.kibela {
       }
       }
     }
     }
 
 
-    >li.active>a {
+    > li.active > a {
       background: transparent !important;
       background: transparent !important;
       border-top: none;
       border-top: none;
       border-right: none;
       border-right: none;
@@ -185,8 +183,8 @@ body.kibela {
     @include expand-editor($header-plus-footer);
     @include expand-editor($header-plus-footer);
 
 
     .main {
     .main {
-      >.row.page-content {
-        >.col-xs-12 {
+      > .row.page-content {
+        > .col-xs-12 {
           width: 100%;
           width: 100%;
           padding: 0;
           padding: 0;
         }
         }

+ 12 - 22
src/client/styles/scss/_on-edit.scss

@@ -1,7 +1,6 @@
 @import 'editor-overlay';
 @import 'editor-overlay';
 
 
 body:not(.on-edit) {
 body:not(.on-edit) {
-
   // hide .page-editor-footer
   // hide .page-editor-footer
   .page-editor-footer {
   .page-editor-footer {
     display: none !important;
     display: none !important;
@@ -19,11 +18,11 @@ body.on-edit {
 
 
   // for growi layout
   // for growi layout
   .main {
   .main {
-    >.row {
+    > .row {
       margin: 0;
       margin: 0;
 
 
-      >.col-lg-10,
-      >.col-md-9 {
+      > .col-lg-10,
+      > .col-md-9 {
         width: 100%;
         width: 100%;
         padding: 0;
         padding: 0;
       }
       }
@@ -72,7 +71,6 @@ body.on-edit {
 
 
   // hide hackmd related alert
   // hide hackmd related alert
   &.hackmd #page-status-alert {
   &.hackmd #page-status-alert {
-
     .alert-hackmd-someone-editing,
     .alert-hackmd-someone-editing,
     .alert-hackmd-draft-exists {
     .alert-hackmd-draft-exists {
       display: none;
       display: none;
@@ -94,20 +92,17 @@ body.on-edit {
     left: $left-margin;
     left: $left-margin;
     z-index: 1;
     z-index: 1;
     width: calc(100% - #{$left-margin} - #{$right-margin});
     width: calc(100% - #{$left-margin} - #{$right-margin});
+    padding-top: 3px;
     pointer-events: none; // disable pointer-events because it becomes an obstacle
     pointer-events: none; // disable pointer-events because it becomes an obstacle
 
 
     background: none;
     background: none;
 
 
-    >.header-container {
+    > .header-container {
       width: 100%; //   for crowi layout
       width: 100%; //   for crowi layout
       padding: 0; //    for crowi layout
       padding: 0; //    for crowi layout
       pointer-events: initial; // enable pointer-events
       pointer-events: initial; // enable pointer-events
     }
     }
 
 
-    .header-wrap {
-      overflow-x: hidden;
-    }
-
     h1#revision-path {
     h1#revision-path {
       @include variable-font-size(20px);
       @include variable-font-size(20px);
       line-height: 1em;
       line-height: 1em;
@@ -122,13 +117,8 @@ body.on-edit {
       }
       }
     }
     }
 
 
-    div#page-tag {
-      display: inline;
-      margin-right: auto;
-
-      .page-tag-form {
-        border-radius: 5px;
-      }
+    .tag-viewer.new-page {
+      display: block;
     }
     }
 
 
     // hide if screen size is less than tablet
     // hide if screen size is less than tablet
@@ -169,7 +159,6 @@ body.on-edit {
   }
   }
 
 
   &.builtin-editor {
   &.builtin-editor {
-
     /*****************
     /*****************
     * Editor styles
     * Editor styles
     *****************/
     *****************/
@@ -211,7 +200,8 @@ body.on-edit {
       }
       }
     }
     }
 
 
-    .page-editor-preview-container {}
+    .page-editor-preview-container {
+    }
 
 
     .page-editor-preview-body {
     .page-editor-preview-body {
       padding-top: 18px;
       padding-top: 18px;
@@ -231,7 +221,7 @@ body.on-edit {
           width: 20px;
           width: 20px;
         }
         }
 
 
-        .dropdown-menu>li>a {
+        .dropdown-menu > li > a {
           display: flex;
           display: flex;
           align-items: center;
           align-items: center;
           justify-content: space-between;
           justify-content: space-between;
@@ -270,7 +260,7 @@ body.on-edit {
     }
     }
 
 
     .hackmd-preinit,
     .hackmd-preinit,
-    #iframe-hackmd-container>iframe {
+    #iframe-hackmd-container > iframe {
       border: none;
       border: none;
     }
     }
 
 
@@ -348,8 +338,8 @@ body.on-edit {
 
 
 #tag-edit-button-tooltip {
 #tag-edit-button-tooltip {
   .tooltip-inner {
   .tooltip-inner {
-    background-color: #fff;
     color: #000;
     color: #000;
+    background-color: #fff;
     border: 1px solid #ccc;
     border: 1px solid #ccc;
   }
   }
 
 

+ 16 - 12
src/client/styles/scss/_page.scss

@@ -8,20 +8,13 @@
    * header
    * header
    */
    */
   header {
   header {
-
     // the container of h1
     // the container of h1
     div.title-container {
     div.title-container {
       margin-right: auto;
       margin-right: auto;
     }
     }
 
 
-    .btn-tag {
-      line-height: 20px;
-      display: block;
-    }
-
     .btn-copy,
     .btn-copy,
     .btn-copy-link,
     .btn-copy-link,
-    .btn-tag,
     .btn-edit {
     .btn-edit {
       @extend .text-muted;
       @extend .text-muted;
       border: none;
       border: none;
@@ -32,13 +25,21 @@
       }
       }
     }
     }
 
 
+    .btn-edit-tags {
+      @extend .text-muted;
+      opacity: 0.5;
+
+      &.no-tags {
+        opacity: 0.7;
+      }
+    }
+
     // change button opacity
     // change button opacity
     &:hover {
     &:hover {
-
       .btn.btn-copy,
       .btn.btn-copy,
       .btn-copy-link,
       .btn-copy-link,
-      .btn-tag,
-      .btn.btn-edit {
+      .btn.btn-edit,
+      .btn.btn-edit-tags {
         opacity: unset;
         opacity: unset;
       }
       }
     }
     }
@@ -88,6 +89,10 @@
         }
         }
       }
       }
     }
     }
+
+    .tag-viewer.new-page {
+      display: none;
+    }
   }
   }
 
 
   // alert component settings
   // alert component settings
@@ -107,7 +112,6 @@
 .main-container .main .content-main .revision-history {
 .main-container .main .content-main .revision-history {
   .revision-history-list {
   .revision-history-list {
     .revision-history-outer {
     .revision-history-outer {
-
       // add border-top except of first element
       // add border-top except of first element
       &:not(:first-of-type) {
       &:not(:first-of-type) {
         border-top: 1px solid $border;
         border-top: 1px solid $border;
@@ -213,7 +217,7 @@
   opacity: 0;
   opacity: 0;
   transition: opacity 0.3s ease-out;
   transition: opacity 0.3s ease-out;
 
 
-  &>* {
+  & > * {
     box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
     box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
   }
   }
 }
 }

+ 7 - 5
src/client/styles/scss/_search.scss

@@ -32,6 +32,7 @@
   }
   }
 
 
   .rbt-menu {
   .rbt-menu {
+    max-height: none !important;
     margin-top: 3px;
     margin-top: 3px;
 
 
     li a span {
     li a span {
@@ -45,7 +46,7 @@
         font-size: 0.9em;
         font-size: 0.9em;
         color: #999;
         color: #999;
 
 
-        >span {
+        > span {
           margin-right: 0.3rem;
           margin-right: 0.3rem;
         }
         }
       }
       }
@@ -114,7 +115,6 @@
 }
 }
 
 
 .search-sidebar {
 .search-sidebar {
-
   .search-form,
   .search-form,
   .form-group,
   .form-group,
   .rbt-input.form-control,
   .rbt-input.form-control,
@@ -157,7 +157,7 @@
       }
       }
 
 
       .nav {
       .nav {
-        >li {
+        > li {
           padding: 2px 8px;
           padding: 2px 8px;
 
 
           &.active {
           &.active {
@@ -187,12 +187,14 @@
       // adjust for anchor links by the height of fixed .search-page-input
       // adjust for anchor links by the height of fixed .search-page-input
       margin-top: -48px;
       margin-top: -48px;
 
 
-      >h2 {
+      > h2 {
+        display: inline;
+        margin-right: 10px;
         font-size: 20px;
         font-size: 20px;
         line-height: 1em;
         line-height: 1em;
       }
       }
 
 
-      &:first-child>h2 {
+      &:first-child > h2 {
         margin-top: 0;
         margin-top: 0;
       }
       }
 
 

+ 32 - 0
src/client/styles/scss/_tag.scss

@@ -0,0 +1,32 @@
+.tag-viewer {
+  .manage-tags {
+    font-size: 10px;
+    cursor: pointer;
+  }
+
+  .tag-icon:not(:first-child) {
+    margin-left: 5px;
+  }
+
+  .btn-edit-tags,
+  .tag-icon {
+    font-size: 10px;
+  }
+
+  .tag-name {
+    margin-left: 1px;
+    font-size: 10px;
+  }
+}
+
+#tags-page {
+  .list-tag-count {
+    background: rgba(0, 0, 0, 0.08);
+  }
+}
+
+#editTagModal {
+  .form-control {
+    height: auto;
+  }
+}

+ 30 - 11
src/client/styles/scss/_user.scss

@@ -24,17 +24,19 @@
 
 
       ul {
       ul {
         padding-left: 0;
         padding-left: 0;
+
         li {
         li {
           list-style: none;
           list-style: none;
         }
         }
       }
       }
+
       .user-page-username {
       .user-page-username {
         font-weight: bold;
         font-weight: bold;
       }
       }
-      .user-page-email {
-      }
-      .user-page-introduction {
-      }
+
+      .user-page-email {}
+
+      .user-page-introduction {}
     }
     }
 
 
     .btn-like,
     .btn-like,
@@ -62,13 +64,30 @@
       height: 48px;
       height: 48px;
     }
     }
   }
   }
-}
 
 
-.user-component {
-  img.picture {
-    margin-right: 4px;
-  }
-  span {
-    margin-right: 4px;
+  .user-page-content {
+    #user-draft-list {
+      #draft-list {
+        .panel-title {
+          width: 100%;
+
+          .label-draft {
+            padding: 1px 5px;
+            margin: 0 0 0 4px;
+            font-weight: normal;
+          }
+        }
+
+        a {
+
+          .icon-copy,
+          .draft-delete,
+          .icon-edit {
+            margin: 0 0 0 4px;
+            cursor: pointer;
+          }
+        }
+      }
+    }
   }
   }
 }
 }

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

@@ -39,6 +39,7 @@
 @import 'user_growi';
 @import 'user_growi';
 @import 'handsontable';
 @import 'handsontable';
 @import 'wiki';
 @import 'wiki';
+@import 'tag';
 
 
 /*
 /*
  * for Guest User Mode
  * for Guest User Mode
@@ -64,14 +65,17 @@
     width: 48px;
     width: 48px;
     height: 48px;
     height: 48px;
   }
   }
+
   &.picture-md {
   &.picture-md {
     width: 24px;
     width: 24px;
     height: 24px;
     height: 24px;
   }
   }
+
   &.picture-sm {
   &.picture-sm {
     width: 18px;
     width: 18px;
     height: 18px;
     height: 18px;
   }
   }
+
   &.picture-xs {
   &.picture-xs {
     width: 14px;
     width: 14px;
     height: 14px;
     height: 14px;

+ 0 - 1
src/server/crowi/dev.js

@@ -5,7 +5,6 @@ const path = require('path');
 const swig = require('swig-templates');
 const swig = require('swig-templates');
 const onHeaders = require('on-headers');
 const onHeaders = require('on-headers');
 
 
-
 class CrowiDev {
 class CrowiDev {
 
 
   /**
   /**

+ 10 - 7
src/server/crowi/express-init.js

@@ -21,10 +21,14 @@ module.exports = function(crowi, app) {
   const i18nMiddleware = require('i18next-express-middleware');
   const i18nMiddleware = require('i18next-express-middleware');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
   const env = crowi.node_env;
   const env = crowi.node_env;
-  const config = crowi.getConfig();
   const middleware = require('../util/middlewares');
   const middleware = require('../util/middlewares');
 
 
+  // Old type config API
+  const config = crowi.getConfig();
   const Config = crowi.model('Config');
   const Config = crowi.model('Config');
+  // New type config API
+  const configManager = crowi.configManager;
+
   const User = crowi.model('User');
   const User = crowi.model('User');
   const lngDetector = new i18nMiddleware.LanguageDetector();
   const lngDetector = new i18nMiddleware.LanguageDetector();
   lngDetector.addDetector(i18nUserSettingDetector);
   lngDetector.addDetector(i18nUserSettingDetector);
@@ -65,7 +69,7 @@ module.exports = function(crowi, app) {
     req.csrfToken = null;
     req.csrfToken = null;
 
 
     res.locals.req = req;
     res.locals.req = req;
-    res.locals.baseUrl = crowi.configManager.getSiteUrl();
+    res.locals.baseUrl = configManager.getSiteUrl();
     res.locals.config = config;
     res.locals.config = config;
     res.locals.env = env;
     res.locals.env = env;
     res.locals.now = now;
     res.locals.now = now;
@@ -114,11 +118,10 @@ module.exports = function(crowi, app) {
       return next();
       return next();
     }
     }
 
 
-    if (config.crowi['security:basicName'] && config.crowi['security:basicSecret']) {
-      return basicAuth(
-        config.crowi['security:basicName'],
-        config.crowi['security:basicSecret'],
-      )(req, res, next);
+    const basicName = configManager.getConfig('crowi', 'security:basicName');
+    const basicSecret = configManager.getConfig('crowi', 'security:basicSecret');
+    if (basicName && basicSecret) {
+      return basicAuth(basicName, basicSecret)(req, res, next);
     }
     }
 
 
     next();
     next();

+ 3 - 2
src/server/crowi/index.js

@@ -6,6 +6,7 @@ const pkg = require('@root/package.json');
 const InterceptorManager = require('@commons/service/interceptor-manager');
 const InterceptorManager = require('@commons/service/interceptor-manager');
 const CdnResourcesService = require('@commons/service/cdn-resources-service');
 const CdnResourcesService = require('@commons/service/cdn-resources-service');
 const Xss = require('@commons/service/xss');
 const Xss = require('@commons/service/xss');
+
 const path = require('path');
 const path = require('path');
 
 
 const sep = path.sep;
 const sep = path.sep;
@@ -56,6 +57,7 @@ function Crowi(rootdir) {
     page: new (require(`${self.eventsDir}page`))(this),
     page: new (require(`${self.eventsDir}page`))(this),
     search: new (require(`${self.eventsDir}search`))(this),
     search: new (require(`${self.eventsDir}search`))(this),
     bookmark: new (require(`${self.eventsDir}bookmark`))(this),
     bookmark: new (require(`${self.eventsDir}bookmark`))(this),
+    tag: new (require(`${self.eventsDir}tag`))(this),
   };
   };
 }
 }
 
 
@@ -217,12 +219,11 @@ Crowi.prototype.getIo = function() {
 Crowi.prototype.scanRuntimeVersions = function() {
 Crowi.prototype.scanRuntimeVersions = function() {
   const self = this;
   const self = this;
 
 
-
   const check = require('check-node-version');
   const check = require('check-node-version');
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
     check((err, result) => {
     check((err, result) => {
       if (err) {
       if (err) {
-        reject();
+        reject(err);
       }
       }
       self.runtimeVersions = result;
       self.runtimeVersions = result;
       resolve();
       resolve();

+ 13 - 0
src/server/events/tag.js

@@ -0,0 +1,13 @@
+const util = require('util');
+const events = require('events');
+
+function TagEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(TagEvent, events.EventEmitter);
+
+TagEvent.prototype.onUpdate = function(tag) { };
+
+module.exports = TagEvent;

+ 16 - 0
src/server/models/page-tag-relation.js

@@ -37,6 +37,22 @@ class PageTagRelation {
     }
     }
   }
   }
 
 
+  static async createTagListWithCount(option) {
+    const opt = option || {};
+    const sortOpt = opt.sortOpt || {};
+    const offset = opt.offset || 0;
+    const limit = opt.limit || 50;
+
+    const tags = await this.aggregate()
+      .group({ _id: '$relatedTag', count: { $sum: 1 } })
+      .sort(sortOpt);
+
+    const list = tags.slice(offset, offset + limit);
+    const totalCount = tags.length;
+
+    return { list, totalCount };
+  }
+
 }
 }
 
 
 module.exports = function() {
 module.exports = function() {

+ 12 - 3
src/server/models/page.js

@@ -353,6 +353,12 @@ module.exports = function(crowi) {
     return (this.latestRevision == this.revision._id.toString());
     return (this.latestRevision == this.revision._id.toString());
   };
   };
 
 
+  pageSchema.methods.findRelatedTagsById = async function() {
+    const PageTagRelation = mongoose.model('PageTagRelation');
+    const relations = await PageTagRelation.find({ relatedPage: this._id }).populate('relatedTag');
+    return relations.map((relation) => { return relation.relatedTag.name });
+  };
+
   pageSchema.methods.isUpdatable = function(previousRevision) {
   pageSchema.methods.isUpdatable = function(previousRevision) {
     const revision = this.latestRevision || this.revision;
     const revision = this.latestRevision || this.revision;
     // comparing ObjectId with string
     // comparing ObjectId with string
@@ -573,7 +579,7 @@ module.exports = function(crowi) {
       /\s+\/\s+/, // avoid miss in renaming
       /\s+\/\s+/, // avoid miss in renaming
       /.+\/edit$/,
       /.+\/edit$/,
       /.+\.md$/,
       /.+\.md$/,
-      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments)(\/.*|$)/,
+      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags)(\/.*|$)/,
     ];
     ];
 
 
     let isCreatable = true;
     let isCreatable = true;
@@ -910,8 +916,9 @@ module.exports = function(crowi) {
     return assignDecendantsTemplate(decendantsTemplates, newPath);
     return assignDecendantsTemplate(decendantsTemplates, newPath);
   };
   };
 
 
-  const fetchTemplate = (templates, templatePath) => {
+  const fetchTemplate = async(templates, templatePath) => {
     let templateBody;
     let templateBody;
+    let templateTags;
     /**
     /**
      * get children template
      * get children template
      * __tempate: applicable only to immediate decendants
      * __tempate: applicable only to immediate decendants
@@ -926,12 +933,14 @@ module.exports = function(crowi) {
 
 
     if (childrenTemplate) {
     if (childrenTemplate) {
       templateBody = childrenTemplate.revision.body;
       templateBody = childrenTemplate.revision.body;
+      templateTags = await childrenTemplate.findRelatedTagsById();
     }
     }
     else if (decendantsTemplate) {
     else if (decendantsTemplate) {
       templateBody = decendantsTemplate.revision.body;
       templateBody = decendantsTemplate.revision.body;
+      templateTags = await decendantsTemplate.findRelatedTagsById();
     }
     }
 
 
-    return templateBody;
+    return { templateBody, templateTags };
   };
   };
 
 
   /**
   /**

+ 2 - 1
src/server/models/tag.js

@@ -32,7 +32,8 @@ class Tag {
 
 
 }
 }
 
 
-module.exports = function() {
+module.exports = function(crowi) {
+  Tag.crowi = crowi;
   schema.loadClass(Tag);
   schema.loadClass(Tag);
   const model = mongoose.model('Tag', schema);
   const model = mongoose.model('Tag', schema);
   return model;
   return model;

+ 12 - 6
src/server/routes/admin.js

@@ -906,7 +906,11 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
-  actions.api.securitySetting = function(req, res) {
+  actions.api.securitySetting = async function(req, res) {
+    if (!req.form.isValid) {
+      return res.json({ status: false, message: req.form.errors.join('\n') });
+    }
+
     const form = req.form.settingForm;
     const form = req.form.settingForm;
     const config = crowi.getConfig();
     const config = crowi.getConfig();
     const isPublicWikiOnly = Config.isPublicWikiOnly(config);
     const isPublicWikiOnly = Config.isPublicWikiOnly(config);
@@ -924,12 +928,14 @@ module.exports = function(crowi, app) {
       }
       }
     }
     }
 
 
-    if (req.form.isValid) {
-      debug('form content', form);
-      return saveSetting(req, res, form);
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', form);
+      return res.json({ status: true });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json({ status: false });
     }
     }
-
-    return res.json({ status: false, message: req.form.errors.join('\n') });
   };
   };
 
 
   actions.api.securityPassportLdapSetting = function(req, res) {
   actions.api.securityPassportLdapSetting = function(req, res) {

+ 43 - 0
src/server/routes/apiv3/docs.js

@@ -0,0 +1,43 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:docs'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+// paths to scan
+const APIS = [
+  'src/server/routes/apiv3/**/*.js',
+];
+
+module.exports = (crowi) => {
+
+  // skip if disabled
+  if (!crowi.configManager.getConfig('crowi', 'app:publishOpenAPI')) {
+    return router;
+  }
+
+  const swaggerJSDoc = require('swagger-jsdoc');
+  const swaggerDefinition = require('@root/config/swagger-definition');
+
+  // generate swagger spec
+  const options = {
+    swaggerDefinition,
+    apis: APIS,
+  };
+  const swaggerSpec = swaggerJSDoc(options);
+
+  // publish swagger spec
+  router.get('/swagger-spec.json', (req, res) => {
+    res.setHeader('Content-Type', 'application/json');
+    res.send(swaggerSpec);
+  });
+
+  // publish redoc
+  router.get('/', (req, res) => {
+    res.render('redoc');
+  });
+
+  return router;
+};

+ 35 - 0
src/server/routes/apiv3/healthcheck.js

@@ -8,7 +8,42 @@ const router = express.Router();
 
 
 const helmet = require('helmet');
 const helmet = require('helmet');
 
 
+/**
+ * @swagger
+ *  tags:
+ *    name: Healthcheck
+ */
+
 module.exports = (crowi) => {
 module.exports = (crowi) => {
+  /**
+   * @swagger
+   *
+   *  /healthcheck:
+   *    get:
+   *      tags: [Healthcheck]
+   *      description: Check whether the server is healthy or not
+   *      produces:
+   *        - application/json
+   *      parameters:
+   *        - name: connectToMiddlewares
+   *          in: query
+   *          description: Check also MongoDB and Elasticsearch
+   *          schema:
+   *            type: boolean
+   *      responses:
+   *        200:
+   *          description: Resources are available
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  mongo:
+   *                    type: string
+   *                    description: 'OK'
+   *                  esInfo:
+   *                    type: object
+   *                    description: A result of `client.info()` of Elasticsearch Info API
+   */
   router.get('/', helmet.noCache(), async(req, res) => {
   router.get('/', helmet.noCache(), async(req, res) => {
     const connectToMiddlewares = req.query.connectToMiddlewares;
     const connectToMiddlewares = req.query.connectToMiddlewares;
 
 

+ 6 - 1
src/server/routes/index.js

@@ -160,7 +160,7 @@ module.exports = function(crowi, app) {
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
   app.get('/me/apiToken'              , loginRequired(crowi, app) , me.apiToken);
   app.get('/me/apiToken'              , loginRequired(crowi, app) , me.apiToken);
-  app.post('/me'                      , form.me.user              , loginRequired(crowi, app) , me.index);
+  app.post('/me'                      , loginRequired(crowi, app) , csrf , form.me.user , me.index);
   // external-accounts
   // external-accounts
   if (Config.isEnabledPassport(config)) {
   if (Config.isEnabledPassport(config)) {
     app.get('/me/external-accounts'                         , loginRequired(crowi, app) , me.externalAccounts.list);
     app.get('/me/external-accounts'                         , loginRequired(crowi, app) , me.externalAccounts.list);
@@ -193,6 +193,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.create'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.create);
   app.post('/_api/pages.create'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequired(crowi, app) , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired(crowi, app, false) , page.api.get);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired(crowi, app, false) , page.api.get);
+  app.get('/_api/pages.exist'         , accessTokenParser , loginRequired(crowi, app, false) , page.api.exist);
   app.get('/_api/pages.updatePost', accessTokenParser, loginRequired(crowi, app, false), page.api.getUpdatePost);
   app.get('/_api/pages.updatePost', accessTokenParser, loginRequired(crowi, app, false), page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired(crowi, app, false) , page.api.getPageTag);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired(crowi, app, false) , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   // allow posting to guests because the client doesn't know whether the user logged in
@@ -202,7 +203,10 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.duplicate', accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
   app.post('/_api/pages.duplicate', accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
+  app.get('/tags'                     , loginRequired(crowi, app, false), tag.showPage);
+  app.get('/_api/tags.list'           , accessTokenParser, loginRequired(crowi, app, false), tag.api.list);
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
+  app.post('/_api/tags.update'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.update);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);
@@ -231,6 +235,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.saveOnHackmd' , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
   app.post('/_api/hackmd.saveOnHackmd' , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
 
   // API v3
   // API v3
+  app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
 
   app.get('/*/$'                   , loginRequired(crowi, app, false) , page.showPageWithEndOfSlash, page.notFound);
   app.get('/*/$'                   , loginRequired(crowi, app, false) , page.showPageWithEndOfSlash, page.notFound);

+ 4 - 2
src/server/routes/login.js

@@ -139,8 +139,10 @@ module.exports = function(crowi, app) {
   actions.register = function(req, res) {
   actions.register = function(req, res) {
     const googleAuth = require('../util/googleAuth')(crowi);
     const googleAuth = require('../util/googleAuth')(crowi);
 
 
-    // ログイン済みならさようなら
-    if (req.user) {
+    // redirect to '/' if both of these are true:
+    //  1. user has logged in
+    //  2. req.user is not username/email string (which is set by basic-auth-connect)
+    if (req.user != null && req.user instanceof Object) {
       return res.redirect('/');
       return res.redirect('/');
     }
     }
 
 

+ 32 - 4
src/server/routes/page.js

@@ -416,10 +416,13 @@ module.exports = function(crowi, app) {
       view = 'customlayout-selector/not_found';
       view = 'customlayout-selector/not_found';
 
 
       // retrieve templates
       // retrieve templates
-      let template = await Page.findTemplate(path);
-      if (template != null) {
-        template = replacePlaceholdersOfTemplate(template, req);
-        renderVars.template = template;
+      const template = await Page.findTemplate(path);
+
+      if (template.templateBody) {
+        const body = replacePlaceholdersOfTemplate(template.templateBody, req);
+        const tags = template.templateTags;
+        renderVars.template = body;
+        renderVars.templateTags = tags;
       }
       }
 
 
       // add scope variables by ancestor page
       // add scope variables by ancestor page
@@ -722,6 +725,29 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 
+  /**
+   * @api {get} /pages.exist Get if page exists
+   * @apiName GetPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} pages (stringified JSON)
+   */
+  api.exist = async function(req, res) {
+    const pagesAsObj = JSON.parse(req.query.pages || '{}');
+    const pagePaths = Object.keys(pagesAsObj);
+
+    await Promise.all(pagePaths.map(async(path) => {
+      // check page existence
+      const isExist = await Page.count({ path }) > 0;
+      pagesAsObj[path] = isExist;
+      return;
+    }));
+
+    const result = { pages: pagesAsObj };
+
+    return res.json(ApiResponse.success(result));
+  };
+
   /**
   /**
    * @api {get} /pages.getPageTag get page tags
    * @api {get} /pages.getPageTag get page tags
    * @apiName GetPageTag
    * @apiName GetPageTag
@@ -1074,10 +1100,12 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     await page.populateDataToShowRevision();
     await page.populateDataToShowRevision();
+    const originTags = await page.findRelatedTagsById();
 
 
     req.body.path = newPagePath;
     req.body.path = newPagePath;
     req.body.body = page.revision.body;
     req.body.body = page.revision.body;
     req.body.grant = page.grant;
     req.body.grant = page.grant;
+    req.body.pageTags = originTags;
 
 
     return api.create(req, res);
     return api.create(req, res);
   };
   };

+ 9 - 1
src/server/routes/search.js

@@ -69,7 +69,15 @@ module.exports = function(crowi, app) {
         scoreMap[esPage._id] = esPage._score;
         scoreMap[esPage._id] = esPage._score;
       }
       }
 
 
-      const findResult = await Page.findListByPageIds(esResult.data);
+      const ids = esResult.data.map((page) => { return page._id });
+      const findResult = await Page.findListByPageIds(ids);
+
+      // add tag data to result pages
+      findResult.pages.map((page) => {
+        const data = esResult.data.find((data) => { return page.id === data._id });
+        page._doc.tags = data._source.tag_names;
+        return page;
+      });
 
 
       result.meta = esResult.meta;
       result.meta = esResult.meta;
       result.totalCount = findResult.totalCount;
       result.totalCount = findResult.totalCount;

+ 74 - 0
src/server/routes/tag.js

@@ -1,12 +1,16 @@
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
 
 
   const Tag = crowi.model('Tag');
   const Tag = crowi.model('Tag');
+  const PageTagRelation = crowi.model('PageTagRelation');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const actions = {};
   const api = {};
   const api = {};
 
 
   actions.api = api;
   actions.api = api;
 
 
+  actions.showPage = function(req, res) {
+    return res.render('tags');
+  };
 
 
   /**
   /**
    * @api {get} /tags.search search tags
    * @api {get} /tags.search search tags
@@ -21,5 +25,75 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({ tags }));
     return res.json(ApiResponse.success({ tags }));
   };
   };
 
 
+  /**
+   * @api {post} /tags.update update tags on view-mode (not edit-mode)
+   * @apiName UpdateTag
+   * @apiGroup Tag
+   *
+   * @apiParam {String} PageId
+   * @apiParam {array} tags
+   */
+  api.update = async function(req, res) {
+    const Page = crowi.model('Page');
+    const tagEvent = crowi.event('tag');
+    const pageId = req.body.pageId;
+    const tags = req.body.tags;
+
+    try {
+      const page = await Page.findById(pageId);
+      await page.updateTags(tags);
+
+      tagEvent.emit('update', page, tags);
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+    return res.json(ApiResponse.success());
+  };
+
+  /**
+   * @api {get} /tags.list get tagnames and count pages relate each tag
+   * @apiName tagList
+   * @apiGroup Tag
+   *
+   * @apiParam {Number} limit
+   * @apiParam {Number} offset
+   */
+  api.list = async function(req, res) {
+    const limit = +req.query.limit || 50;
+    const offset = +req.query.offset || 0;
+    const sortOpt = { count: -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;
+
+      return res.json(ApiResponse.success(result));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+
   return actions;
   return actions;
 };
 };

+ 6 - 0
src/server/service/config-loader.js

@@ -116,6 +116,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null,
     default: null,
   },
   },
+  PUBLISH_OPEN_API: {
+    ns:      'crowi',
+    key:     'app:publishOpenAPI',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   MAX_FILE_SIZE: {
   MAX_FILE_SIZE: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'app:maxFileSize',
     key:     'app:maxFileSize',

+ 11 - 12
src/server/service/file-uploader/gridfs.js

@@ -5,18 +5,17 @@ const util = require('util');
 module.exports = function(crowi) {
 module.exports = function(crowi) {
   const lib = {};
   const lib = {};
   const COLLECTION_NAME = 'attachmentFiles';
   const COLLECTION_NAME = 'attachmentFiles';
-  const CHUNK_COLLECTION_NAME = 'attachmentFiles.chunks';
+  const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
 
   // instantiate mongoose-gridfs
   // instantiate mongoose-gridfs
-  const gridfs = require('mongoose-gridfs')({
-    collection: COLLECTION_NAME,
-    model: 'AttachmentFile',
-    mongooseConnection: mongoose.connection,
+  const { createModel } = require('mongoose-gridfs');
+  const AttachmentFile = createModel({
+    modelName: COLLECTION_NAME,
+    bucketName: COLLECTION_NAME,
+    connection: mongoose.connection,
   });
   });
-
-  // obtain a model
-  const AttachmentFile = gridfs.model;
-  const Chunks = mongoose.model('Chunks', gridfs.schema, CHUNK_COLLECTION_NAME);
+  // get Collection instance of chunk
+  const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
 
 
   // create promisified method
   // create promisified method
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
@@ -30,7 +29,7 @@ module.exports = function(crowi) {
 
 
     const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
     const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
 
 
-    AttachmentFile.unlinkById(attachmentFile._id, (error, unlinkedFile) => {
+    AttachmentFile.unlink({ _id: attachmentFile._id }, (error, unlinkedFile) => {
       if (error) {
       if (error) {
         throw new Error(error);
         throw new Error(error);
       }
       }
@@ -42,7 +41,7 @@ module.exports = function(crowi) {
    */
    */
   const getCollectionSize = () => {
   const getCollectionSize = () => {
     return new Promise((resolve, reject) => {
     return new Promise((resolve, reject) => {
-      Chunks.collection.stats((err, data) => {
+      chunkCollection.stats((err, data) => {
         if (err) {
         if (err) {
           // return 0 if not exist
           // return 0 if not exist
           if (err.errmsg.includes('not found')) {
           if (err.errmsg.includes('not found')) {
@@ -117,7 +116,7 @@ module.exports = function(crowi) {
     }
     }
 
 
     // return stream.Readable
     // return stream.Readable
-    return AttachmentFile.readById(attachmentFile._id);
+    return AttachmentFile.read({ _id: attachmentFile._id });
   };
   };
 
 
   return lib;
   return lib;

+ 13 - 2
src/server/util/search.js

@@ -84,11 +84,15 @@ SearchClient.prototype.registerUpdateEvent = function() {
   const pageEvent = this.crowi.event('page');
   const pageEvent = this.crowi.event('page');
   pageEvent.on('create', this.syncPageCreated.bind(this));
   pageEvent.on('create', this.syncPageCreated.bind(this));
   pageEvent.on('update', this.syncPageUpdated.bind(this));
   pageEvent.on('update', this.syncPageUpdated.bind(this));
+  pageEvent.on('updateTag', this.syncPageUpdated.bind(this));
   pageEvent.on('delete', this.syncPageDeleted.bind(this));
   pageEvent.on('delete', this.syncPageDeleted.bind(this));
 
 
   const bookmarkEvent = this.crowi.event('bookmark');
   const bookmarkEvent = this.crowi.event('bookmark');
   bookmarkEvent.on('create', this.syncBookmarkChanged.bind(this));
   bookmarkEvent.on('create', this.syncBookmarkChanged.bind(this));
   bookmarkEvent.on('delete', this.syncBookmarkChanged.bind(this));
   bookmarkEvent.on('delete', this.syncBookmarkChanged.bind(this));
+
+  const tagEvent = this.crowi.event('tag');
+  tagEvent.on('update', this.syncTagChanged.bind(this));
 };
 };
 
 
 SearchClient.prototype.shouldIndexed = function(page) {
 SearchClient.prototype.shouldIndexed = function(page) {
@@ -387,7 +391,7 @@ SearchClient.prototype.search = async function(query) {
 
 
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
   // getting path by default is almost for debug
   // getting path by default is almost for debug
-  let fields = ['path', 'bookmark_count'];
+  let fields = ['path', 'bookmark_count', 'tag_names'];
   if (option) {
   if (option) {
     fields = option.fields || fields;
     fields = option.fields || fields;
   }
   }
@@ -408,7 +412,7 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
 };
 };
 
 
 SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
 SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
-  let fields = ['path', 'bookmark_count'];
+  let fields = ['path', 'bookmark_count', 'tag_names'];
   if (option) {
   if (option) {
     fields = option.fields || fields;
     fields = option.fields || fields;
   }
   }
@@ -849,4 +853,11 @@ SearchClient.prototype.syncBookmarkChanged = async function(pageId) {
     .catch((err) => { return logger.error('ES Error', err) });
     .catch((err) => { return logger.error('ES Error', err) });
 };
 };
 
 
+SearchClient.prototype.syncTagChanged = async function(page) {
+  this.updatePages([page])
+    .then((res) => { return debug('ES Response', res) })
+    .catch((err) => { return logger.error('ES Error', err) });
+};
+
+
 module.exports = SearchClient;
 module.exports = SearchClient;

+ 1 - 1
src/server/views/admin/app.html

@@ -2,7 +2,7 @@
 
 
 {% block html_title %}{{ customTitle(t('App settings')) }}{% endblock %}
 {% block html_title %}{{ customTitle(t('App settings')) }}{% endblock %}
 
 
-{% block head_warn %} {# remove including block for './widget/alert_siteurl_undefined.html' #}
+{% block head_warn_alert_siteurl_undefined %} {# remove including block for './widget/alert_siteurl_undefined.html' #}
 {% endblock %}
 {% endblock %}
 
 
 {% block content_header %}
 {% block content_header %}

+ 2 - 2
src/server/views/admin/customize.html

@@ -282,7 +282,7 @@
           </div>
           </div>
 
 
           <div class="form-group">
           <div class="form-group">
-            <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.recent_created_page_num") }}</label>
+            <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.recent_created__n_draft_num_desc") }}</label>
             <div class="col-xs-5">
             <div class="col-xs-5">
               <select class="form-control selectpicker" name="settingForm[customize:showRecentCreatedNumber]" value="{{ settingForm['customize:showRecentCreatedNumber'] }}">
               <select class="form-control selectpicker" name="settingForm[customize:showRecentCreatedNumber]" value="{{ settingForm['customize:showRecentCreatedNumber'] }}">
                 <option value="10" {% if 10 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>10</option>
                 <option value="10" {% if 10 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>10</option>
@@ -291,7 +291,7 @@
               </select>
               </select>
 
 
               <p class="help-block">
               <p class="help-block">
-                {{ t("customize_page.recent_created_page_num_desc") }}
+                {{ t("customize_page.recently_created_n_draft_num_desc") }}
               </p>
               </p>
             </div>
             </div>
           </div>
           </div>

+ 16 - 16
src/server/views/admin/importer.html

@@ -41,7 +41,7 @@
 
 
       <!-- esa Importer management forms -->
       <!-- esa Importer management forms -->
       <form action="/_api/admin/settings/importerEsa" method="post" class="form-horizontal" id="importerSettingFormEsa" role="form"
       <form action="/_api/admin/settings/importerEsa" method="post" class="form-horizontal" id="importerSettingFormEsa" role="form"
-          data-success-messaage="更新しました">
+          data-success-messaage="{{ ('Updated') }}">
         <fieldset>
         <fieldset>
           <legend>{{ t('importer_management.import_from', 'esa.io') }}</legend>
           <legend>{{ t('importer_management.import_from', 'esa.io') }}</legend>
           <table class="table table-bordered table-mapping">
           <table class="table table-bordered table-mapping">
@@ -54,17 +54,17 @@
             </thead>
             </thead>
             <tbody>
             <tbody>
               <tr>
               <tr>
-                <th>記事</th>
+                <th>{{ t('Article') }}</th>
                 <th><i class="icon-arrow-right-circle text-success"></i></th>
                 <th><i class="icon-arrow-right-circle text-success"></i></th>
-                <th>ページ</th>
+                <th>{{ t('Page') }}</th>
               </tr>
               </tr>
               <tr>
               <tr>
-                <th>カテゴリー</th>
+                <th>{{ t('Category') }}</th>
                 <th><i class="icon-arrow-right-circle text-success"></i></th>
                 <th><i class="icon-arrow-right-circle text-success"></i></th>
-                <th>ページパス</th>
+                <th>{{ t('Page Path') }}</th>
               </tr>
               </tr>
               <tr>
               <tr>
-                <th>ユーザー</th>
+                <th>{{ t('User') }}</th>
                 <th></th>
                 <th></th>
                 <th>(TBD)</th>
                 <th>(TBD)</th>
               </tr>
               </tr>
@@ -72,7 +72,7 @@
           </table>
           </table>
           <div class="well well-sm mb-0 small">
           <div class="well well-sm mb-0 small">
             <ul>
             <ul>
-              <li>既に GROWI 側に同名のページが存在する場合、そのページはスキップされます</li>
+              <li>{{ t("importer_management.page_skip") }}</li>
             </ul>
             </ul>
           </div>
           </div>
           <div class="form-group">
           <div class="form-group">
@@ -114,7 +114,7 @@
 
 
       <!-- qiita:team Importer management forms -->
       <!-- qiita:team Importer management forms -->
       <form action="/_api/admin/settings/importerQiita" method="post" class="form-horizontal mt-5" id="importerSettingFormQiita" role="form"
       <form action="/_api/admin/settings/importerQiita" method="post" class="form-horizontal mt-5" id="importerSettingFormQiita" role="form"
-          data-success-messaage="更新しました">
+          data-success-messaage="Updated">
         <fieldset>
         <fieldset>
           <legend>{{ t('importer_management.import_from', 'Qiita:Team') }}</legend>
           <legend>{{ t('importer_management.import_from', 'Qiita:Team') }}</legend>
           <table class="table table-bordered table-mapping">
           <table class="table table-bordered table-mapping">
@@ -127,22 +127,22 @@
             </thead>
             </thead>
             <tbody>
             <tbody>
               <tr>
               <tr>
-                <th>記事</th>
+                <th>{{ t('Article') }}</th>
                 <th><i class="icon-arrow-right-circle text-success"></i></th>
                 <th><i class="icon-arrow-right-circle text-success"></i></th>
-                <th>ページ</th>
+                <th>{{ t('Page') }}</th>
               </tr>
               </tr>
               <tr>
               <tr>
-                <th>タグ</th>
+                <th>{{ t('Tag')}}</th>
                 <th></th>
                 <th></th>
                 <th>-</th>
                 <th>-</th>
               </tr>
               </tr>
               <tr>
               <tr>
-                <th>ディレクトリ階層タグ</th>
+                <th>{{ t("importer_management.Directory_hierarchy_tag") }}</th>
                 <th></th>
                 <th></th>
                 <th>(TBD)</th>
                 <th>(TBD)</th>
               </tr>
               </tr>
               <tr>
               <tr>
-                <th>ユーザー</th>
+                <th>{{ t('User') }}</th>
                 <th></th>
                 <th></th>
                 <th>(TBD)</th>
                 <th>(TBD)</th>
               </tr>
               </tr>
@@ -150,7 +150,7 @@
           </table>
           </table>
           <div class="well well-sm mb-0 small">
           <div class="well well-sm mb-0 small">
             <ul>
             <ul>
-              <li>既に GROWI 側に同名のページが存在する場合、そのページはスキップされます</li>
+              <li>{{ t("importer_management.page_skip") }}</li>
             </ul>
             </ul>
           </div>
           </div>
           <div class="form-group">
           <div class="form-group">
@@ -223,7 +223,7 @@
   /**
   /**
    * Post form data and process UI
    * Post form data and process UI
    */
    */
-  function postData(form, button, action, success_msg = "成功しました", error_msg = "エラーが発生しました") {
+  function postData(form, button, action, success_msg = "Success", error_msg = " {{ t('Error occurred') }} " ) {
     var id = form.attr('id');
     var id = form.attr('id');
     button.attr('disabled', 'disabled');
     button.attr('disabled', 'disabled');
     var jqxhr = $.post(action, form.serialize(), function(data)
     var jqxhr = $.post(action, form.serialize(), function(data)
@@ -236,7 +236,7 @@
         }
         }
       })
       })
       .fail(function() {
       .fail(function() {
-        showMessage(id, "エラーが発生しました", 'danger');
+        showMessage(id, "{{ t('Error occurred') }}", 'danger');
       })
       })
       .always(function() {
       .always(function() {
         button.prop('disabled', false);
         button.prop('disabled', false);

+ 2 - 2
src/server/views/admin/security.html

@@ -44,11 +44,11 @@
             <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">{{ t('Basic authentication') }}</label>
             <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">{{ t('Basic authentication') }}</label>
             <div class="col-xs-3">
             <div class="col-xs-3">
               <label for="">ID</label>
               <label for="">ID</label>
-              <input class="form-control" type="text" name="settingForm[security:basicName]"   value="{{ settingForm['security:basicName']|default('') }}" {% if not isAclEnabled  %}readonly{% endif%}>
+              <input class="form-control" type="text" name="settingForm[security:basicName]" value="{{ settingForm['security:basicName']|default('') }}" autocomplete="nope" {% if not isAclEnabled  %}readonly{% endif%}>
             </div>
             </div>
             <div class="col-xs-3">
             <div class="col-xs-3">
               <label for="">{{ t('Password') }}</label>
               <label for="">{{ t('Password') }}</label>
-              <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}" {% if not isAclEnabled  %}readonly{% endif%}>
+              <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}" autocomplete="nope" {% if not isAclEnabled  %}readonly{% endif%}>
             </div>
             </div>
             <div class="col-xs-offset-3 col-xs-9">
             <div class="col-xs-offset-3 col-xs-9">
               <p class="help-block small">
               <p class="help-block small">

+ 0 - 4
src/server/views/layout-crowi/base/layout.html

@@ -7,10 +7,6 @@
   {{ cdnScriptTag('highlight-addons') }}
   {{ cdnScriptTag('highlight-addons') }}
 {% endblock %}
 {% endblock %}
 
 
-{% block head_warn %}
-  {% include '../../widget/alert_siteurl_undefined.html' %}
-{% endblock %}
-
 {% block layout_main %}
 {% block layout_main %}
 <div class="container-fluid">
 <div class="container-fluid">
 
 

+ 3 - 1
src/server/views/layout-crowi/forbidden.html

@@ -10,7 +10,9 @@
       <div>
       <div>
         <div>
         <div>
           <h1 class="title" id="revision-path"></h1>
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if page and not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
         </div>
       </div>
       </div>
 
 

+ 3 - 1
src/server/views/layout-crowi/not_creatable.html

@@ -10,7 +10,9 @@
       <div>
       <div>
         <div>
         <div>
           <h1 class="title" id="revision-path"></h1>
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if page and not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
         </div>
       </div>
       </div>
 
 

+ 3 - 1
src/server/views/layout-crowi/not_found.html

@@ -10,7 +10,9 @@
       <div>
       <div>
         <div>
         <div>
           <h1 class="title" id="revision-path"></h1>
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
         </div>
       </div>
       </div>
 
 

+ 3 - 1
src/server/views/layout-crowi/page.html

@@ -11,7 +11,9 @@
       <div class="d-flex align-items-center">
       <div class="d-flex align-items-center">
         <div class="title-container">
         <div class="title-container">
           <h1 class="title" id="revision-path"></h1>
           <h1 class="title" id="revision-path"></h1>
-          <div id="revision-url" class="url-line"></div>
+          {% if page and not forbidden and not isTrashPage() %}
+            <div id="tag-label"></div>
+          {% endif %}
         </div>
         </div>
         {% include '../widget/header-buttons.html' %}
         {% include '../widget/header-buttons.html' %}
       </div>
       </div>

+ 4 - 4
src/server/views/layout-crowi/page_list.html

@@ -16,10 +16,10 @@
 
 
     <div class="d-flex align-items-center">
     <div class="d-flex align-items-center">
       <div class="title-container">
       <div class="title-container">
-        <div class="d-flex">
-          <h1 class="title" id="revision-path"></h1>
-        </div>
-        <div id="revision-url" class="url-line"></div>
+        <h1 class="title" id="revision-path"></h1>
+        {% if page and not forbidden and not isTrashPage() %}
+          <div id="tag-label"></div>
+        {% endif %}
       </div>
       </div>
       {% include '../widget/header-buttons.html' %}
       {% include '../widget/header-buttons.html' %}
     </div>
     </div>

+ 0 - 4
src/server/views/layout-growi/base/layout.html

@@ -5,10 +5,6 @@
   {{ cdnScriptTag('highlight-addons') }}
   {{ cdnScriptTag('highlight-addons') }}
 {% endblock %}
 {% endblock %}
 
 
-{% block head_warn %}
-  {% include '../../widget/alert_siteurl_undefined.html' %}
-{% endblock %}
-
 {% block layout_main %}
 {% block layout_main %}
 <div class="container-fluid">
 <div class="container-fluid">
 
 

+ 3 - 3
src/server/views/layout-growi/widget/header.html

@@ -8,9 +8,9 @@
       </div>
       </div>
       <div class="title-container">
       <div class="title-container">
         <h1 class="title" id="revision-path"></h1>
         <h1 class="title" id="revision-path"></h1>
-        {# [TODO] GC-1391 activate #}
-        {# <h1 class="title" id="tag-viewer"></h1> #}
-        <div id="revision-url" class="url-line"></div>
+        {% if not forbidden and not isTrashPage() %}
+          <div id="tag-label"></div>
+        {% endif %}
       </div>
       </div>
       {% if page %}
       {% if page %}
       {% include '../../widget/header-buttons.html' %}
       {% include '../../widget/header-buttons.html' %}

+ 1 - 5
src/server/views/layout-kibela/base/layout.html

@@ -5,10 +5,6 @@
   {{ cdnScriptTag('highlight-addons') }}
   {{ cdnScriptTag('highlight-addons') }}
 {% endblock %}
 {% endblock %}
 
 
-{% block head_warn %}
-  {% include '../../widget/alert_siteurl_undefined.html' %}
-{% endblock %}
-
 {% block layout_main %}
 {% block layout_main %}
 <div class="container-fluid">
 <div class="container-fluid">
 
 
@@ -16,7 +12,7 @@
 
 
     <div id="main" class="main col-md-7 col-xs-12 kibela-block bg-white m-t-30 round-corner {% if page %}{{ css.grant(page) }}{% endif %}{% block main_css_class %}{% endblock %}">
     <div id="main" class="main col-md-7 col-xs-12 kibela-block bg-white m-t-30 round-corner {% if page %}{{ css.grant(page) }}{% endif %}{% block main_css_class %}{% endblock %}">
       <div class="row bg-title">
       <div class="row bg-title">
-        <div class="col-xs-12 ">
+        <div class="col-xs-12 header-container">
           {% block content_header %} {% endblock %}
           {% block content_header %} {% endblock %}
         </div>
         </div>
       </div>
       </div>

Some files were not shown because too many files changed in this diff