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

Merge pull request #7317 from weseek/master

Release v6.0.6
Yuki Takei 3 лет назад
Родитель
Сommit
85dad84b0e
100 измененных файлов с 1109 добавлено и 1524 удалено
  1. 5 2
      .github/ISSUE_TEMPLATE/config.yml
  2. 0 25
      .github/ISSUE_TEMPLATE/user-request.md
  3. 3 0
      .github/workflows/ci-app-prod.yml
  4. 2 0
      .github/workflows/ci-app.yml
  5. 1 2
      .github/workflows/draft-release.yml
  6. 1 1
      .github/workflows/pr-to-master.yml
  7. 1 1
      .github/workflows/release-slackbot-proxy.yml
  8. 85 0
      bin/data-migrations/v6/README.md
  9. 57 0
      bin/data-migrations/v6/src/migration.js
  10. 75 0
      bin/data-migrations/v6/src/processor.js
  11. 1 1
      lerna.json
  12. 1 1
      package.json
  13. 2 1
      packages/app/config/next-i18next.config.ts
  14. 1 0
      packages/app/docker/Dockerfile
  15. 14 15
      packages/app/package.json
  16. 1 13
      packages/app/public/static/locales/en_US/admin.json
  17. 1 1
      packages/app/public/static/locales/en_US/commons.json
  18. 0 1
      packages/app/public/static/locales/en_US/translation.json
  19. 1 13
      packages/app/public/static/locales/ja_JP/admin.json
  20. 2 2
      packages/app/public/static/locales/ja_JP/commons.json
  21. 0 1
      packages/app/public/static/locales/ja_JP/translation.json
  22. 1 13
      packages/app/public/static/locales/zh_CN/admin.json
  23. 1 1
      packages/app/public/static/locales/zh_CN/commons.json
  24. 0 1
      packages/app/public/static/locales/zh_CN/translation.json
  25. 0 42
      packages/app/src/client/legacy/crowi-presentation.js
  26. 0 1
      packages/app/src/client/services/AdminAppContainer.js
  27. 0 35
      packages/app/src/client/services/AdminMarkDownContainer.js
  28. 5 11
      packages/app/src/client/services/layout.ts
  29. 2 2
      packages/app/src/client/services/page-operation.ts
  30. 2 2
      packages/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  31. 2 2
      packages/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  32. 60 0
      packages/app/src/client/util/locale-utils.ts
  33. 0 107
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  34. 0 379
      packages/app/src/client/util/reveal/plugins/markdown.js
  35. 0 5
      packages/app/src/client/util/smooth-scroll.ts
  36. 1 1
      packages/app/src/client/util/toastr.ts
  37. 1 1
      packages/app/src/components/Admin/App/AwsSetting.tsx
  38. 9 8
      packages/app/src/components/Admin/App/ConfirmModal.tsx
  39. 3 3
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  40. 0 8
      packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx
  41. 0 143
      packages/app/src/components/Admin/MarkdownSetting/PresentationForm.jsx
  42. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  43. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  44. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  45. 1 2
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  46. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  47. 52 23
      packages/app/src/components/Comments.tsx
  48. 2 5
      packages/app/src/components/ContentLinkButtons.tsx
  49. 13 46
      packages/app/src/components/DescendantsPageList.tsx
  50. 2 8
      packages/app/src/components/DescendantsPageListModal.tsx
  51. 1 2
      packages/app/src/components/Fab.tsx
  52. 0 14
      packages/app/src/components/Invited.module.scss
  53. 0 11
      packages/app/src/components/Layout/BasicLayout.tsx
  54. 0 5
      packages/app/src/components/Layout/NoLoginLayout.module.scss
  55. 1 1
      packages/app/src/components/Me/BasicInfoSettings.tsx
  56. 12 17
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  57. 0 3
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  58. 1 2
      packages/app/src/components/NotAvailable.tsx
  59. 11 4
      packages/app/src/components/NotFoundPage.tsx
  60. 0 102
      packages/app/src/components/Page/PageContents.tsx
  61. 39 0
      packages/app/src/components/Page/PageContentsUtilities.tsx
  62. 79 38
      packages/app/src/components/Page/PageView.tsx
  63. 22 60
      packages/app/src/components/Page/RevisionLoader.tsx
  64. 1 1
      packages/app/src/components/PageAlert/PageAlerts.tsx
  65. 1 1
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  66. 3 2
      packages/app/src/components/PageComment/Comment.module.scss
  67. 7 5
      packages/app/src/components/PageComment/Comment.tsx
  68. 8 0
      packages/app/src/components/PageDeleteModal.tsx
  69. 24 14
      packages/app/src/components/PageEditor.tsx
  70. 5 3
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  71. 1 5
      packages/app/src/components/PageEditor/Preview.tsx
  72. 11 10
      packages/app/src/components/PageEditorByHackmd.tsx
  73. 0 30
      packages/app/src/components/PagePresentationModal.jsx
  74. 25 8
      packages/app/src/components/PagePresentationModal.module.scss
  75. 84 0
      packages/app/src/components/PagePresentationModal.tsx
  76. 7 10
      packages/app/src/components/PageSideContents.tsx
  77. 3 2
      packages/app/src/components/PageStatusAlert.tsx
  78. 1 2
      packages/app/src/components/PageTimeline.tsx
  79. 8 8
      packages/app/src/components/PrivateLegacyPages.tsx
  80. 2 4
      packages/app/src/components/PutbackPageModal.jsx
  81. 14 1
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  82. 13 14
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  83. 1 2
      packages/app/src/components/SearchPage.tsx
  84. 2 2
      packages/app/src/components/SearchPage/SearchControl.tsx
  85. 2 5
      packages/app/src/components/SearchPage/SearchPageBase.tsx
  86. 24 29
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  87. 15 19
      packages/app/src/components/SearchPage/SearchResultList.tsx
  88. 0 64
      packages/app/src/components/ShareLink/ShareLinkPageContents.tsx
  89. 124 0
      packages/app/src/components/ShareLink/ShareLinkPageView.tsx
  90. 6 4
      packages/app/src/components/Sidebar/InfiniteScroll.tsx
  91. 22 26
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  92. 12 11
      packages/app/src/components/Sidebar/RecentChanges.tsx
  93. 8 2
      packages/app/src/components/Sidebar/SidebarNav.tsx
  94. 2 1
      packages/app/src/components/StickyStretchableScroller.tsx
  95. 24 6
      packages/app/src/components/TrashPageList.tsx
  96. 1 1
      packages/app/src/interfaces/revision.ts
  97. 1 0
      packages/app/src/interfaces/services/renderer.ts
  98. 36 0
      packages/app/src/migrations/20230213090921-remove-presentation-configurations.js
  99. 28 25
      packages/app/src/pages/[[...path]].page.tsx
  100. 2 12
      packages/app/src/pages/_app.page.tsx

+ 5 - 2
.github/ISSUE_TEMPLATE/config.yml

@@ -1,5 +1,8 @@
 blank_issues_enabled: false
 blank_issues_enabled: false
 contact_links:
 contact_links:
-  - name: Question or Suggestions
+  - name: User request or Suggestions
+    url: https://github.com/weseek/growi/discussions
+    about: If you have feature requests or suggestions, you can create a new discussion and consider it with the community.
+  - name: Questions
     url: https://growi-slackin.weseek.co.jp/
     url: https://growi-slackin.weseek.co.jp/
-    about: If you have questions or suggestions, you can join our Slack team and talk about anything, anytime.
+    about: If you have questions, you can join our Slack team and talk about anything, anytime.

+ 0 - 25
.github/ISSUE_TEMPLATE/user-request.md

@@ -1,25 +0,0 @@
----
-name: User request
-about: Suggest an idea for this project
-title: 'Request:'
-labels: user requests
----
-
-
-## Informations
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. 
-
-e.g. I'm having trouble getting immediate access to information
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to realization.
-
-e.g. It's good if there is a space where everyone can access information in common.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-- [ ] Custom Page In Sidebar
-  - [ ] Can be edited in `/Sidebar` path
-  - [ ] Can be described with md like a page

+ 3 - 0
.github/workflows/ci-app-prod.yml

@@ -14,6 +14,8 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/preset-themes/**
+      - packages/presentation/**
       - packages/remark-*/**
       - packages/remark-*/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
@@ -31,6 +33,7 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/preset-themes/**
       - packages/remark-*/**
       - packages/remark-*/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**

+ 2 - 0
.github/workflows/ci-app.yml

@@ -14,6 +14,8 @@ on:
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/preset-themes/**
+      - packages/presentation/**
       - packages/remark-*/**
       - packages/remark-*/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**

+ 1 - 2
.github/workflows/draft-release.yml

@@ -55,9 +55,8 @@ jobs:
           RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
           RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
           echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
           echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
 
 
-      # See: https://github.com/bakunyo/git-pr-release-action/issues/15, https://github.com/samunohito/SimpleVolumeMixer/commit/2059044c71236509466cf9b1bb2d56d515274938
       - name: Create/Update Pull Request
       - name: Create/Update Pull Request
-        uses: bakunyo/git-pr-release-action@281e1fe424fac01f3992542266805e4202a22fe0
+        uses: bakunyo/git-pr-release-action@master
         env:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current

+ 1 - 1
.github/workflows/pr-to-master.yml

@@ -36,7 +36,7 @@ jobs:
         !startsWith( github.head_ref, 'dependabot/' ))
         !startsWith( github.head_ref, 'dependabot/' ))
 
 
     steps:
     steps:
-      - uses: amannn/action-semantic-pull-request@v4.2.0
+      - uses: amannn/action-semantic-pull-request@v5.0.2
         with:
         with:
           types: |
           types: |
             feat
             feat

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -58,7 +58,7 @@ jobs:
       uses: docker/setup-buildx-action@v2
       uses: docker/setup-buildx-action@v2
 
 
     - name: Build and push
     - name: Build and push
-      uses: docker/build-push-action@v2
+      uses: docker/build-push-action@v4
       with:
       with:
         context: .
         context: .
         file: ./packages/slackbot-proxy/docker/Dockerfile
         file: ./packages/slackbot-proxy/docker/Dockerfile

+ 85 - 0
bin/data-migrations/v6/README.md

@@ -0,0 +1,85 @@
+# Migration to v6 from v5
+
+> **Warning**
+> **Migration in this way is applied only to the latest revision. Past revisions are not applied.**
+
+## Usage
+```
+git clone https://github.com/weseek/growi
+cd growi/bin/data-migrations/v6
+
+NETWORK=growi_devcontainer_default \
+MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
+docker run --rm \
+  --network $NETWORK \
+  -v "$(pwd)"/src:/opt \
+  -w /opt \
+  -e MIGRATION_TYPE=v6 \
+  mongo:6.0 \
+  /bin/mongosh $MONGO_URI migration.js
+```
+
+## Variables
+| Variable              | Description                                                                    | Default |
+| --------------------- | ------------------------------------------------------------------------------ | ------- |
+| NETWORK     | Network in docker compose of MongoDB server                                                         | -       |
+| MONGO_URI| URI that can connect to MongoDB                                                     | -       |
+
+## Environment variables
+### Required
+
+| Variable              | Description                                                                    | Default |
+| --------------------- | ------------------------------------------------------------------------------ | ------- |
+| MIGRATION_TYPE     | Migrated notation                                                        | -       |
+
+The value of `MIGRATION_TYPE` is one of the following.
+- `v6-drawio`: Migration for Draw.io notation only([
+reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-draw-io-diagrams-net-%E8%A8%98%E6%B3%95))
+- `v6-plantuml`: Migration for PlantUML notation only([
+reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-plantuml-%E8%A8%98%E6%B3%95))
+- `v6-tsv`: Migration for table notation by TSV only([
+reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-csv-tsv-%E3%81%AB%E3%82%88%E3%82%8B%E3%83%86%E3%83%BC%E3%83%95%E3%82%99%E3%83%AB%E6%8F%8F%E7%94%BB%E8%A8%98%E6%B3%95))
+- `v6-csv`: Migration for table notation by CSV only([
+reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-csv-tsv-%E3%81%AB%E3%82%88%E3%82%8B%E3%83%86%E3%83%BC%E3%83%95%E3%82%99%E3%83%AB%E6%8F%8F%E7%94%BB%E8%A8%98%E6%B3%95))
+- `v6-bracketlink`: Migration for only page links within GROWI([
+reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E6%9C%AA%E5%AE%9F%E8%A3%85-%E5%BB%83%E6%AD%A2%E6%A4%9C%E8%A8%8E%E4%B8%AD-growi-%E7%8B%AC%E8%87%AA%E8%A8%98%E6%B3%95%E3%81%AE%E3%83%98%E3%82%9A%E3%83%BC%E3%82%B7%E3%82%99%E3%83%AA%E3%83%B3%E3%82%AF))
+- `v6`: Migration for all the above notations
+- `custom`: You can define your own processors and apply them to `revision` (see "Advanced" below for details)
+
+### Optional
+
+| Variable              | Description                                                                    | Default |
+| --------------------- | ------------------------------------------------------------------------------ | ------- |
+| BATCH_SIZE     | Number of revisions to be processed at one time(revision)                                                         | 100       |
+| BATCH_INTERVAL| Interval after batch processing(ms)                                                     | 3000       |
+
+※The `BATCH_INTERVAL` is for server load control. If you don't mind the load of the MongoDB, there is no problem to reduce it.
+
+## Advanced
+
+By creating a function in `growi/bin/data-migrations/v6/src/processor.js` that replaces a specific regular expression, you can replace all specific strings in the latest revisions for all pages.
+
+The following function replaces the string `foo` with the string `bar`.
+
+``` javascript
+function customProcessor(body) {
+  var fooRegExp = /foo/g; // foo regex
+  return body.replace(fooRegExp, 'bar'); // replace to bar
+}
+```
+
+By passing `custom` in the environment variable `MIGRATION_TYPE` and executing it, you can apply the `customProcessor` to all the latest `revisions`.
+```
+git clone https://github.com/weseek/growi
+cd growi/bin/data-migrations/v6
+
+NETWORK=growi_devcontainer_default \
+MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
+docker run --rm \
+  --network $NETWORK \
+  -v "$(pwd)"/src:/opt \
+  -w /opt \
+  -e MIGRATION_TYPE=custom \
+  mongo:6.0 \
+  /bin/mongosh $MONGO_URI migration.js
+```

+ 57 - 0
bin/data-migrations/v6/src/migration.js

@@ -0,0 +1,57 @@
+
+/* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid, import/extensions */
+// ignore lint error because this file is js as mongoshell
+
+var pagesCollection = db.getCollection('pages');
+var revisionsCollection = db.getCollection('revisions');
+
+var getProcessorArray = require('./processor.js');
+
+var migrationType = process.env.MIGRATION_TYPE;
+var processors = getProcessorArray(migrationType);
+
+var operations = [];
+
+var batchSize = process.env.BATCH_SIZE ?? 100; // default 100 revisions in 1 bulkwrite
+var batchSizeInterval = process.env.BATCH_INTERVAL ?? 3000; // default 3 sec
+
+// ===========================================
+// replace method with processors
+// ===========================================
+function replaceLatestRevisions(body, processors) {
+  var replacedBody = body;
+  processors.forEach((processor) => {
+    replacedBody = processor(replacedBody);
+  });
+  return replacedBody;
+}
+
+if (processors.length === 0) {
+  throw Error('No valid processors found. Please enter a valid environment variable');
+}
+
+pagesCollection.find({}).forEach((doc) => {
+  if (doc.revision) {
+    var revision = revisionsCollection.findOne({ _id: doc.revision });
+    var replacedBody = replaceLatestRevisions(revision.body, [...processors]);
+    var operation = {
+      updateOne: {
+        filter: { _id: revision._id },
+        update: {
+          $set: { body: replacedBody },
+        },
+      },
+    };
+    operations.push(operation);
+
+    // bulkWrite per 100 revisions
+    if (operations.length > (batchSize - 1)) {
+      revisionsCollection.bulkWrite(operations);
+      // sleep time can be set from env var
+      sleep(batchSizeInterval);
+      operations = [];
+    }
+  }
+});
+revisionsCollection.bulkWrite(operations);
+print('migration complete!');

+ 75 - 0
bin/data-migrations/v6/src/processor.js

@@ -0,0 +1,75 @@
+
+/* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid */
+// ignore lint error because this file is js as mongoshell
+
+// ===========================================
+// processors for old format
+// ===========================================
+function drawioProcessor(body) {
+  var oldDrawioRegExp = /:::\s?drawio\n(.+?)\n:::/g; // drawio old format
+  return body.replace(oldDrawioRegExp, '``` drawio\n$1\n```');
+}
+
+function plantumlProcessor(body) {
+  var oldPlantUmlRegExp = /@startuml\n([\s\S]*?)\n@enduml/g; // plantUML old format
+  return body.replace(oldPlantUmlRegExp, '``` plantuml\n$1\n```');
+}
+
+function tsvProcessor(body) {
+  var oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
+  return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
+}
+
+function csvProcessor(body) {
+  var oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
+  return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
+}
+
+function bracketlinkProcessor(body) {
+  // https://regex101.com/r/btZ4hc/1
+  var oldBracketLinkRegExp = /(?<!\[)\[{1}(\/.*?)\]{1}(?!\])/g; // Page Link old format
+  return body.replace(oldBracketLinkRegExp, '[[$1]]');
+}
+
+// processor for MIGRATION_TYPE=custom
+function customProcessor(body) {
+  // ADD YOUR PROCESS HERE!
+  // https://github.com/weseek/growi/discussions/7180
+  return body;
+}
+
+// ===========================================
+// define processors
+// ===========================================
+
+function getProcessorArray(migrationType) {
+  var oldFormatProcessors;
+  switch (migrationType) {
+    case 'v6-drawio':
+      oldFormatProcessors = [drawioProcessor];
+      break;
+    case 'v6-plantuml':
+      oldFormatProcessors = [plantumlProcessor];
+      break;
+    case 'v6-tsv':
+      oldFormatProcessors = [tsvProcessor];
+      break;
+    case 'v6-csv':
+      oldFormatProcessors = [csvProcessor];
+      break;
+    case 'v6-bracketlink':
+      oldFormatProcessors = [bracketlinkProcessor];
+      break;
+    case 'v6':
+      oldFormatProcessors = [drawioProcessor, plantumlProcessor, tsvProcessor, csvProcessor, bracketlinkProcessor];
+      break;
+    case 'custom':
+      oldFormatProcessors = [customProcessor];
+      break;
+    default:
+      oldFormatProcessors = [];
+  }
+  return oldFormatProcessors;
+}
+
+module.exports = getProcessorArray;

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
 {
   "npmClient": "yarn",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "useWorkspaces": true,
-  "version": "6.0.5",
+  "version": "6.0.6-RC.0",
   "packages": [
   "packages": [
     "packages/*"
     "packages/*"
   ]
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "6.0.5",
+  "version": "6.0.6-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

+ 2 - 1
packages/app/config/next-i18next.config.ts

@@ -7,8 +7,9 @@ import I18NextLocalStorageBackend from 'i18next-localstorage-backend';
 
 
 const isDev = process.env.NODE_ENV === 'development';
 const isDev = process.env.NODE_ENV === 'development';
 
 
+export const defaultLang = Lang.en_US;
 export const i18n = {
 export const i18n = {
-  defaultLocale: Lang.en_US,
+  defaultLocale: defaultLang,
   locales: AllLang,
   locales: AllLang,
 };
 };
 export const defaultNS = 'translation';
 export const defaultNS = 'translation';

+ 1 - 0
packages/app/docker/Dockerfile

@@ -99,6 +99,7 @@ COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/preset-themes packages/preset-themes
 COPY packages/preset-themes packages/preset-themes
 COPY packages/slack packages/slack
 COPY packages/slack packages/slack
 COPY packages/hackmd packages/hackmd
 COPY packages/hackmd packages/hackmd
+COPY packages/presentation packages/presentation
 COPY packages/remark-drawio packages/remark-drawio
 COPY packages/remark-drawio packages/remark-drawio
 COPY packages/remark-growi-directive packages/remark-growi-directive
 COPY packages/remark-growi-directive packages/remark-growi-directive
 COPY packages/remark-lsx packages/remark-lsx
 COPY packages/remark-lsx packages/remark-lsx

+ 14 - 15
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "6.0.5",
+  "version": "6.0.6-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -67,14 +67,14 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.5",
-    "@growi/core": "^6.0.5",
-    "@growi/hackmd": "^6.0.5",
-    "@growi/preset-themes": "^6.0.5",
-    "@growi/remark-drawio": "^6.0.5",
-    "@growi/remark-growi-directive": "^6.0.5",
-    "@growi/remark-lsx": "^6.0.5",
-    "@growi/slack": "^6.0.5",
+    "@growi/codemirror-textlint": "^6.0.6-RC.0",
+    "@growi/core": "^6.0.6-RC.0",
+    "@growi/hackmd": "^6.0.6-RC.0",
+    "@growi/preset-themes": "^6.0.6-RC.0",
+    "@growi/remark-drawio": "^6.0.6-RC.0",
+    "@growi/remark-growi-directive": "^6.0.6-RC.0",
+    "@growi/remark-lsx": "^6.0.6-RC.0",
+    "@growi/slack": "^6.0.6-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
@@ -97,8 +97,8 @@
     "cookie-parser": "^1.4.5",
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
     "csurf": "^1.11.0",
     "csv-to-markdown-table": "^1.1.0",
     "csv-to-markdown-table": "^1.1.0",
-    "dayjs": "^1.11.7",
     "date-fns": "^2.23.0",
     "date-fns": "^2.23.0",
+    "dayjs": "^1.11.7",
     "detect-indent": "^7.0.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
     "diff_match_patch": "^0.1.1",
     "diff_match_patch": "^0.1.1",
@@ -185,7 +185,7 @@
     "string-width": "=4.2.2",
     "string-width": "=4.2.2",
     "superjson": "^1.9.1",
     "superjson": "^1.9.1",
     "swagger-jsdoc": "^6.1.0",
     "swagger-jsdoc": "^6.1.0",
-    "swr": "^1.3.0",
+    "swr": "^2.0.3",
     "throttle-debounce": "^3.0.1",
     "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.29",
     "uglifycss": "^0.0.29",
@@ -203,7 +203,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/ui": "^6.0.5",
+    "@growi/presentation": "^6.0.6-RC.0",
+    "@growi/ui": "^6.0.6-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
     "@next/bundle-analyzer": "^12.2.3",
@@ -237,14 +238,12 @@
     "react-copy-to-clipboard": "^5.0.1",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dropzone": "^11.2.4",
     "react-dropzone": "^11.2.4",
     "react-hotkeys": "^2.0.0",
     "react-hotkeys": "^2.0.0",
-    "react-waypoint": "^10.1.0",
     "rehype-rewrite": "^3.0.6",
     "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
-    "reveal.js": "^4.3.1",
     "sass": "^1.53.0",
     "sass": "^1.53.0",
-    "simplebar-react": "^2.3.6",
     "simple-line-icons": "^2.5.5",
     "simple-line-icons": "^2.5.5",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
+    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.4.11",
     "sticky-events": "^3.4.11",
     "swagger2openapi": "^5.3.1",
     "swagger2openapi": "^5.3.1",

+ 1 - 13
packages/app/public/static/locales/en_US/admin.json

@@ -383,6 +383,7 @@
     "bucket_name": "Bucket name",
     "bucket_name": "Bucket name",
     "custom_endpoint": "Custom endpoint",
     "custom_endpoint": "Custom endpoint",
     "custom_endpoint_change": "Input the URL of the endpoint of an object storage service like MinIO that has a S3-compatible API.  Amazon S3 is used if empty.",
     "custom_endpoint_change": "Input the URL of the endpoint of an object storage service like MinIO that has a S3-compatible API.  Amazon S3 is used if empty.",
+    "s3_secret_access_key_input_description": "Setting value is hidden",
     "load_plugins": "Load plugins",
     "load_plugins": "Load plugins",
     "enable": "Enable",
     "enable": "Enable",
     "disable": "Disable",
     "disable": "Disable",
@@ -407,19 +408,6 @@
       "disallow_indent_change": "Disallow change of indent size by users",
       "disallow_indent_change": "Disallow change of indent size by users",
       "disallow_indent_change_desc": "Force users to use ther default indent size."
       "disallow_indent_change_desc": "Force users to use ther default indent size."
     },
     },
-    "presentation_header": "Presentation setting",
-    "presentation_desc": "You can change presentation settings.",
-    "presentation_options": {
-      "page_break_setting": "Page break setting",
-      "preset_one_separator": "Preset 1",
-      "preset_one_separator_desc": "3 Blank lines",
-      "preset_one_separator_value": "\\n\\n\\n",
-      "preset_two_separator": "Preset 2",
-      "preset_two_separator_desc": "5 Hyphens",
-      "preset_two_separator_value": "-----",
-      "custom_separator": "Custom",
-      "custom_separator_desc": "Regular expression"
-    },
     "xss_header": "Prevent XSS(cross site scripting) setting",
     "xss_header": "Prevent XSS(cross site scripting) setting",
     "xss_desc": "You can change the handling of HTML tags in markdown text.",
     "xss_desc": "You can change the handling of HTML tags in markdown text.",
     "xss_options": {
     "xss_options": {

+ 1 - 1
packages/app/public/static/locales/en_US/commons.json

@@ -100,7 +100,7 @@
 
 
   "g2g_data_transfer": {
   "g2g_data_transfer": {
     "tab": "Data transfer",
     "tab": "Data transfer",
-    "data_transfer": "GROWI To GROWI Data Transfer",
+    "data_transfer": "Data Transfer",
     "transfer_data_to_this_growi": "Transfer data from another GROWI to this GROWI",
     "transfer_data_to_this_growi": "Transfer data from another GROWI to this GROWI",
     "publish_transfer_key": "Publish transfer key",
     "publish_transfer_key": "Publish transfer key",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",

+ 0 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -58,7 +58,6 @@
   "attachment_data": "Attachment Data",
   "attachment_data": "Attachment Data",
   "No_attachments_yet": "No attachments yet.",
   "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "Presentation",
   "Presentation Mode": "Presentation",
-  "The end": "The end",
   "Not available for guest": "Not available for guest",
   "Not available for guest": "Not available for guest",
   "Not available in this version": "Not available in this version",
   "Not available in this version": "Not available in this version",
   "No users have liked this yet": "No users have liked this yet",
   "No users have liked this yet": "No users have liked this yet",

+ 1 - 13
packages/app/public/static/locales/ja_JP/admin.json

@@ -391,6 +391,7 @@
     "bucket_name": "バケット名",
     "bucket_name": "バケット名",
     "custom_endpoint": "カスタムエンドポイント",
     "custom_endpoint": "カスタムエンドポイント",
     "custom_endpoint_change": "MinIOなど、S3互換APIを持つ他のオブジェクトストレージサービスを使用する場合のみ、そのエンドポイントのURLを入力してください。空欄の場合は、Amazon S3を使用します。",
     "custom_endpoint_change": "MinIOなど、S3互換APIを持つ他のオブジェクトストレージサービスを使用する場合のみ、そのエンドポイントのURLを入力してください。空欄の場合は、Amazon S3を使用します。",
+    "s3_secret_access_key_input_description": "設定値は非表示です",
     "load_plugins": "プラグインを読み込む",
     "load_plugins": "プラグインを読み込む",
     "enable": "有効",
     "enable": "有効",
     "disable": "無効",
     "disable": "無効",
@@ -415,19 +416,6 @@
       "disallow_indent_change": "ユーザによるインデント幅変更を許可しない",
       "disallow_indent_change": "ユーザによるインデント幅変更を許可しない",
       "disallow_indent_change_desc": "ユーザにデフォルトのインデント幅の使用を強制します。"
       "disallow_indent_change_desc": "ユーザにデフォルトのインデント幅の使用を強制します。"
     },
     },
-    "presentation_header": "プレゼンテーション設定",
-    "presentation_desc": "プレゼンテーションの設定を変更できます。",
-    "presentation_options": {
-      "page_break_setting": "改頁を設定する",
-      "preset_one_separator": "プリセット 1",
-      "preset_one_separator_desc": "連続した空行3行で改頁します",
-      "preset_one_separator_value": "\\n\\n\\n",
-      "preset_two_separator": "プリセット 2",
-      "preset_two_separator_desc": "連続したハイフン5つで改頁します",
-      "preset_two_separator_value": "-----",
-      "custom_separator": "カスタム",
-      "custom_separator_desc": "正規表現を設定できます"
-    },
     "xss_header": "XSS(Cross Site Scripting)対策設定",
     "xss_header": "XSS(Cross Site Scripting)対策設定",
     "xss_desc": "マークダウンテキスト内の HTML タグの扱いを設定し、悪意のあるプログラムからの攻撃を防ぎます",
     "xss_desc": "マークダウンテキスト内の HTML タグの扱いを設定し、悪意のあるプログラムからの攻撃を防ぎます",
     "xss_options": {
     "xss_options": {

+ 2 - 2
packages/app/public/static/locales/ja_JP/commons.json

@@ -100,11 +100,11 @@
 
 
   "g2g_data_transfer": {
   "g2g_data_transfer": {
     "tab": "データ移行",
     "tab": "データ移行",
-    "data_transfer": "別GROWIとのデータ移行",
+    "data_transfer": "データ移行",
     "transfer_data_to_this_growi": "別GROWIのデータをこのGROWIへ移行する",
     "transfer_data_to_this_growi": "別GROWIのデータをこのGROWIへ移行する",
     "publish_transfer_key": "移行キーを発行する",
     "publish_transfer_key": "移行キーを発行する",
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
-    "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ移行はご利用いただけなくなります。",
+    "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
     "transfer_to_growi_cloud": "※ GROWI.cloud への移行を実施する場合はこちらをご確認ください。"
     "transfer_to_growi_cloud": "※ GROWI.cloud への移行を実施する場合はこちらをご確認ください。"
   }
   }
 }
 }

+ 0 - 1
packages/app/public/static/locales/ja_JP/translation.json

@@ -55,7 +55,6 @@
   "attachment_data": "添付データ",
   "attachment_data": "添付データ",
   "No_attachments_yet": "No attachments yet.",
   "No_attachments_yet": "No attachments yet.",
   "Presentation Mode": "プレゼンテーション",
   "Presentation Mode": "プレゼンテーション",
-  "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available in this version": "このバージョンでは利用できません",
   "Not available in this version": "このバージョンでは利用できません",
   "No users have liked this yet": "いいねをしているユーザーはいません",
   "No users have liked this yet": "いいねをしているユーザーはいません",

+ 1 - 13
packages/app/public/static/locales/zh_CN/admin.json

@@ -391,6 +391,7 @@
     "bucket_name": "Bucket name",
     "bucket_name": "Bucket name",
     "custom_endpoint": "Custom endpoint",
     "custom_endpoint": "Custom endpoint",
     "custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
     "custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
+    "s3_secret_access_key_input_description": "设定的值被隐藏。",
     "load_plugins": "加载插件",
     "load_plugins": "加载插件",
     "enable": "启用",
     "enable": "启用",
     "disable": "停用",
     "disable": "停用",
@@ -415,19 +416,6 @@
       "disallow_indent_change": "不允许用户更改缩进值",
       "disallow_indent_change": "不允许用户更改缩进值",
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
       "disallow_indent_change_desc": "您可以不允许用户更改缩进值。"
     },
     },
-    "presentation_header": "演示文稿设置",
-    "presentation_desc": "您可以更改演示文稿设置。",
-    "presentation_options": {
-      "page_break_setting": "分页设置",
-      "preset_one_separator": "预设 1",
-      "preset_one_separator_desc": "3 空行",
-      "preset_one_separator_value": "\\n\\n\\n",
-      "preset_two_separator": "预设 2",
-      "preset_two_separator_desc": "5 连字符",
-      "preset_two_separator_value": "-----",
-      "custom_separator": "自定义",
-      "custom_separator_desc": "正则表达式"
-    },
     "xss_header": "阻止XSS(跨站点脚本)设置",
     "xss_header": "阻止XSS(跨站点脚本)设置",
     "xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
     "xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
     "xss_options": {
     "xss_options": {

+ 1 - 1
packages/app/public/static/locales/zh_CN/commons.json

@@ -100,7 +100,7 @@
 
 
   "g2g_data_transfer": {
   "g2g_data_transfer": {
     "tab": "数据迁移",
     "tab": "数据迁移",
-    "data_transfer": "与另一个GROWI的数据转移",
+    "data_transfer": "数据迁移",
     "transfer_data_to_this_growi": "将数据从另一个GROWI迁移到这个GROWI上",
     "transfer_data_to_this_growi": "将数据从另一个GROWI迁移到这个GROWI上",
     "publish_transfer_key": "发布迁移密钥",
     "publish_transfer_key": "发布迁移密钥",
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",

+ 0 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -55,7 +55,6 @@
   "attachment_data": "Attachment Data",
   "attachment_data": "Attachment Data",
   "No_attachments_yet": "暂无附件",
   "No_attachments_yet": "暂无附件",
 	"Presentation Mode": "演示文稿",
 	"Presentation Mode": "演示文稿",
-  "The end": "结束",
   "Not available for guest": "不提供给客人",
   "Not available for guest": "不提供给客人",
   "Not available in this version": "此版本中不提供",
   "Not available in this version": "此版本中不提供",
   "No users have liked this yet": "还没有用户喜欢这个",
   "No users have liked this yet": "还没有用户喜欢这个",

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

@@ -1,42 +0,0 @@
-import Reveal from 'reveal.js';
-
-Reveal.initialize({
-  controls: true,
-  progress: true,
-  history: true,
-  center: true,
-  transition: 'slide',
-
-  // // This specification method can't be used
-  // //   sice deleting symlink prevented `src` from being resolved -- 2017.06.15 Yuki Takei
-  //
-  // Optional libraries used to extend on reveal.js
-  // dependencies: [
-  //   { src: 'lib/js/classList.js', condition: function() { return !document.body.classList; } },
-  //   { src: 'plugin/markdown/marked.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
-  //   { src: 'plugin/markdown/markdown.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
-  //   { src: 'plugin/highlight/highlight.js', async: true, callback: function() { hljs.initHighlightingOnLoad(); } },
-  //   { src: 'plugin/zoom-js/zoom.js', async: true, condition: function() { return !!document.body.classList; } },
-  //   { src: 'plugin/notes/notes.js', async: true, condition: function() { return !!document.body.classList; } }
-  // ]
-});
-
-require.ensure([], () => {
-  require('reveal.js/plugin/zoom/zoom');
-  require('reveal.js/plugin/notes/notes');
-  require('../util/reveal/plugins/growi-renderer');
-
-  // fix https://github.com/weseek/crowi-plus/issues/96
-  Reveal.slide(0, 0);
-  Reveal.sync();
-});
-
-Reveal.addEventListener('ready', () => {
-  // event.currentSlide, event.indexh, event.indexv
-  $('.reveal section').each(function() {
-    const $self = $(this);
-    if ($self.children().length !== 1) {
-      $self.addClass('only');
-    }
-  });
-});

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

@@ -108,7 +108,6 @@ export default class AdminAppContainer extends Container {
       s3CustomEndpoint: appSettingsParams.s3CustomEndpoint,
       s3CustomEndpoint: appSettingsParams.s3CustomEndpoint,
       s3Bucket: appSettingsParams.s3Bucket,
       s3Bucket: appSettingsParams.s3Bucket,
       s3AccessKeyId: appSettingsParams.s3AccessKeyId,
       s3AccessKeyId: appSettingsParams.s3AccessKeyId,
-      s3SecretAccessKey: appSettingsParams.s3SecretAccessKey,
       s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode,
       s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode,
 
 
       gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
       gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,

+ 0 - 35
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -24,8 +24,6 @@ export default class AdminMarkDownContainer extends Container {
       isEnabledLinebreaksInComments: false,
       isEnabledLinebreaksInComments: false,
       adminPreferredIndentSize: 4,
       adminPreferredIndentSize: 4,
       isIndentSizeForced: false,
       isIndentSizeForced: false,
-      pageBreakSeparator: 1,
-      pageBreakCustomSeparator: '',
       isEnabledXss: false,
       isEnabledXss: false,
       xssOption: '',
       xssOption: '',
       tagWhiteList: '',
       tagWhiteList: '',
@@ -55,8 +53,6 @@ export default class AdminMarkDownContainer extends Container {
       isEnabledLinebreaksInComments: markdownParams.isEnabledLinebreaksInComments,
       isEnabledLinebreaksInComments: markdownParams.isEnabledLinebreaksInComments,
       adminPreferredIndentSize: markdownParams.adminPreferredIndentSize,
       adminPreferredIndentSize: markdownParams.adminPreferredIndentSize,
       isIndentSizeForced: markdownParams.isIndentSizeForced,
       isIndentSizeForced: markdownParams.isIndentSizeForced,
-      pageBreakSeparator: markdownParams.pageBreakSeparator,
-      pageBreakCustomSeparator: markdownParams.pageBreakCustomSeparator || '',
       isEnabledXss: markdownParams.isEnabledXss,
       isEnabledXss: markdownParams.isEnabledXss,
       xssOption: markdownParams.xssOption,
       xssOption: markdownParams.xssOption,
       tagWhiteList: markdownParams.tagWhiteList || '',
       tagWhiteList: markdownParams.tagWhiteList || '',
@@ -68,20 +64,6 @@ export default class AdminMarkDownContainer extends Container {
     this.setState({ adminPreferredIndentSize });
     this.setState({ adminPreferredIndentSize });
   }
   }
 
 
-  /**
-   * Switch PageBreakSeparator
-   */
-  switchPageBreakSeparator(pageBreakSeparator) {
-    this.setState({ pageBreakSeparator });
-  }
-
-  /**
-   * Set PageBreakCustomSeparator
-   */
-  setPageBreakCustomSeparator(pageBreakCustomSeparator) {
-    this.setState({ pageBreakCustomSeparator });
-  }
-
   /**
   /**
    * Switch enableXss
    * Switch enableXss
    */
    */
@@ -140,21 +122,4 @@ export default class AdminMarkDownContainer extends Container {
     });
     });
   }
   }
 
 
-  /**
-   * Update Presentation Setting
-   */
-  async updatePresentationSetting() {
-
-    const response = await apiv3Put('/markdown-setting/presentation', {
-      pageBreakSeparator: this.state.pageBreakSeparator,
-      pageBreakCustomSeparator: this.state.pageBreakCustomSeparator,
-    });
-
-    this.setState({
-      pageBreakSeparator: response.data.presentationParams.pageBreakSeparator,
-      pageBreakCustomSeparator: response.data.presentationParams.pageBreakCustomSeparator,
-    });
-    return response;
-  }
-
 }
 }

+ 5 - 11
packages/app/src/client/services/layout.ts

@@ -1,3 +1,4 @@
+import type { IPage } from '~/interfaces/page';
 import { useIsContainerFluid } from '~/stores/context';
 import { useIsContainerFluid } from '~/stores/context';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
@@ -5,25 +6,18 @@ import { useEditorMode } from '~/stores/ui';
 export const useEditorModeClassName = (): string => {
 export const useEditorModeClassName = (): string => {
   const { getClassNamesByEditorMode } = useEditorMode();
   const { getClassNamesByEditorMode } = useEditorMode();
 
 
-  // TODO: Enable `editing-sidebar` class somehow
-  // https://redmine.weseek.co.jp/issues/111527
-  // const classNames: string[] = [];
-  // if (currentPage != null) {
-  //   const isSidebar = currentPage.path === '/Sidebar';
-  //   classNames.push(...getClassNamesByEditorMode(/* isSidebar */));
-  // }
-
   return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
   return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
 };
 };
 
 
-export const useCurrentGrowiLayoutFluidClassName = (): string => {
+export const useCurrentGrowiLayoutFluidClassName = (initialPage?: IPage): string => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
 
 
   const { data: dataIsContainerFluid } = useIsContainerFluid();
   const { data: dataIsContainerFluid } = useIsContainerFluid();
 
 
-  const isContainerFluidEachPage = currentPage == null || !('expandContentWidth' in currentPage)
+  const page = currentPage ?? initialPage;
+  const isContainerFluidEachPage = page == null || !('expandContentWidth' in page)
     ? null
     ? null
-    : currentPage.expandContentWidth;
+    : page.expandContentWidth;
   const isContainerFluidDefault = dataIsContainerFluid;
   const isContainerFluidDefault = dataIsContainerFluid;
   const isContainerFluid = isContainerFluidEachPage ?? isContainerFluidDefault;
   const isContainerFluid = isContainerFluidEachPage ?? isContainerFluidDefault;
 
 

+ 2 - 2
packages/app/src/client/services/page-operation.ts

@@ -6,7 +6,7 @@ import urljoin from 'url-join';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentPageId } from '~/stores/context';
 import { useCurrentPageId } from '~/stores/context';
 import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
 import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -179,7 +179,7 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
 
 
 export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => Promise<void>) | undefined => {
 export const useUpdateStateAfterSave = (pageId: string|undefined|null): (() => Promise<void>) | undefined => {
   const { mutate: mutateCurrentPageId } = useCurrentPageId();
   const { mutate: mutateCurrentPageId } = useCurrentPageId();
-  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
   const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);

+ 2 - 2
packages/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -23,7 +23,7 @@ declare global {
 
 
 
 
 export const useDrawioModalLauncherForView = (opts?: {
 export const useDrawioModalLauncherForView = (opts?: {
-  onSaveSuccess?: (newMarkdown: string) => void,
+  onSaveSuccess?: () => void,
   onSaveError?: (error: any) => void,
   onSaveError?: (error: any) => void,
 }): void => {
 }): void => {
 
 
@@ -61,7 +61,7 @@ export const useDrawioModalLauncherForView = (opts?: {
         optionsToSave,
         optionsToSave,
       );
       );
 
 
-      opts?.onSaveSuccess?.(newMarkdown);
+      opts?.onSaveSuccess?.();
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);

+ 2 - 2
packages/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -22,7 +22,7 @@ declare global {
 
 
 
 
 export const useHandsontableModalLauncherForView = (opts?: {
 export const useHandsontableModalLauncherForView = (opts?: {
-  onSaveSuccess?: (newMarkdown: string) => void,
+  onSaveSuccess?: () => void,
   onSaveError?: (error: any) => void,
   onSaveError?: (error: any) => void,
 }): void => {
 }): void => {
 
 
@@ -60,7 +60,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
         optionsToSave,
         optionsToSave,
       );
       );
 
 
-      opts?.onSaveSuccess?.(newMarkdown);
+      opts?.onSaveSuccess?.();
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);

+ 60 - 0
packages/app/src/client/util/locale-utils.ts

@@ -1,9 +1,69 @@
+import type { IncomingHttpHeaders } from 'http';
+
+import { Lang } from '@growi/core';
+
+import * as nextI18NextConfig from '^/config/next-i18next.config';
+
 // https://docs.google.com/spreadsheets/d/1FoYdyEraEQuWofzbYCDPKN7EdKgS_2ZrsDrOA8scgwQ
 // https://docs.google.com/spreadsheets/d/1FoYdyEraEQuWofzbYCDPKN7EdKgS_2ZrsDrOA8scgwQ
 const DIAGRAMS_NET_LANG_MAP = {
 const DIAGRAMS_NET_LANG_MAP = {
   ja_JP: 'ja',
   ja_JP: 'ja',
   zh_CN: 'zh',
   zh_CN: 'zh',
 };
 };
 
 
+const ACCEPT_LANG_MAP = {
+  en: Lang.en_US,
+  ja: Lang.ja_JP,
+  zh: Lang.zh_CN,
+};
+
 export const getDiagramsNetLangCode = (lang) => {
 export const getDiagramsNetLangCode = (lang) => {
   return DIAGRAMS_NET_LANG_MAP[lang];
   return DIAGRAMS_NET_LANG_MAP[lang];
 };
 };
+
+/**
+ * It return the first language that matches ACCEPT_LANG_MAP keys from sorted accept languages array
+ * @param sortedAcceptLanguagesArray
+ */
+const getPreferredLanguage = (sortedAcceptLanguagesArray: string[]): Lang => {
+  for (const lang of sortedAcceptLanguagesArray) {
+    const matchingLang = Object.keys(ACCEPT_LANG_MAP).find(key => lang.includes(key));
+    if (matchingLang) return ACCEPT_LANG_MAP[matchingLang];
+  }
+  return nextI18NextConfig.defaultLang;
+};
+
+/**
+  * Detect locale from browser accept language
+  * @param headers
+  */
+export const detectLocaleFromBrowserAcceptLanguage = (headers: IncomingHttpHeaders): Lang => {
+  // 1. get the header accept-language
+  // ex. "ja,ar-SA;q=0.8,en;q=0.6,en-CA;q=0.4,en-US;q=0.2"
+  const acceptLanguages = headers['accept-language'];
+
+  if (acceptLanguages == null) {
+    return nextI18NextConfig.defaultLang;
+  }
+
+  // 1. trim blank spaces.
+  // 2. separate by ,.
+  // 3. if "lang;q=x", then { 'x', 'lang' } to add to the associative array.
+  //    if "lang" has no weight x (";q=x"), add it with key = 1.
+  // ex. {'1': 'ja','0.8': 'ar-SA','0.6': 'en','0.4': 'en-CA','0.2': 'en-US'}
+  const acceptLanguagesDict = acceptLanguages
+    .replace(/\s+/g, '')
+    .split(',')
+    .map(item => item.split(/\s*;\s*q\s*=\s*/))
+    .reduce((acc, [key, value = '1']) => {
+      acc[value] = key;
+      return acc;
+    }, {});
+
+  // 1. create an array of sorted languages in descending order.
+  // ex. [ 'ja', 'ar-SA', 'en', 'en-CA', 'en-US' ]
+  const sortedAcceptLanguagesArray = Object.keys(acceptLanguagesDict)
+    .sort((x, y) => y.localeCompare(x))
+    .map(item => acceptLanguagesDict[item]);
+
+  return getPreferredLanguage(sortedAcceptLanguagesArray);
+};

+ 0 - 107
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -1,107 +0,0 @@
-/**
- * reveal.js growi-renderer plugin.
- */
-(function(root, factory) {
-  const growiRendererPlugin = factory();
-  growiRendererPlugin.initialize();
-}(this, () => {
-  /* eslint-disable no-useless-escape */
-  const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
-  const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
-  const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
-  /* eslint-enable no-useless-escape */
-
-  const growiRenderer = window.parent.previewRenderer;
-
-  let marked;
-
-  /**
-   * Add data separator before lines
-   * starting with '#' to markdown.
-   */
-  function divideSlides() {
-    const sections = document.querySelectorAll('[data-markdown]');
-    for (let i = 0, len = sections.length; i < len; i++) {
-      const section = sections[i];
-      const markdown = marked.getMarkdownFromSlide(section);
-      const context = { markdown };
-      const { interceptorManager } = window.parent;
-      let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
-      // replace string '\n' to LF code.
-      dataSeparator = dataSeparator.replace(/\\n/g, '\n');
-      const replaceValue = `${dataSeparator}#`;
-      // detach code block.
-      interceptorManager.process('prePreProcess', context);
-      // if there is only '\n' in the first line, replace it.
-      context.markdown = context.markdown.replace(/^\n/, '');
-      // add data separator to markdown.
-      context.markdown = context.markdown.replace(/[\n]+#/g, replaceValue);
-      // restore code block.
-      interceptorManager.process('postPreProcess', context);
-      section.innerHTML = marked.createMarkdownSlide(context.markdown);
-    }
-  }
-
-  /**
-   * Converts data-markdown slides to HTML slides by GrowiRenderer.
-   */
-  function convertSlides() {
-    const sections = document.querySelectorAll('[data-markdown]');
-    let markdown;
-    const { interceptorManager } = window.parent;
-
-    for (let i = 0, len = sections.length; i < len; i++) {
-      const section = sections[i];
-
-      // Only parse the same slide once
-      if (!section.getAttribute('data-markdown-parsed')) {
-        section.setAttribute('data-markdown-parsed', 'true');
-        const notes = section.querySelector('aside.notes');
-        markdown = marked.getMarkdownFromSlide(section);
-        const context = { markdown, currentPathname: decodeURIComponent(window.parent.location.pathname) };
-
-        interceptorManager.process('preRender', context)
-          .then(() => { return interceptorManager.process('prePreProcess', context) })
-          .then(() => {
-            context.markdown = growiRenderer.preProcess(context.markdown, context);
-          })
-          .then(() => { return interceptorManager.process('postPreProcess', context) })
-          .then(() => {
-            context.parsedHTML = growiRenderer.process(context.markdown, context);
-          })
-          .then(() => { return interceptorManager.process('prePostProcess', context) })
-          .then(() => {
-            context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
-          })
-          .then(() => { return interceptorManager.process('postPostProcess', context) })
-          .then(() => { return interceptorManager.process('preRenderHtml', context) })
-          .then(() => { return interceptorManager.process('postRenderHtml', context) })
-          .then(() => {
-            section.innerHTML = context.parsedHTML;
-          });
-        marked.addAttributes(section, section, null, section.getAttribute('data-element-attributes')
-          || section.parentNode.getAttribute('data-element-attributes')
-          || DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR,
-        section.getAttribute('data-attributes')
-          || section.parentNode.getAttribute('data-attributes')
-          || DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR);
-
-        // If there were notes, we need to re-add them after
-        // having overwritten the section's HTML
-        if (notes) {
-          section.appendChild(notes);
-        }
-      }
-    }
-  }
-
-  // API
-  return {
-    async initialize() {
-      marked = require('./markdown').default(growiRenderer.process);
-      divideSlides();
-      marked.processSlides();
-      convertSlides();
-    },
-  };
-}));

+ 0 - 379
packages/app/src/client/util/reveal/plugins/markdown.js

@@ -1,379 +0,0 @@
-/**
- * The reveal.js markdown plugin. Handles parsing of
- * markdown inside of presentations as well as loading
- * of external markdown documents.
- * Referred from The reveal.js markdown plugin.
- * https://github.com/hakimel/reveal.js/blob/master/plugin/markdown/markdown.js
- */
-export default function( marked ) {
-
-  const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$',
-    DEFAULT_NOTES_SEPARATOR = 'notes?:',
-    DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$',
-    DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
-
-  const SCRIPT_END_PLACEHOLDER = '__SCRIPT_END__';
-
-
-  /**
-   * Retrieves the markdown contents of a slide section
-   * element. Normalizes leading tabs/whitespace.
-   */
-  function getMarkdownFromSlide( section ) {
-
-    // look for a <script> or <textarea data-template> wrapper
-    let template = section.querySelector( '[data-template]' ) || section.querySelector( 'script' );
-
-    // strip leading whitespace so it isn't evaluated as code
-    let text = ( template || section ).textContent;
-
-    // restore script end tags
-    text = text.replace( new RegExp( SCRIPT_END_PLACEHOLDER, 'g' ), '</script>' );
-
-    let leadingWs = text.match( /^\n?(\s*)/ )[1].length,
-      leadingTabs = text.match( /^\n?(\t*)/ )[1].length;
-
-    if ( leadingTabs > 0 ) {
-      text = text.replace( new RegExp('\\n?\\t{' + leadingTabs + '}', 'g'), '\n' );
-    }
-    else if ( leadingWs > 1 ) {
-      text = text.replace( new RegExp('\\n? {' + leadingWs + '}', 'g'), '\n' );
-    }
-
-    return text;
-
-  }
-
-  /**
-   * Given a markdown slide section element, this will
-   * return all arguments that aren't related to markdown
-   * parsing. Used to forward any other user-defined arguments
-   * to the output markdown slide.
-   */
-  function getForwardedAttributes( section ) {
-
-    let attributes = section.attributes;
-    let result = [];
-
-    for ( let i = 0, len = attributes.length; i < len; i++ ) {
-      let name = attributes[i].name,
-        value = attributes[i].value;
-
-      // disregard attributes that are used for markdown loading/parsing
-      if ( /data\-(markdown|separator|vertical|notes)/gi.test( name ) ) continue;
-
-      if ( value ) {
-        result.push( name + '="' + value + '"' );
-      }
-      else {
-        result.push( name );
-      }
-    }
-
-    return result.join( ' ' );
-
-  }
-
-  /**
-   * Inspects the given options and fills out default
-   * values for what's not defined.
-   */
-  function getSlidifyOptions( options ) {
-
-    options = options || {};
-    options.separator = options.separator || DEFAULT_SLIDE_SEPARATOR;
-    options.notesSeparator = options.notesSeparator || DEFAULT_NOTES_SEPARATOR;
-    options.attributes = options.attributes || '';
-
-    return options;
-
-  }
-
-  /**
-   * Helper function for constructing a markdown slide.
-   */
-  function createMarkdownSlide( content, options ) {
-
-    options = getSlidifyOptions( options );
-
-    let notesMatch = content.split( new RegExp( options.notesSeparator, 'mgi' ) );
-
-    if ( notesMatch.length === 2 ) {
-      content = notesMatch[0] + '<aside class="notes">' + marked(notesMatch[1].trim()) + '</aside>';
-    }
-
-    // prevent script end tags in the content from interfering
-    // with parsing
-    content = content.replace( /<\/script>/g, SCRIPT_END_PLACEHOLDER );
-
-    return '<script type="text/template">' + content + '</script>';
-
-  }
-
-  /**
-   * Parses a data string into multiple slides based
-   * on the passed in separator arguments.
-   */
-  function slidify( markdown, options ) {
-
-    options = getSlidifyOptions( options );
-
-    let separatorRegex = new RegExp( options.separator + ( options.verticalSeparator ? '|' + options.verticalSeparator : '' ), 'mg' ),
-      horizontalSeparatorRegex = new RegExp( options.separator );
-
-    let matches,
-      lastIndex = 0,
-      isHorizontal,
-      wasHorizontal = true,
-      content,
-      sectionStack = [];
-
-    // iterate until all blocks between separators are stacked up
-    while ( (matches = separatorRegex.exec( markdown )) != null ) {
-      // notes = null;
-
-      // determine direction (horizontal by default)
-      isHorizontal = horizontalSeparatorRegex.test( matches[0] );
-
-      if ( !isHorizontal && wasHorizontal ) {
-        // create vertical stack
-        sectionStack.push( [] );
-      }
-
-      // pluck slide content from markdown input
-      content = markdown.substring( lastIndex, matches.index );
-
-      if ( isHorizontal && wasHorizontal ) {
-        // add to horizontal stack
-        sectionStack.push( content );
-      }
-      else {
-        // add to vertical stack
-        sectionStack[sectionStack.length-1].push( content );
-      }
-
-      lastIndex = separatorRegex.lastIndex;
-      wasHorizontal = isHorizontal;
-    }
-
-    // add the remaining slide
-    ( wasHorizontal ? sectionStack : sectionStack[sectionStack.length-1] ).push( markdown.substring( lastIndex ) );
-
-    let markdownSections = '';
-
-    // flatten the hierarchical stack, and insert <section data-markdown> tags
-    for ( let i = 0, len = sectionStack.length; i < len; i++ ) {
-      // vertical
-      if ( sectionStack[i] instanceof Array ) {
-        markdownSections += '<section '+ options.attributes +'>';
-
-        sectionStack[i].forEach( function( child ) {
-          markdownSections += '<section data-markdown>' + createMarkdownSlide( child, options ) + '</section>';
-        } );
-
-        markdownSections += '</section>';
-      }
-      else {
-        markdownSections += '<section '+ options.attributes +' data-markdown>' + createMarkdownSlide( sectionStack[i], options ) + '</section>';
-      }
-    }
-
-    return markdownSections;
-
-  }
-
-  /**
-   * Parses any current data-markdown slides, splits
-   * multi-slide markdown into separate sections and
-   * handles loading of external markdown.
-   */
-  function processSlides() {
-
-    let sections = document.querySelectorAll( '[data-markdown]'),
-      section;
-
-    for ( let i = 0, len = sections.length; i < len; i++ ) {
-
-      section = sections[i];
-
-      if ( section.getAttribute( 'data-markdown' ).length ) {
-
-        let xhr = new XMLHttpRequest(),
-          url = section.getAttribute( 'data-markdown' );
-
-        let datacharset = section.getAttribute( 'data-charset' );
-
-        // see https://developer.mozilla.org/en-US/docs/Web/API/element.getAttribute#Notes
-        if ( datacharset != null && datacharset != '' ) {
-          xhr.overrideMimeType( 'text/html; charset=' + datacharset );
-        }
-
-        xhr.onreadystatechange = function() {
-          if ( xhr.readyState === 4 ) {
-            // file protocol yields status code 0 (useful for local debug, mobile applications etc.)
-            if ( ( xhr.status >= 200 && xhr.status < 300 ) || xhr.status === 0 ) {
-
-              section.outerHTML = slidify( xhr.responseText, {
-                separator: section.getAttribute( 'data-separator' ),
-                verticalSeparator: section.getAttribute( 'data-separator-vertical' ),
-                notesSeparator: section.getAttribute( 'data-separator-notes' ),
-                attributes: getForwardedAttributes( section )
-              });
-
-            }
-            else {
-
-              section.outerHTML = '<section data-state="alert">' +
-                'ERROR: The attempt to fetch ' + url + ' failed with HTTP status ' + xhr.status + '.' +
-                'Check your browser\'s JavaScript console for more details.' +
-                '<p>Remember that you need to serve the presentation HTML from a HTTP server.</p>' +
-                '</section>';
-
-            }
-          }
-        };
-
-        xhr.open( 'GET', url, false );
-
-        try {
-          xhr.send();
-        }
-        catch ( e ) {
-          alert( 'Failed to get the Markdown file ' + url + '. Make sure that the presentation and the file are served by a HTTP server and the file can be found there. ' + e );
-        }
-
-      }
-      else if ( section.getAttribute( 'data-separator' ) || section.getAttribute( 'data-separator-vertical' ) || section.getAttribute( 'data-separator-notes' ) ) {
-
-        section.outerHTML = slidify( getMarkdownFromSlide( section ), {
-          separator: section.getAttribute( 'data-separator' ),
-          verticalSeparator: section.getAttribute( 'data-separator-vertical' ),
-          notesSeparator: section.getAttribute( 'data-separator-notes' ),
-          attributes: getForwardedAttributes( section )
-        });
-
-      }
-      else {
-        section.innerHTML = createMarkdownSlide( getMarkdownFromSlide( section ) );
-      }
-    }
-
-  }
-
-  /**
-   * Check if a node value has the attributes pattern.
-   * If yes, extract it and add that value as one or several attributes
-   * the the terget element.
-   *
-   * You need Cache Killer on Chrome to see the effect on any FOM transformation
-   * directly on refresh (F5)
-   * http://stackoverflow.com/questions/5690269/disabling-chrome-cache-for-website-development/7000899#answer-11786277
-   */
-  function addAttributeInElement( node, elementTarget, separator ) {
-
-    let mardownClassesInElementsRegex = new RegExp( separator, 'mg' );
-    let mardownClassRegex = new RegExp( "([^\"= ]+?)=\"([^\"=]+?)\"", 'mg' );
-    let nodeValue = node.nodeValue;
-    let matches = mardownClassesInElementsRegex.exec( nodeValue );
-    if ( matches != null ) {
-
-      let classes = matches[1];
-      nodeValue = nodeValue.substring( 0, matches.index ) + nodeValue.substring( mardownClassesInElementsRegex.lastIndex );
-      node.nodeValue = nodeValue;
-      let matchesClass;
-      while ( (matchesClass = mardownClassRegex.exec( classes )) != null ) {
-        elementTarget.setAttribute( matchesClass[1], matchesClass[2] );
-      }
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Add attributes to the parent element of a text node,
-   * or the element of an attribute node.
-   */
-  function addAttributes( section, element, previousElement, separatorElementAttributes, separatorSectionAttributes ) {
-
-    if ( element != null && element.childNodes != undefined && element.childNodes.length > 0 ) {
-      let previousParentElement = element;
-      for ( let i = 0; i < element.childNodes.length; i++ ) {
-        let childElement = element.childNodes[i];
-        if ( i > 0 ) {
-          let j = i - 1;
-          while ( j >= 0 ) {
-            let aPreviousChildElement = element.childNodes[j];
-            if ( typeof aPreviousChildElement.setAttribute == 'function' && aPreviousChildElement.tagName != 'BR' ) {
-              previousParentElement = aPreviousChildElement;
-              break;
-            }
-            j = j - 1;
-          }
-        }
-        let parentSection = section;
-        if ( childElement.nodeName ==  'section' ) {
-          parentSection = childElement ;
-          previousParentElement = childElement ;
-        }
-        if ( typeof childElement.setAttribute == 'function' || childElement.nodeType == Node.COMMENT_NODE ) {
-          addAttributes( parentSection, childElement, previousParentElement, separatorElementAttributes, separatorSectionAttributes );
-        }
-      }
-    }
-
-    if ( element.nodeType == Node.COMMENT_NODE ) {
-      if ( addAttributeInElement( element, previousElement, separatorElementAttributes ) == false ) {
-        addAttributeInElement( element, section, separatorSectionAttributes );
-      }
-    }
-  }
-
-  /**
-   * Converts any current data-markdown slides in the
-   * DOM to HTML.
-   */
-  function convertSlides() {
-
-    let sections = document.querySelectorAll( '[data-markdown]');
-
-    for ( let i = 0, len = sections.length; i < len; i++ ) {
-
-      let section = sections[i];
-
-      // Only parse the same slide once
-      if ( !section.getAttribute( 'data-markdown-parsed' ) ) {
-
-        section.setAttribute( 'data-markdown-parsed', true );
-
-        let notes = section.querySelector( 'aside.notes' );
-        let markdown = getMarkdownFromSlide( section );
-
-        section.innerHTML = marked( markdown );
-        addAttributes(   section, section, null, section.getAttribute( 'data-element-attributes' ) ||
-                section.parentNode.getAttribute( 'data-element-attributes' ) ||
-                DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR,
-        section.getAttribute( 'data-attributes' ) ||
-                section.parentNode.getAttribute( 'data-attributes' ) ||
-                DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR);
-
-        // If there were notes, we need to re-add them after
-        // having overwritten the section's HTML
-        if ( notes ) {
-          section.appendChild( notes );
-        }
-
-      }
-
-    }
-
-  }
-
-  // API
-  return {
-    getMarkdownFromSlide: getMarkdownFromSlide,
-    createMarkdownSlide: createMarkdownSlide,
-    processSlides: processSlides,
-    addAttributes: addAttributes,
-    convertSlides: convertSlides
-  };
-}

+ 0 - 5
packages/app/src/client/util/smooth-scroll.ts

@@ -1,5 +0,0 @@
-// option object for react-scroll
-export const DEFAULT_AUTO_SCROLL_OPTS = {
-  smooth: 'easeOutQuint',
-  duration: 1200,
-};

+ 1 - 1
packages/app/src/client/util/toastr.ts

@@ -17,7 +17,7 @@ export const toastError = (err: string | Error | Error[], option: ToastOptions =
 
 
   for (const err of errs) {
   for (const err of errs) {
     const message = (typeof err === 'string') ? err : err.message;
     const message = (typeof err === 'string') ? err : err.message;
-    toast.error(message || err, option);
+    toast.error(message, option);
   }
   }
 };
 };
 
 

+ 1 - 1
packages/app/src/components/Admin/App/AwsSetting.tsx

@@ -140,11 +140,11 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
-            defaultValue={props.s3SecretAccessKey || ''}
             onChange={(e) => {
             onChange={(e) => {
               props?.onChangeS3SecretAccessKey(e.target.value);
               props?.onChangeS3SecretAccessKey(e.target.value);
             }}
             }}
           />
           />
+          <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
         </div>
         </div>
       </div>
       </div>
 
 

+ 9 - 8
packages/app/src/components/Admin/App/ConfirmModal.tsx

@@ -1,15 +1,15 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { useTranslation } from 'next-i18next';
-import { TFunctionResult } from 'i18next';
 
 
 type ConfirmModalProps = {
 type ConfirmModalProps = {
   isModalOpen: boolean
   isModalOpen: boolean
-  warningMessage: TFunctionResult
-  supplymentaryMessage: TFunctionResult | null
-  confirmButtonTitle: TFunctionResult
+  warningMessage: string
+  supplymentaryMessage: string | null
+  confirmButtonTitle: string
   onConfirm?: () => Promise<void>
   onConfirm?: () => Promise<void>
   onCancel?: () => void
   onCancel?: () => void
 };
 };
@@ -43,13 +43,14 @@ export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) =>
               <br />
               <br />
               <br />
               <br />
               <span className="text-warning">
               <span className="text-warning">
-                <i className="icon-exclamation icon-fw"></i>
-                {props.supplymentaryMessage}
+                <>
+                  <i className="icon-exclamation icon-fw"></i>
+                  {props.supplymentaryMessage}
+                </>
               </span>
               </span>
             </>
             </>
           )
           )
         }
         }
-
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <button
         <button

+ 3 - 3
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -29,15 +29,15 @@ const AdminNavigation = (props) => {
       case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
       case 'customize':                return <><i className="mr-1 icon-fw icon-wrench"></i>{          t('customize_settings.customize_settings') }</>;
       case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
       case 'importer':                 return <><i className="mr-1 icon-fw icon-cloud-upload"></i>{    t('importer_management.import_data') }</>;
       case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
       case 'export':                   return <><i className="mr-1 icon-fw icon-cloud-download"></i>{  t('export_management.export_archive_data') }</>;
+      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-plane"></i>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
       case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
       case 'notification':             return <><i className="mr-1 icon-fw icon-bell"></i>{            t('external_notification.external_notification')}</>;
       case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
       case 'slack-integration':        return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration.slack_integration') }</>;
       case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
       case 'slack-integration-legacy': return <><i className="mr-1 icon-fw icon-shuffle"></i>{         t('slack_integration_legacy.slack_integration_legacy')}</>;
       case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
       case 'users':                    return <><i className="mr-1 icon-fw icon-user"></i>{            t('user_management.user_management') }</>;
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
-      case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
-      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-arrow-right"></i>{     t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
       case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
       case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
+      case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */
       /* eslint-enable no-multi-spaces, max-len */
@@ -87,6 +87,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="customize"    isListGroupItems isActive={isActiveMenu('/customize')} />
         <MenuLink menu="customize"    isListGroupItems isActive={isActiveMenu('/customize')} />
         <MenuLink menu="importer"     isListGroupItems isActive={isActiveMenu('/importer')} />
         <MenuLink menu="importer"     isListGroupItems isActive={isActiveMenu('/importer')} />
         <MenuLink menu="export"       isListGroupItems isActive={isActiveMenu('/export')} />
         <MenuLink menu="export"       isListGroupItems isActive={isActiveMenu('/export')} />
+        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
         <MenuLink menu="notification" isListGroupItems isActive={isActiveMenu('/notification') || isActiveMenu('/global-notification')} />
         <MenuLink menu="notification" isListGroupItems isActive={isActiveMenu('/notification') || isActiveMenu('/global-notification')} />
         <MenuLink menu="slack-integration" isListGroupItems isActive={isActiveMenu('/slack-integration')} />
         <MenuLink menu="slack-integration" isListGroupItems isActive={isActiveMenu('/slack-integration')} />
         <MenuLink menu="slack-integration-legacy" isListGroupItems isActive={isActiveMenu('/slack-integration-legacy')} />
         <MenuLink menu="slack-integration-legacy" isListGroupItems isActive={isActiveMenu('/slack-integration-legacy')} />
@@ -94,7 +95,6 @@ const AdminNavigation = (props) => {
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
         <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
-        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
           && (

+ 0 - 8
packages/app/src/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx

@@ -12,7 +12,6 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 import IndentForm from './IndentForm';
 import IndentForm from './IndentForm';
 import LineBreakForm from './LineBreakForm';
 import LineBreakForm from './LineBreakForm';
-import PresentationForm from './PresentationForm';
 import XssForm from './XssForm';
 import XssForm from './XssForm';
 
 
 const logger = loggerFactory('growi:MarkDown');
 const logger = loggerFactory('growi:MarkDown');
@@ -56,13 +55,6 @@ const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
       </Card>
       </Card>
       <IndentForm />
       <IndentForm />
 
 
-      {/* Presentation Setting */}
-      <h2 className="admin-setting-header">{ t('markdown_settings.presentation_header') }</h2>
-      <Card className="card well my-3">
-        <CardBody className="px-0 py-2">{ t('markdown_settings.presentation_desc') }</CardBody>
-      </Card>
-      <PresentationForm />
-
       {/* XSS Setting */}
       {/* XSS Setting */}
       <h2 className="admin-setting-header">{ t('markdown_settings.xss_header') }</h2>
       <h2 className="admin-setting-header">{ t('markdown_settings.xss_header') }</h2>
       <Card className="card well my-3">
       <Card className="card well my-3">

+ 0 - 143
packages/app/src/components/Admin/MarkdownSetting/PresentationForm.jsx

@@ -1,143 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
-const logger = loggerFactory('growi:markdown:presentation');
-
-class PresentationForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t } = this.props;
-
-    try {
-      await this.props.adminMarkDownContainer.updatePresentationSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.presentation_header'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-
-  render() {
-    const { t, adminMarkDownContainer } = this.props;
-    const { pageBreakSeparator, pageBreakCustomSeparator } = adminMarkDownContainer.state;
-
-    return (
-      <fieldset className="form-group col-12 my-2">
-
-        <label className="col-8 offset-4 col-form-label font-weight-bold text-left mt-3">
-          {t('markdown_settings.presentation_options.page_break_setting')}
-        </label>
-
-        <div className="form-group col-12 my-3">
-          <div className="row">
-            <div className="col-md-4 col-sm-12 align-self-start mb-4">
-              <div className="custom-control custom-radio">
-                <input
-                  type="radio"
-                  className="custom-control-input"
-                  id="pageBreakOption1"
-                  checked={pageBreakSeparator === 1}
-                  onChange={() => adminMarkDownContainer.switchPageBreakSeparator(1)}
-                />
-                <label className="custom-control-label w-100" htmlFor="pageBreakOption1">
-                  <p className="font-weight-bold">{ t('markdown_settings.presentation_options.preset_one_separator') }</p>
-                  <div className="mt-3">
-                    { t('markdown_settings.presentation_options.preset_one_separator_desc') }
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={t('markdown_settings.presentation_options.preset_one_separator_value')}
-                      readOnly
-                    />
-                  </div>
-                </label>
-              </div>
-            </div>
-
-            <div className="col-md-4 col-sm-12 align-self-start mb-4">
-              <div className="custom-control custom-radio">
-                <input
-                  type="radio"
-                  className="custom-control-input"
-                  id="pageBreakOption2"
-                  checked={pageBreakSeparator === 2}
-                  onChange={() => adminMarkDownContainer.switchPageBreakSeparator(2)}
-                />
-                <label className="custom-control-label w-100" htmlFor="pageBreakOption2">
-                  <p className="font-weight-bold">{ t('markdown_settings.presentation_options.preset_two_separator') }</p>
-                  <div className="mt-3">
-                    { t('markdown_settings.presentation_options.preset_two_separator_desc') }
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={t('markdown_settings.presentation_options.preset_two_separator_value')}
-                      readOnly
-                    />
-                  </div>
-                </label>
-              </div>
-            </div>
-            <div className="col-md-4 col-sm-12 align-self-start mb-4">
-              <div className="custom-control custom-radio">
-                <input
-                  type="radio"
-                  id="pageBreakOption3"
-                  className="custom-control-input"
-                  checked={pageBreakSeparator === 3}
-                  onChange={() => adminMarkDownContainer.switchPageBreakSeparator(3)}
-                />
-                <label className="custom-control-label w-100" htmlFor="pageBreakOption3">
-                  <p className="font-weight-bold">{ t('markdown_settings.presentation_options.custom_separator') }</p>
-                  <div className="mt-3">
-                    { t('markdown_settings.presentation_options.custom_separator_desc') }
-                    <input
-                      className="form-control"
-                      defaultValue={pageBreakCustomSeparator}
-                      onChange={(e) => { adminMarkDownContainer.setPageBreakCustomSeparator(e.target.value) }}
-                    />
-                  </div>
-                </label>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null} />
-      </fieldset>
-    );
-  }
-
-}
-
-PresentationForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
-
-};
-
-const PresentationFormWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-
-  return <PresentationForm t={t} {...props} />;
-};
-
-const PresentationFormWrapper = withUnstatedContainers(PresentationFormWrapperFC, [AdminMarkDownContainer]);
-
-export default PresentationFormWrapper;

+ 1 - 2
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -2,7 +2,6 @@ import React, {
   FC, useCallback, useState, useMemo,
   FC, useCallback, useState, useMemo,
 } from 'react';
 } from 'react';
 
 
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -30,7 +29,7 @@ type AvailableOption = {
   actionForPages: string,
   actionForPages: string,
   iconClass: string,
   iconClass: string,
   styleClass: string,
   styleClass: string,
-  label: TFunctionResult,
+  label: string,
 };
 };
 
 
 // actionName master constants
 // actionName master constants

+ 1 - 2
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,7 +1,6 @@
 import React, { FC, useCallback, useState } from 'react';
 import React, { FC, useCallback, useState } from 'react';
 
 
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
@@ -9,7 +8,7 @@ import { IUserGroupHasId } from '~/interfaces/user';
 type Props = {
 type Props = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
   selectableParentUserGroups?: IUserGroupHasId[],
-  submitButtonLabel: TFunctionResult;
+  submitButtonLabel: string;
   onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
   onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
 };
 };
 
 

+ 1 - 2
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -3,7 +3,6 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import { Ref } from '@growi/core';
 import { Ref } from '@growi/core';
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -13,7 +12,7 @@ import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 
 
 type Props = {
 type Props = {
   userGroup?: IUserGroupHasId,
   userGroup?: IUserGroupHasId,
-  buttonLabel?: TFunctionResult,
+  buttonLabel?: string,
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   isShow?: boolean
   onHide?: () => Promise<void> | void
   onHide?: () => Promise<void> | void

+ 1 - 2
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -4,12 +4,11 @@ import React, {
 
 
 import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
-import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 
 
 type Props = {
 type Props = {
-  headerLabel?: TFunctionResult,
+  headerLabel?: string,
   userGroups: IUserGroupHasId[],
   userGroups: IUserGroupHasId[],
   userGroupRelations: IUserGroupRelation[],
   userGroupRelations: IUserGroupRelation[],
   childUserGroups: IUserGroupHasId[],
   childUserGroups: IUserGroupHasId[],

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -13,7 +13,7 @@ type Props = {
 }
 }
 
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
 export const UserGroupUserTable = (props: Props): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
 
   const {
   const {
     userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
     userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,

+ 52 - 23
packages/app/src/components/Comments.tsx

@@ -1,9 +1,9 @@
-import React from 'react';
+import React, { useEffect, useRef } from 'react';
 
 
 import { type IRevisionHasId, pagePathUtils } from '@growi/core';
 import { type IRevisionHasId, pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
-import type { PageCommentProps } from '~/components/PageComment';
+import { ROOT_ELEM_ID as PageCommentRootElemId, type PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useIsTrashPage } from '~/stores/page';
 import { useIsTrashPage } from '~/stores/page';
 
 
@@ -22,16 +22,47 @@ export type CommentsProps = {
   pageId: string,
   pageId: string,
   pagePath: string,
   pagePath: string,
   revision: IRevisionHasId,
   revision: IRevisionHasId,
+  onLoaded?: () => void,
 }
 }
 
 
 export const Comments = (props: CommentsProps): JSX.Element => {
 export const Comments = (props: CommentsProps): JSX.Element => {
 
 
-  const { pageId, pagePath, revision } = props;
+  const {
+    pageId, pagePath, revision, onLoaded,
+  } = props;
 
 
   const { mutate } = useSWRxPageComment(pageId);
   const { mutate } = useSWRxPageComment(pageId);
   const { data: isDeleted } = useIsTrashPage();
   const { data: isDeleted } = useIsTrashPage();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
+  const pageCommentParentRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const parent = pageCommentParentRef.current;
+    if (parent == null) return;
+
+    const observerCallback = (mutationRecords:MutationRecord[]) => {
+      mutationRecords.forEach((record:MutationRecord) => {
+        const target = record.target as HTMLElement;
+
+        for (const child of Array.from(target.children)) {
+          const childId = (child as HTMLElement).id;
+          if (childId === PageCommentRootElemId) {
+            onLoaded?.();
+            break;
+          }
+        }
+
+      });
+    };
+
+    const observer = new MutationObserver(observerCallback);
+    observer.observe(parent, { childList: true });
+    return () => {
+      observer.disconnect();
+    };
+  }, [onLoaded]);
+
   const isTopPagePath = isTopPage(pagePath);
   const isTopPagePath = isTopPage(pagePath);
 
 
   if (pageId == null || isTopPagePath) {
   if (pageId == null || isTopPagePath) {
@@ -41,29 +72,27 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   return (
   return (
     <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
     <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
       <div className="container-lg">
       <div className="container-lg">
-        <div className="page-comments">
-          <div id="page-comments-list" className="page-comments-list">
-            <PageComment
+        <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
+          <PageComment
+            pageId={pageId}
+            pagePath={pagePath}
+            revision={revision}
+            currentUser={currentUser}
+            isReadOnly={false}
+            titleAlign="left"
+            hideIfEmpty={false}
+          />
+        </div>
+        { !isDeleted && (
+          <div id="page-comment-write">
+            <CommentEditor
               pageId={pageId}
               pageId={pageId}
-              pagePath={pagePath}
-              revision={revision}
-              currentUser={currentUser}
-              isReadOnly={false}
-              titleAlign="left"
-              hideIfEmpty={false}
+              isForNewComment
+              onCommentButtonClicked={mutate}
+              revisionId={revision._id}
             />
             />
           </div>
           </div>
-          { !isDeleted && (
-            <div id="page-comment-write">
-              <CommentEditor
-                pageId={pageId}
-                isForNewComment
-                onCommentButtonClicked={mutate}
-                revisionId={revision._id}
-              />
-            </div>
-          )}
-        </div>
+        )}
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 2 - 5
packages/app/src/components/ContentLinkButtons.tsx

@@ -3,17 +3,14 @@ import React from 'react';
 import { IUserHasId } from '@growi/core';
 import { IUserHasId } from '@growi/core';
 import { Link as ScrollLink } from 'react-scroll';
 import { Link as ScrollLink } from 'react-scroll';
 
 
-import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 
 
 import styles from './ContentLinkButtons.module.scss';
 import styles from './ContentLinkButtons.module.scss';
 
 
-const OFFSET = -120;
-
 const BookMarkLinkButton = React.memo(() => {
 const BookMarkLinkButton = React.memo(() => {
 
 
   return (
   return (
-    <ScrollLink to="bookmarks-list" offset={OFFSET} {...DEFAULT_AUTO_SCROLL_OPTS}>
+    <ScrollLink to="bookmarks-list" offset={-120}>
       <button
       <button
         type="button"
         type="button"
         className="btn btn-outline-secondary btn-sm px-2"
         className="btn btn-outline-secondary btn-sm px-2"
@@ -30,7 +27,7 @@ BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 const RecentlyCreatedLinkButton = React.memo(() => {
 const RecentlyCreatedLinkButton = React.memo(() => {
 
 
   return (
   return (
-    <ScrollLink to="recently-created-list" offset={OFFSET} {...DEFAULT_AUTO_SCROLL_OPTS}>
+    <ScrollLink to="recently-created-list" offset={-120}>
       <button
       <button
         type="button"
         type="button"
         className="btn btn-outline-secondary btn-sm px-3"
         className="btn btn-outline-secondary btn-sm px-3"

+ 13 - 46
packages/app/src/components/DescendantsPageList.tsx

@@ -11,15 +11,14 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import {
 import {
-  useIsGuestUser, useIsSharedUser, useShowPageLimitationXL,
+  useIsGuestUser, useIsSharedUser,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useIsTrashPage } from '~/stores/page';
 import {
 import {
-  usePageTreeTermManager, useDescendantsPageListForCurrentPathTermManager, useSWRxDescendantsPageListForCurrrentPath,
+  mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList,
   useSWRxPageInfoForList, useSWRxPageList,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
 
 
-import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
+import { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
@@ -37,7 +36,7 @@ const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> =>
   return { data: page };
   return { data: page };
 };
 };
 
 
-export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
+const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -52,10 +51,6 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
 
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
 
 
-  // for mutation
-  const { advance: advancePt } = usePageTreeTermManager();
-  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
-
   // initial data
   // initial data
   if (pagingResult != null) {
   if (pagingResult != null) {
     // convert without meta at first
     // convert without meta at first
@@ -74,23 +69,22 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
       toastSuccess(t('deleted_pages_completely', { path }));
       toastSuccess(t('deleted_pages_completely', { path }));
     }
     }
 
 
-    advancePt();
+    mutatePageTree();
 
 
     if (onPagesDeleted != null) {
     if (onPagesDeleted != null) {
       onPagesDeleted(...args);
       onPagesDeleted(...args);
     }
     }
-  }, [advancePt, onPagesDeleted, t]);
+  }, [onPagesDeleted, t]);
 
 
   const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
   const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
     toastSuccess(t('page_has_been_reverted', { path }));
     toastSuccess(t('page_has_been_reverted', { path }));
 
 
-    advancePt();
-    advanceDpl();
+    mutatePageTree();
 
 
     if (onPagePutBacked != null) {
     if (onPagePutBacked != null) {
       onPagePutBacked(path);
       onPagePutBacked(path);
     }
     }
-  }, [advanceDpl, advancePt, onPagePutBacked, t]);
+  }, [onPagePutBacked, t]);
 
 
   function setPageNumber(selectedPageNumber) {
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
     setActivePage(selectedPageNumber);
@@ -135,43 +129,18 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
 
 
 export type DescendantsPageListProps = {
 export type DescendantsPageListProps = {
   path: string,
   path: string,
+  limit?: number,
+  forceHideMenuItems?: ForceHideMenuItems,
 }
 }
 
 
 export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
 export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
-  const { path } = props;
+  const { path, limit, forceHideMenuItems } = props;
 
 
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
 
 
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
 
 
-  const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage);
-
-  if (error != null) {
-    return (
-      <div className="my-5">
-        <div className="text-danger">{error.message}</div>
-      </div>
-    );
-  }
-
-  return (
-    <DescendantsPageListSubstance
-      pagingResult={pagingResult}
-      activePage={activePage}
-      setActivePage={setActivePage}
-      onPagesDeleted={() => mutate()}
-      onPagePutBacked={() => mutate()}
-    />
-  );
-};
-
-export const DescendantsPageListForCurrentPath = (): JSX.Element => {
-
-  const [activePage, setActivePage] = useState(1);
-
-  const { data: isTrashPage } = useIsTrashPage();
-  const { data: limit } = useShowPageLimitationXL();
-  const { data: pagingResult, error, mutate } = useSWRxDescendantsPageListForCurrrentPath(activePage, limit);
+  const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
 
 
   if (error != null) {
   if (error != null) {
     return (
     return (
@@ -181,8 +150,6 @@ export const DescendantsPageListForCurrentPath = (): JSX.Element => {
     );
     );
   }
   }
 
 
-  const forceHideMenuItems = isTrashPage ? [MenuItemType.RENAME] : undefined;
-
   return (
   return (
     <DescendantsPageListSubstance
     <DescendantsPageListSubstance
       pagingResult={pagingResult}
       pagingResult={pagingResult}
@@ -190,7 +157,7 @@ export const DescendantsPageListForCurrentPath = (): JSX.Element => {
       setActivePage={setActivePage}
       setActivePage={setActivePage}
       forceHideMenuItems={forceHideMenuItems}
       forceHideMenuItems={forceHideMenuItems}
       onPagesDeleted={() => mutate()}
       onPagesDeleted={() => mutate()}
+      onPagePutBacked={() => mutate()}
     />
     />
   );
   );
-
 };
 };

+ 2 - 8
packages/app/src/components/DescendantsPageListModal.tsx

@@ -20,15 +20,9 @@ import TimeLineIcon from './Icons/TimeLineIcon';
 
 
 import styles from './DescendantsPageListModal.module.scss';
 import styles from './DescendantsPageListModal.module.scss';
 
 
-const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
-  const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
-  return <DescendantsPageList {...props}/>;
-};
+const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
 
 
-const PageTimeline = (): JSX.Element => {
-  const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
-  return <PageTimeline />;
-};
+const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
 
 
 export const DescendantsPageListModal = (): JSX.Element => {
 export const DescendantsPageListModal = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();

+ 1 - 2
packages/app/src/components/Fab.tsx

@@ -6,7 +6,6 @@ import { animateScroll } from 'react-scroll';
 import { useRipple } from 'react-use-ripple';
 import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 
 
-import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useIsAbleToChangeEditorMode } from '~/stores/ui';
 import { useIsAbleToChangeEditorMode } from '~/stores/ui';
@@ -96,7 +95,7 @@ export const Fab = (): JSX.Element => {
 
 
   const ScrollToTopButton = useCallback(() => {
   const ScrollToTopButton = useCallback(() => {
     const clickHandler = () => {
     const clickHandler = () => {
-      animateScroll.scrollToTop(DEFAULT_AUTO_SCROLL_OPTS);
+      animateScroll.scrollToTop({ duration: 200 });
     };
     };
 
 
     return (
     return (

+ 0 - 14
packages/app/src/components/Invited.module.scss

@@ -1,14 +0,0 @@
-.invited,
-.nologin.error {
-  .main .row {
-    @media (min-width: 510px) {
-      .offset-sm-4 {
-        margin-left: calc(50% - 240px);
-      }
-
-      .col-sm-4 {
-        width: 480px;
-      }
-    }
-  }
-}

+ 0 - 11
packages/app/src/components/Layout/BasicLayout.tsx

@@ -4,7 +4,6 @@ import dynamic from 'next/dynamic';
 import { DndProvider } from 'react-dnd';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
 
-import { useEditorModeClassName } from '../../client/services/layout';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 import Sidebar from '../Sidebar';
 import Sidebar from '../Sidebar';
 
 
@@ -68,13 +67,3 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
     </RawLayout>
     </RawLayout>
   );
   );
 };
 };
-
-export const BasicLayoutWithEditorMode = ({ children }: Props): JSX.Element => {
-  const className = useEditorModeClassName();
-
-  return (
-    <BasicLayout className={className}>
-      {children}
-    </BasicLayout>
-  );
-};

+ 0 - 5
packages/app/src/components/Layout/NoLoginLayout.module.scss

@@ -14,11 +14,6 @@
     .main {
     .main {
       width: 100vw;
       width: 100vw;
 
 
-      > .row {
-        margin-right: 20px;
-        margin-left: 20px;
-      }
-
       .nologin-header {
       .nologin-header {
         display: flex;
         display: flex;
         flex-direction: column;
         flex-direction: column;

+ 1 - 1
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -120,7 +120,7 @@ export const BasicInfoSettings = (): JSX.Element => {
                     checked={personalSettingsInfo?.lang === locale}
                     checked={personalSettingsInfo?.lang === locale}
                     onChange={() => changePersonalSettingsHandler({ lang: locale })}
                     onChange={() => changePersonalSettingsHandler({ lang: locale })}
                   />
                   />
-                  <label className="custom-control-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name')}</label>
+                  <label className="custom-control-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
                 </div>
                 </div>
               );
               );
             })
             })

+ 12 - 17
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -24,7 +24,7 @@ import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   useIsAbleToChangeEditorMode, useIsAbleToShowPageAuthors,
   useIsAbleToChangeEditorMode, useIsAbleToShowPageAuthors,
@@ -36,7 +36,6 @@ import HistoryIcon from '../Icons/HistoryIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { NotAvailable } from '../NotAvailable';
 import { NotAvailable } from '../NotAvailable';
-import { NotAvailableForNow } from '../NotAvailableForNow';
 import { Skeleton } from '../Skeleton';
 import { Skeleton } from '../Skeleton';
 
 
 import type { AuthorInfoProps } from './AuthorInfo';
 import type { AuthorInfoProps } from './AuthorInfo';
@@ -85,23 +84,19 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
 
 
-  const hrefForPresentationModal = `${pageId}/?presentation=1`;
-
   return (
   return (
     <>
     <>
       {/* Presentation */}
       {/* Presentation */}
-      <NotAvailableForNow>
-        <DropdownItem
-          onClick={() => openPresentationModal(hrefForPresentationModal)}
-          data-testid="open-presentation-modal-btn"
-          className="grw-page-control-dropdown-item"
-        >
-          <i className="icon-fw grw-page-control-dropdown-icon">
-            <PresentationIcon />
-          </i>
-          { t('Presentation Mode') }
-        </DropdownItem>
-      </NotAvailableForNow>
+      <DropdownItem
+        onClick={() => openPresentationModal()}
+        data-testid="open-presentation-modal-btn"
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw grw-page-control-dropdown-icon">
+          <PresentationIcon />
+        </i>
+        { t('Presentation Mode') }
+      </DropdownItem>
 
 
       {/* Export markdown */}
       {/* Export markdown */}
       <DropdownItem
       <DropdownItem
@@ -200,7 +195,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const router = useRouter();
   const router = useRouter();
 
 
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
-  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
 
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
   const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
   const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');

+ 0 - 3
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -2,10 +2,8 @@ import React, {
   FC, memo, useMemo, useRef,
   FC, memo, useMemo, useRef,
 } from 'react';
 } from 'react';
 
 
-import { isServer } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
-import Image from 'next/image';
 import Link from 'next/link';
 import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -17,7 +15,6 @@ import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 
-import { HasChildren } from '../../interfaces/common';
 import GrowiLogo from '../Icons/GrowiLogo';
 import GrowiLogo from '../Icons/GrowiLogo';
 
 
 import { GlobalSearchProps } from './GlobalSearch';
 import { GlobalSearchProps } from './GlobalSearch';

+ 1 - 2
packages/app/src/components/NotAvailable.tsx

@@ -1,13 +1,12 @@
 import React from 'react';
 import React from 'react';
 
 
-import { TFunction } from 'next-i18next';
 import { Disable } from 'react-disable';
 import { Disable } from 'react-disable';
 import { UncontrolledTooltip, UncontrolledTooltipProps } from 'reactstrap';
 import { UncontrolledTooltip, UncontrolledTooltipProps } from 'reactstrap';
 
 
 type NotAvailableProps = {
 type NotAvailableProps = {
   children: JSX.Element
   children: JSX.Element
   isDisabled: boolean
   isDisabled: boolean
-  title: ReturnType<TFunction>
+  title: string
   classNamePrefix?: string
   classNamePrefix?: string
   placement?: UncontrolledTooltipProps['placement']
   placement?: UncontrolledTooltipProps['placement']
 }
 }

+ 11 - 4
packages/app/src/components/NotFoundPage.tsx

@@ -3,19 +3,26 @@ import React, { useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import { DescendantsPageList } from './DescendantsPageList';
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import { PageTimeline } from './PageTimeline';
 import { PageTimeline } from './PageTimeline';
 
 
-const NotFoundPage = (): JSX.Element => {
+
+type NotFoundPageProps = {
+  path: string,
+}
+
+const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const { path } = props;
+
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
       pagelist: {
       pagelist: {
         Icon: PageListIcon,
         Icon: PageListIcon,
-        Content: DescendantsPageListForCurrentPath,
+        Content: () => <DescendantsPageList path={path} />,
         i18n: t('page_list'),
         i18n: t('page_list'),
         index: 0,
         index: 0,
       },
       },
@@ -26,7 +33,7 @@ const NotFoundPage = (): JSX.Element => {
         index: 1,
         index: 1,
       },
       },
     };
     };
-  }, [t]);
+  }, [path, t]);
 
 
   return (
   return (
     <div className="d-edit-none">
     <div className="d-edit-none">

+ 0 - 102
packages/app/src/components/Page/PageContents.tsx

@@ -1,102 +0,0 @@
-import React, { useEffect } from 'react';
-
-import { pagePathUtils } from '@growi/core';
-import { useTranslation } from 'next-i18next';
-import type { HtmlElementNode } from 'rehype-toc';
-
-import { useDrawioModalLauncherForView } from '~/client/services/side-effects/drawio-modal-launcher-for-view';
-import { useHandsontableModalLauncherForView } from '~/client/services/side-effects/handsontable-modal-launcher-for-view';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCurrentPathname } from '~/stores/context';
-import { useEditingMarkdown } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
-import { useViewOptions } from '~/stores/renderer';
-import { useCurrentPageTocNode } from '~/stores/ui';
-import { registerGrowiFacade } from '~/utils/growi-facade';
-import loggerFactory from '~/utils/logger';
-
-import RevisionRenderer from './RevisionRenderer';
-
-
-const logger = loggerFactory('growi:Page');
-
-
-export const PageContents = (): JSX.Element => {
-  const { t } = useTranslation();
-
-  const { data: currentPathname } = useCurrentPathname();
-  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
-
-  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
-
-  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions((toc: HtmlElementNode) => {
-    mutateCurrentPageTocNode(toc);
-  });
-
-  // register to facade
-  useEffect(() => {
-    registerGrowiFacade({
-      markdownRenderer: {
-        optionsMutators: {
-          viewOptionsMutator: mutateRendererOptions,
-        },
-      },
-    });
-  }, [mutateRendererOptions]);
-
-  useHandsontableModalLauncherForView({
-    onSaveSuccess: (newMarkdown) => {
-      toastSuccess(t('toaster.save_succeeded'));
-
-      // rerender
-      if (!isSharedPage) {
-        mutateCurrentPage();
-      }
-      mutateEditingMarkdown(newMarkdown);
-    },
-    onSaveError: (error) => {
-      toastError(error);
-    },
-  });
-
-  useDrawioModalLauncherForView({
-    onSaveSuccess: (newMarkdown) => {
-      toastSuccess(t('toaster.save_succeeded'));
-
-      // rerender
-      if (!isSharedPage) {
-        mutateCurrentPage();
-      }
-      mutateEditingMarkdown(newMarkdown);
-    },
-    onSaveError: (error) => {
-      toastError(error);
-    },
-  });
-
-
-  if (currentPage == null || rendererOptions == null) {
-    const entries = Object.entries({
-      currentPage, rendererOptions,
-    })
-      .map(([key, value]) => [key, value == null ? 'null' : undefined])
-      .filter(([, value]) => value != null);
-
-    logger.warn('Some of materials are missing.', Object.fromEntries(entries));
-
-    return <></>;
-  }
-
-  const { _id: revisionId, body: markdown } = currentPage.revision;
-
-  return (
-    <>
-      { revisionId != null && (
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-      )}
-    </>
-  );
-
-};

+ 39 - 0
packages/app/src/components/Page/PageContentsUtilities.tsx

@@ -0,0 +1,39 @@
+import { useTranslation } from 'next-i18next';
+
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { useDrawioModalLauncherForView } from '~/client/services/side-effects/drawio-modal-launcher-for-view';
+import { useHandsontableModalLauncherForView } from '~/client/services/side-effects/handsontable-modal-launcher-for-view';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useCurrentPageId } from '~/stores/context';
+
+
+export const PageContentsUtilities = (): null => {
+  const { t } = useTranslation();
+
+  const { data: pageId } = useCurrentPageId();
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
+
+  useHandsontableModalLauncherForView({
+    onSaveSuccess: () => {
+      toastSuccess(t('toaster.save_succeeded'));
+
+      updateStateAfterSave?.();
+    },
+    onSaveError: (error) => {
+      toastError(error);
+    },
+  });
+
+  useDrawioModalLauncherForView({
+    onSaveSuccess: () => {
+      toastSuccess(t('toaster.save_succeeded'));
+
+      updateStateAfterSave?.();
+    },
+    onSaveError: (error) => {
+      toastError(error);
+    },
+  });
+
+  return null;
+};

+ 79 - 38
packages/app/src/components/Page/PageView.tsx

@@ -1,13 +1,21 @@
-import React, { useMemo } from 'react';
+import React, {
+  useEffect, useMemo, useRef, useState,
+} from 'react';
+
 
 
 import { type IPagePopulatedToShowRevision, pagePathUtils } from '@growi/core';
 import { type IPagePopulatedToShowRevision, pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
-
+import type { RendererConfig } from '~/interfaces/services/renderer';
+import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import {
 import {
   useIsForbidden, useIsIdenticalPath, useIsNotCreatable, useIsNotFound,
   useIsForbidden, useIsIdenticalPath, useIsNotCreatable, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useViewOptions } from '~/stores/renderer';
 import { useIsMobile } from '~/stores/ui';
 import { useIsMobile } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
+
 
 
 import type { CommentsProps } from '../Comments';
 import type { CommentsProps } from '../Comments';
 import { MainPane } from '../Layout/MainPane';
 import { MainPane } from '../Layout/MainPane';
@@ -17,7 +25,7 @@ import type { PageSideContentsProps } from '../PageSideContents';
 import { UserInfo } from '../User/UserInfo';
 import { UserInfo } from '../User/UserInfo';
 import type { UsersHomePageFooterProps } from '../UsersHomePageFooter';
 import type { UsersHomePageFooterProps } from '../UsersHomePageFooter';
 
 
-import { PageContents } from './PageContents';
+import RevisionRenderer from './RevisionRenderer';
 
 
 import styles from './PageView.module.scss';
 import styles from './PageView.module.scss';
 
 
@@ -29,35 +37,69 @@ const NotCreatablePage = dynamic(() => import('../NotCreatablePage').then(mod =>
 const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
 const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
+const PageContentsUtilities = dynamic(() => import('./PageContentsUtilities').then(mod => mod.PageContentsUtilities), { ssr: false });
 const Comments = dynamic<CommentsProps>(() => import('../Comments').then(mod => mod.Comments), { ssr: false });
 const Comments = dynamic<CommentsProps>(() => import('../Comments').then(mod => mod.Comments), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../UsersHomePageFooter')
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
-
-const IdenticalPathPage = (): JSX.Element => {
-  const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
-  return <IdenticalPathPage />;
-};
+const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
 
 
 
 
 type Props = {
 type Props = {
   pagePath: string,
   pagePath: string,
-  page?: IPagePopulatedToShowRevision,
-  ssrBody?: JSX.Element,
+  rendererConfig: RendererConfig,
+  initialPage?: IPagePopulatedToShowRevision,
 }
 }
 
 
 export const PageView = (props: Props): JSX.Element => {
 export const PageView = (props: Props): JSX.Element => {
+
+  const commentsContainerRef = useRef<HTMLDivElement>(null);
+
+  const [isCommentsLoaded, setCommentsLoaded] = useState(false);
+
   const {
   const {
-    pagePath, page, ssrBody,
+    pagePath, initialPage, rendererConfig,
   } = props;
   } = props;
 
 
-  const pageId = page?._id;
-
   const { data: isIdenticalPathPage } = useIsIdenticalPath();
   const { data: isIdenticalPathPage } = useIsIdenticalPath();
   const { data: isForbidden } = useIsForbidden();
   const { data: isForbidden } = useIsForbidden();
   const { data: isNotCreatable } = useIsNotCreatable();
   const { data: isNotCreatable } = useIsNotCreatable();
-  const { data: isNotFound } = useIsNotFound();
+  const { data: isNotFoundMeta } = useIsNotFound();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
 
 
+  const { data: pageBySWR } = useSWRxCurrentPage();
+  const { data: viewOptions, mutate: mutateRendererOptions } = useViewOptions();
+
+  const page = pageBySWR ?? initialPage;
+  const isNotFound = isNotFoundMeta || page?.revision == null;
+  const isUsersHomePagePath = isUsersHomePage(pagePath);
+
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
+  // ***************************  Auto Scroll  ***************************
+  useEffect(() => {
+    // do nothing if hash is empty
+    const { hash } = window.location;
+    if (hash.length === 0) {
+      return;
+    }
+
+    const targetId = hash.slice(1);
+
+    const target = document.getElementById(targetId);
+    target?.scrollIntoView();
+
+  }, [isCommentsLoaded]);
+  // *******************************  end  *******************************
+
   const specialContents = useMemo(() => {
   const specialContents = useMemo(() => {
     if (isIdenticalPathPage) {
     if (isIdenticalPathPage) {
       return <IdenticalPathPage />;
       return <IdenticalPathPage />;
@@ -68,44 +110,43 @@ export const PageView = (props: Props): JSX.Element => {
     if (isNotCreatable) {
     if (isNotCreatable) {
       return <NotCreatablePage />;
       return <NotCreatablePage />;
     }
     }
-    if (isNotFound) {
-      return <NotFoundPage />;
-    }
-  }, [isForbidden, isIdenticalPathPage, isNotCreatable, isNotFound]);
+  }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
 
   const sideContents = !isNotFound && !isNotCreatable
   const sideContents = !isNotFound && !isNotCreatable
     ? (
     ? (
       <PageSideContents page={page} />
       <PageSideContents page={page} />
     )
     )
-    : <></>;
+    : null;
 
 
-  const footerContents = !isIdenticalPathPage && !isNotFound && page != null
+  const footerContents = !isIdenticalPathPage && !isNotFound
     ? (
     ? (
       <>
       <>
-        { pageId != null && pagePath != null && (
-          <Comments pageId={pageId} pagePath={pagePath} revision={page.revision} />
-        ) }
-        { pagePath != null && isUsersHomePage(pagePath) && (
+        <div id="comments-container" ref={commentsContainerRef}>
+          <Comments pageId={page._id} pagePath={pagePath} revision={page.revision} onLoaded={() => setCommentsLoaded(true)} />
+        </div>
+        { isUsersHomePagePath && (
           <UsersHomePageFooter creatorId={page.creator._id}/>
           <UsersHomePageFooter creatorId={page.creator._id}/>
         ) }
         ) }
         <PageContentFooter page={page} />
         <PageContentFooter page={page} />
       </>
       </>
     )
     )
-    : <></>;
+    : null;
 
 
-  const isUsersHomePagePath = isUsersHomePage(pagePath);
+  const Contents = () => {
+    if (isNotFound) {
+      return <NotFoundPage path={pagePath} />;
+    }
 
 
-  const contents = specialContents != null
-    ? <></>
-    // TODO: show SSR body
-    // : (() => {
-    //   const PageContents = dynamic(() => import('./PageContents').then(mod => mod.PageContents), {
-    //     ssr: false,
-    //     // loading: () => ssrBody ?? <></>,
-    //   });
-    //   return <PageContents />;
-    // })();
-    : <PageContents />;
+    const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
+    const markdown = page.revision.body;
+
+    return (
+      <>
+        <PageContentsUtilities />
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      </>
+    );
+  };
 
 
   return (
   return (
     <MainPane
     <MainPane
@@ -119,7 +160,7 @@ export const PageView = (props: Props): JSX.Element => {
         <>
         <>
           { isUsersHomePagePath && <UserInfo author={page?.creator} /> }
           { isUsersHomePagePath && <UserInfo author={page?.creator} /> }
           <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
           <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
-            { contents }
+            <Contents />
           </div>
           </div>
         </>
         </>
       ) }
       ) }

+ 22 - 60
packages/app/src/components/Page/RevisionLoader.tsx

@@ -1,11 +1,10 @@
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { useState, useEffect } from 'react';
 
 
 import { Ref, IRevision, IRevisionHasId } from '@growi/core';
 import { Ref, IRevision, IRevisionHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { Waypoint } from 'react-waypoint';
 
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
+import { useSWRxPageRevision } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './RevisionRenderer';
 import RevisionRenderer from './RevisionRenderer';
@@ -16,7 +15,6 @@ export type RevisionLoaderProps = {
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
   pageId: string,
   pageId: string,
   revisionId: Ref<IRevision>,
   revisionId: Ref<IRevision>,
-  lazy?: boolean,
   onRevisionLoaded?: (revision: IRevisionHasId) => void,
   onRevisionLoaded?: (revision: IRevisionHasId) => void,
 }
 }
 
 
@@ -34,63 +32,39 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
-    rendererOptions, pageId, revisionId, lazy, onRevisionLoaded,
+    rendererOptions, pageId, revisionId, onRevisionLoaded,
   } = props;
   } = props;
 
 
-  const [isLoading, setIsLoading] = useState<boolean>();
-  const [isLoaded, setIsLoaded] = useState<boolean>();
-  const [markdown, setMarkdown] = useState<string>('');
-  const [errors, setErrors] = useState<any | null>();
-
-  const loadData = useCallback(async() => {
-    if (!isLoaded && !isLoading) {
-      setIsLoading(true);
-    }
+  const { data: pageRevision, isLoading, error } = useSWRxPageRevision(pageId, revisionId);
 
 
-    // load data with REST API
-    try {
-      const res = await apiv3Get(`/revisions/${revisionId}`, { pageId });
+  const [markdown, setMarkdown] = useState<string>('');
 
 
-      setMarkdown(res.data?.revision?.body);
-      setErrors(null);
+  useEffect(() => {
+    if (pageRevision != null) {
+      setMarkdown(pageRevision?.body ?? '');
 
 
       if (onRevisionLoaded != null) {
       if (onRevisionLoaded != null) {
-        onRevisionLoaded(res.data.revision);
+        onRevisionLoaded(pageRevision);
       }
       }
     }
     }
-    catch (errors) {
-      setErrors(errors);
-    }
-    finally {
-      setIsLoaded(true);
-      setIsLoading(false);
-    }
 
 
-  }, [isLoaded, isLoading, onRevisionLoaded, pageId, revisionId]);
+  }, [onRevisionLoaded, pageRevision]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (!lazy) {
-      loadData();
-    }
-  }, [lazy, loadData]);
-
-  const onWaypointChange = (event) => {
-    if (event.currentPosition === Waypoint.above || event.currentPosition === Waypoint.inside) {
-      loadData();
+    if (error != null) {
+      const isForbidden = error != null && error[0].code === 'forbidden-page';
+      if (isForbidden) {
+        setMarkdown(`<i class="icon-exclamation p-1"></i>${t('not_allowed_to_see_this_page')}`);
+      }
+      else {
+        const errorMessages = error.map((error) => {
+          return `<i class="icon-exclamation p-1"></i><span class="text-muted"><em>${error.message}</em></span>`;
+        });
+        setMarkdown(errorMessages.join('\n'));
+      }
     }
     }
-    return;
-  };
+  }, [error, t]);
 
 
-  /* ----- before load ----- */
-  if (lazy && !isLoaded) {
-    return (
-      <Waypoint onPositionChange={onWaypointChange} bottomOffset="-100px">
-        <div></div>
-      </Waypoint>
-    );
-  }
-
-  /* ----- loading ----- */
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <div className="wiki">
       <div className="wiki">
@@ -101,18 +75,6 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
     );
     );
   }
   }
 
 
-  /* ----- after load ----- */
-  const isForbidden = errors != null && errors[0].code === 'forbidden-page';
-  if (isForbidden) {
-    setMarkdown(`<i class="icon-exclamation p-1"></i>${t('not_allowed_to_see_this_page')}`);
-  }
-  else if (errors != null) {
-    const errorMessages = errors.map((error) => {
-      return `<i class="icon-exclamation p-1"></i><span class="text-muted"><em>${error.message}</em></span>`;
-    });
-    setMarkdown(errorMessages.join('\n'));
-  }
-
   return (
   return (
     <RevisionLoaderRoot>
     <RevisionLoaderRoot>
       <RevisionRenderer
       <RevisionRenderer

+ 1 - 1
packages/app/src/components/PageAlert/PageAlerts.tsx

@@ -4,12 +4,12 @@ import dynamic from 'next/dynamic';
 
 
 import { useIsNotFound } from '~/stores/context';
 import { useIsNotFound } from '~/stores/context';
 
 
-import { FixPageGrantAlert } from './FixPageGrantAlert';
 import { OldRevisionAlert } from './OldRevisionAlert';
 import { OldRevisionAlert } from './OldRevisionAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 
 
+const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 // dynamic import because TrashPageAlert uses localStorageMiddleware
 // dynamic import because TrashPageAlert uses localStorageMiddleware
 const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
 const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
 
 

+ 1 - 1
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -114,7 +114,7 @@ export const TrashPageAlert = (): JSX.Element => {
           <br />
           <br />
           <UserPicture user={deleteUser} />
           <UserPicture user={deleteUser} />
           <span className="ml-2">
           <span className="ml-2">
-            Deleted by { deleteUser?.name } at <span data-vrt-blackout-datetime>{deletedAt || pageData?.updatedAt}</span>
+            Deleted by { deleteUser?.name } at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
           </span>
           </span>
         </div>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
         <div className="pt-1 d-flex align-items-end align-items-lg-center">

+ 3 - 2
packages/app/src/components/PageComment/Comment.module.scss

@@ -1,3 +1,4 @@
+@use '../../styles/variables' as var;
 @use '../../styles/bootstrap/init' as bs;
 @use '../../styles/bootstrap/init' as bs;
 @use './_comment-inheritance';
 @use './_comment-inheritance';
 
 
@@ -10,10 +11,10 @@
 
 
   .page-comment {
   .page-comment {
     position: relative;
     position: relative;
-    padding-top: 70px;
-    margin-top: -70px;
     pointer-events: none;
     pointer-events: none;
 
 
+    scroll-margin-top: var.$grw-scroll-margin-top-in-view;
+
     // user name
     // user name
     .page-comment-creator {
     .page-comment-creator {
       margin-top: -0.5em;
       margin-top: -0.5em;

+ 7 - 5
packages/app/src/components/PageComment/Comment.tsx

@@ -80,17 +80,19 @@ export const Comment = (props: CommentProps): JSX.Element => {
     let className = 'page-comment flex-column';
     let className = 'page-comment flex-column';
 
 
     // TODO: fix so that `comment.createdAt` to be type Date https://redmine.weseek.co.jp/issues/113876
     // TODO: fix so that `comment.createdAt` to be type Date https://redmine.weseek.co.jp/issues/113876
-    let commentCreatedAt = comment.createdAt;
-    if (typeof commentCreatedAt === 'string') {
-      commentCreatedAt = parseISO(commentCreatedAt);
-    }
+    const commentCreatedAtFixed = typeof comment.createdAt === 'string'
+      ? parseISO(comment.createdAt)
+      : comment.createdAt;
+    const revisionCreatedAtFixed = typeof revisionCreatedAt === 'string'
+      ? parseISO(revisionCreatedAt)
+      : revisionCreatedAt;
 
 
     // Conditional for called from SearchResultContext
     // Conditional for called from SearchResultContext
     if (revisionId != null && revisionCreatedAt != null) {
     if (revisionId != null && revisionCreatedAt != null) {
       if (comment.revision === revisionId) {
       if (comment.revision === revisionId) {
         className += ' page-comment-current';
         className += ' page-comment-current';
       }
       }
-      else if (commentCreatedAt.getTime() > revisionCreatedAt.getTime()) {
+      else if (commentCreatedAtFixed.getTime() > revisionCreatedAtFixed.getTime()) {
         className += ' page-comment-newer';
         className += ' page-comment-newer';
       }
       }
       else {
       else {

+ 8 - 0
packages/app/src/components/PageDeleteModal.tsx

@@ -83,6 +83,14 @@ const PageDeleteModal: FC = () => {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
   const [errs, setErrs] = useState<Error[] | null>(null);
 
 
+  // initialize when opening modal
+  useEffect(() => {
+    if (isOpened) {
+      setIsDeleteRecursively(true);
+      setIsDeleteCompletely(forceDeleteCompletelyMode);
+    }
+  }, [forceDeleteCompletelyMode, isOpened]);
+
   useEffect(() => {
   useEffect(() => {
     setIsDeleteCompletely(forceDeleteCompletelyMode);
     setIsDeleteCompletely(forceDeleteCompletelyMode);
   }, [forceDeleteCompletelyMode]);
   }, [forceDeleteCompletelyMode]);

+ 24 - 14
packages/app/src/components/PageEditor.tsx

@@ -7,7 +7,7 @@ import nodePath from 'path';
 
 
 
 
 import {
 import {
-  IPageHasId, PageGrant, pathUtils,
+  IPageHasId, pathUtils,
 } from '@growi/core';
 } from '@growi/core';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -16,7 +16,7 @@ import { throttle, debounce } from 'throttle-debounce';
 
 
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
-import { toastError, toastSuccess } from '~/client/util/toastr';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -25,15 +25,16 @@ import {
   useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
   useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
-  useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
+  useCurrentIndentSize, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
   useIsEnabledUnsavedWarning,
   useIsConflict,
   useIsConflict,
   useEditingMarkdown,
   useEditingMarkdown,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import { useConflictDiffModal } from '~/stores/modal';
 import { useConflictDiffModal } from '~/stores/modal';
-import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
-import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import {
+  useCurrentPagePath, useSWRMUTxCurrentPage, useSWRxCurrentPage, useSWRxTagsInfo,
+} from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
 import {
   EditorMode,
   EditorMode,
@@ -74,7 +75,8 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPathname } = useCurrentPathname();
-  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
@@ -90,7 +92,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
-  const { advance: advancePt } = usePageTreeTermManager();
 
 
   const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
   const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
@@ -209,7 +210,7 @@ const PageEditor = React.memo((): JSX.Element => {
       );
       );
 
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
-      advancePt();
+      mutatePageTree();
 
 
       return page;
       return page;
     }
     }
@@ -217,6 +218,7 @@ const PageEditor = React.memo((): JSX.Element => {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error);
       toastError(error);
       if (error.code === 'conflict') {
       if (error.code === 'conflict') {
+        toastWarning('(TBD) resolve conflict');
         // pageContainer.setState({
         // pageContainer.setState({
         //   remoteRevisionId: error.data.revisionId,
         //   remoteRevisionId: error.data.revisionId,
         //   remoteRevisionBody: error.data.revisionBody,
         //   remoteRevisionBody: error.data.revisionBody,
@@ -227,8 +229,7 @@ const PageEditor = React.memo((): JSX.Element => {
       return null;
       return null;
     }
     }
 
 
-  // eslint-disable-next-line max-len
-  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId, advancePt]);
+  }, [currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
 
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
@@ -255,11 +256,20 @@ const PageEditor = React.memo((): JSX.Element => {
     }
     }
 
 
     const page = await save();
     const page = await save();
-    if (page != null) {
+    if (page == null) {
+      return;
+    }
+
+    if (isNotFound) {
+      await router.push(`/${page._id}#edit`);
+    }
+    else {
       updateStateAfterSave?.();
       updateStateAfterSave?.();
-      toastSuccess(t('toaster.save_succeeded'));
     }
     }
-  }, [editorMode, save, t, updateStateAfterSave]);
+    toastSuccess(t('toaster.save_succeeded'));
+    mutateEditorMode(EditorMode.Editor);
+
+  }, [editorMode, isNotFound, mutateEditorMode, router, save, t, updateStateAfterSave]);
 
 
 
 
   /**
   /**

+ 5 - 3
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -36,12 +36,14 @@ const EditorNavbarBottom = (): JSX.Element => {
 
 
   const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
   const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
 
 
+  // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
+  const slackChannelsDataString = slackChannelsData?.toString();
   useEffect(() => {
   useEffect(() => {
-    if (slackChannelsData != null) {
-      setSlackChannelsStr(slackChannelsData.toString());
+    if (editorMode === 'editor') {
+      setSlackChannelsStr(slackChannelsDataString ?? '');
       mutateIsSlackEnabled(false);
       mutateIsSlackEnabled(false);
     }
     }
-  }, [mutateIsSlackEnabled, slackChannelsData]);
+  }, [editorMode, mutateIsSlackEnabled, slackChannelsDataString]);
 
 
   const isSlackEnabledToggleHandler = (bool: boolean) => {
   const isSlackEnabledToggleHandler = (bool: boolean) => {
     mutateIsSlackEnabled(bool, false);
     mutateIsSlackEnabled(bool, false);

+ 1 - 5
packages/app/src/components/PageEditor/Preview.tsx

@@ -3,7 +3,6 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
-import { useEditorSettings } from '~/stores/editor';
 
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 import RevisionRenderer from '../Page/RevisionRenderer';
 
 
@@ -22,12 +21,9 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
     markdown, pagePath,
     markdown, pagePath,
   } = props;
   } = props;
 
 
-  const { data: editorSettings } = useEditorSettings();
-
-
   return (
   return (
     <div
     <div
-      className="page-editor-preview-body"
+      className={`page-editor-preview-body ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       ref={ref}
       ref={ref}
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
         if (props.onScroll != null) {
         if (props.onScroll != null) {

+ 11 - 10
packages/app/src/components/PageEditorByHackmd.tsx

@@ -24,8 +24,10 @@ import {
 import {
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 } from '~/stores/hackmd';
-import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+import {
+  useCurrentPagePath, useSWRMUTxCurrentPage, useSWRxCurrentPage, useSWRxTagsInfo,
+} from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
 import {
   EditorMode,
   EditorMode,
@@ -64,12 +66,12 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: grantData } = useSelectedGrant();
   const { data: grantData } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
   const { data: hackmdUri } = useHackmdUri();
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
-  const { advance: advancePt } = usePageTreeTermManager();
 
 
   const { returnPathForURL } = pathUtils;
   const { returnPathForURL } = pathUtils;
 
 
   // pageData
   // pageData
-  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
+  const { data: pageData } = useSWRxCurrentPage();
+  const { trigger: mutatePageData } = useSWRMUTxCurrentPage();
   const revision = pageData?.revision;
   const revision = pageData?.revision;
 
 
   const [isInitialized, setIsInitialized] = useState(false);
   const [isInitialized, setIsInitialized] = useState(false);
@@ -131,7 +133,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
         mutateIsHackmdDraftUpdatingInRealtime(false);
         mutateIsHackmdDraftUpdatingInRealtime(false);
 
 
         // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
         // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
-        advancePt();
+        mutatePageTree();
       }
       }
       setIsInitialized(false);
       setIsInitialized(false);
       mutateEditorMode(EditorMode.View);
       mutateEditorMode(EditorMode.View);
@@ -141,7 +143,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
       toastError(error.message);
     }
     }
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, isNotFound, mutateEditorMode, router, updateStateAfterSave, mutateIsHackmdDraftUpdatingInRealtime, advancePt]);
+  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, isNotFound, mutateEditorMode, router, updateStateAfterSave, mutateIsHackmdDraftUpdatingInRealtime]);
 
 
   // set handler to save and reload Page
   // set handler to save and reload Page
   useEffect(() => {
   useEffect(() => {
@@ -264,7 +266,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateTagsInfo();
       mutateTagsInfo();
 
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
-      advancePt();
+      mutatePageTree();
 
 
       mutateIsEnabledUnsavedWarning(false);
       mutateIsEnabledUnsavedWarning(false);
 
 
@@ -276,9 +278,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  }, [
-    currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave,
-    saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, advancePt, mutateIsEnabledUnsavedWarning, t]);
+  // eslint-disable-next-line max-len
+  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, mutateIsEnabledUnsavedWarning, t]);
 
 
   /**
   /**
    * onChange event of HackmdEditor handler
    * onChange event of HackmdEditor handler

+ 0 - 30
packages/app/src/components/PagePresentationModal.jsx

@@ -1,30 +0,0 @@
-import React from 'react';
-
-import {
-  Modal, ModalBody,
-} from 'reactstrap';
-
-import { usePagePresentationModal } from '~/stores/modal';
-
-import styles from './PagePresentationModal.module.scss';
-
-const PagePresentationModal = () => {
-
-  const { data: presentationData, close: closePresentationModal } = usePagePresentationModal();
-
-  return (
-    <Modal
-      isOpen={presentationData.isOpened}
-      toggle={closePresentationModal}
-      data-testid="page-presentation-modal"
-      className={`grw-presentation-modal ${styles['grw-presentation-modal']} grw-body-only-modal-expanded`}
-      unmountOnClose={false}
-    >
-      <ModalBody className="modal-body">
-        <iframe src={presentationData.href} />
-      </ModalBody>
-    </Modal>
-  );
-};
-
-export default PagePresentationModal;

+ 25 - 8
packages/app/src/components/PagePresentationModal.module.scss

@@ -1,11 +1,28 @@
+@use '~/styles/_modal';
+
 .grw-presentation-modal :global {
 .grw-presentation-modal :global {
-  .modal-body {
-    background: black;
-
-    iframe {
-      width: 100%;
-      height: 100%;
-      border: 0;
-    }
+
+  @include modal.expand-modal-fullscreen(false, false, 0px);
+
+  .modal-content {
+    background-color: transparent;
+    border-radius: 0;
+  }
+
+  .grw-presentation-controls {
+    position: absolute;
+    top: 1rem;
+    right: 1rem;
+    z-index: 110; // over ".reveal .slides"
+  }
+
+  .close {
+    display: inline-block;
+    width: 3rem;
+    height: 3rem;
+    font-size: 1.5rem;
+    color: var(--color-global);
+    opacity: 0.3;
   }
   }
+
 }
 }

+ 84 - 0
packages/app/src/components/PagePresentationModal.tsx

@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react';
+
+import type { PresentationProps } from '@growi/presentation';
+import dynamic from 'next/dynamic';
+import type { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+import {
+  Modal, ModalBody,
+} from 'reactstrap';
+
+
+import { usePagePresentationModal } from '~/stores/modal';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { usePresentationViewOptions } from '~/stores/renderer';
+import { useNextThemes } from '~/stores/use-next-themes';
+
+
+import styles from './PagePresentationModal.module.scss';
+
+
+const Presentation = dynamic<PresentationProps>(() => import('@growi/presentation').then(mod => mod.Presentation), {
+  ssr: false,
+  loading: () => (
+    <i className="fa fa-4x fa-spinner fa-pulse text-muted"></i>
+  ),
+});
+
+
+const PagePresentationModal = (): JSX.Element => {
+
+  const { data: presentationModalData, close: closePresentationModal } = usePagePresentationModal();
+
+  const { isDarkMode } = useNextThemes();
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: rendererOptions } = usePresentationViewOptions();
+
+  const requestFullscreen = useCallback(() => {
+    document.documentElement.requestFullscreen();
+  }, []);
+
+  const isOpen = presentationModalData?.isOpened ?? false;
+
+  if (!isOpen) {
+    return <></>;
+  }
+
+  const markdown = currentPage?.revision.body;
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      toggle={closePresentationModal}
+      data-testid="page-presentation-modal"
+      className={`grw-presentation-modal ${styles['grw-presentation-modal']}`}
+    >
+      <div className="grw-presentation-controls d-flex">
+        <button className="close btn-fullscreen" type="button" aria-label="fullscreen" onClick={requestFullscreen}>
+          <i className="ti ti-fullscreen" aria-hidden></i>
+        </button>
+        <button className="close btn-close" type="button" aria-label="close" onClick={closePresentationModal}>
+          <i className="ti ti-close" aria-hidden></i>
+        </button>
+      </div>
+      <ModalBody className="modal-body d-flex justify-content-center align-items-center">
+        { rendererOptions != null && (
+          <Presentation
+            options={{
+              rendererOptions: rendererOptions as ReactMarkdownOptions,
+              revealOptions: {
+                embedded: true,
+                hash: true,
+              },
+              isDarkMode,
+            }}
+          >
+            {markdown}
+          </Presentation>
+        ) }
+      </ModalBody>
+    </Modal>
+  );
+};
+
+export default PagePresentationModal;

+ 7 - 10
packages/app/src/components/PageSideContents.tsx

@@ -4,8 +4,6 @@ import { IPageHasId, pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { Link } from 'react-scroll';
 import { Link } from 'react-scroll';
 
 
-import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
-import { useCurrentPathname } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
 
 
 import CountBadge from './Common/CountBadge';
 import CountBadge from './Common/CountBadge';
@@ -20,27 +18,26 @@ const { isTopPage, isUsersHomePage } = pagePathUtils;
 
 
 
 
 export type PageSideContentsProps = {
 export type PageSideContentsProps = {
-  page?: IPageHasId,
+  page: IPageHasId,
   isSharedUser?: boolean,
   isSharedUser?: boolean,
 }
 }
 
 
 export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { data: currentPathname } = useCurrentPathname();
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
 
   const { page, isSharedUser } = props;
   const { page, isSharedUser } = props;
 
 
-  const pagePath = page?.path ?? currentPathname;
-  const isTopPagePath = isTopPage(pagePath ?? '');
-  const isUsersHomePagePath = isUsersHomePage(pagePath ?? '');
+  const pagePath = page.path;
+  const isTopPagePath = isTopPage(pagePath);
+  const isUsersHomePagePath = isUsersHomePage(pagePath);
 
 
   return (
   return (
     <>
     <>
       {/* Page list */}
       {/* Page list */}
       <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
       <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-        { pagePath != null && !isSharedUser && (
+        { !isSharedUser && (
           <button
           <button
             type="button"
             type="button"
             className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
             className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
@@ -57,9 +54,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
       </div>
       </div>
 
 
       {/* Comments */}
       {/* Comments */}
-      { page != null && !isTopPagePath && (
+      { !isTopPagePath && (
         <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
         <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-          <Link to={'page-comments'} offset={-100} {...DEFAULT_AUTO_SCROLL_OPTS}>
+          <Link to={'page-comments'} offset={-120}>
             <button
             <button
               type="button"
               type="button"
               className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
               className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"

+ 3 - 2
packages/app/src/components/PageStatusAlert.tsx

@@ -9,7 +9,7 @@ import {
   useHasDraftOnHackmd, useIsHackmdDraftUpdatingInRealtime, useRevisionIdHackmdSynced,
   useHasDraftOnHackmd, useIsHackmdDraftUpdatingInRealtime, useRevisionIdHackmdSynced,
 } from '~/stores/hackmd';
 } from '~/stores/hackmd';
 import { useConflictDiffModal } from '~/stores/modal';
 import { useConflictDiffModal } from '~/stores/modal';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
 import { useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/stores/remote-latest-page';
 import { useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/stores/remote-latest-page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
@@ -39,7 +39,8 @@ export const PageStatusAlert = (): JSX.Element => {
   const { data: remoteRevisionId } = useRemoteRevisionId();
   const { data: remoteRevisionId } = useRemoteRevisionId();
   const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
 
 
-  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
+  const { data: pageData } = useSWRxCurrentPage();
+  const { trigger: mutatePageData } = useSWRMUTxCurrentPage();
   const revision = pageData?.revision;
   const revision = pageData?.revision;
 
 
   const refreshPage = useCallback(async() => {
   const refreshPage = useCallback(async() => {

+ 1 - 2
packages/app/src/components/PageTimeline.tsx

@@ -32,7 +32,6 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
       <div className="card-body">
       <div className="card-body">
         { rendererOptions != null && (
         { rendererOptions != null && (
           <RevisionLoader
           <RevisionLoader
-            lazy
             rendererOptions={rendererOptions}
             rendererOptions={rendererOptions}
             pageId={page._id}
             pageId={page._id}
             revisionId={page.revision}
             revisionId={page.revision}
@@ -55,7 +54,7 @@ export const PageTimeline = (): JSX.Element => {
 
 
   const handlePage = useCallback(async(selectedPage: number) => {
   const handlePage = useCallback(async(selectedPage: number) => {
     if (currentPagePath == null) { return }
     if (currentPagePath == null) { return }
-    const res = await apiv3Get('/pages/list', { path: currentPagePath, selectedPage });
+    const res = await apiv3Get('/pages/list', { path: currentPagePath, page: selectedPage });
     setTotalPageItems(res.data.totalCount);
     setTotalPageItems(res.data.totalCount);
     setPages(res.data.pages);
     setPages(res.data.pages);
     setLimit(res.data.limit);
     setLimit(res.data.limit);

+ 8 - 8
packages/app/src/components/PrivateLegacyPages.tsx

@@ -18,7 +18,7 @@ import { useCurrentUser } from '~/stores/context';
 import {
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
-import { usePageTreeTermManager, useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import { mutatePageTree, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import {
 import {
   useSWRxSearch,
   useSWRxSearch,
 } from '~/stores/search';
 } from '~/stores/search';
@@ -213,13 +213,12 @@ const PrivateLegacyPages = (): JSX.Element => {
   });
   });
 
 
   const { data: migrationStatus, mutate: mutateMigrationStatus } = useSWRxV5MigrationStatus();
   const { data: migrationStatus, mutate: mutateMigrationStatus } = useSWRxV5MigrationStatus();
-  const { advance: advancePt } = usePageTreeTermManager();
 
 
   const searchInvokedHandler = useCallback((_keyword: string) => {
   const searchInvokedHandler = useCallback((_keyword: string) => {
     mutateMigrationStatus();
     mutateMigrationStatus();
     setKeyword(_keyword);
     setKeyword(_keyword);
     setOffset(0);
     setOffset(0);
-  }, []);
+  }, [mutateMigrationStatus]);
 
 
   const { open: openModal, close: closeModal } = usePrivateLegacyPagesMigrationModal();
   const { open: openModal, close: closeModal } = usePrivateLegacyPagesMigrationModal();
   const { data: socket } = useGlobalSocket();
   const { data: socket } = useGlobalSocket();
@@ -245,7 +244,7 @@ const PrivateLegacyPages = (): JSX.Element => {
       socket?.off(SocketEventName.PageMigrationSuccess);
       socket?.off(SocketEventName.PageMigrationSuccess);
       socket?.off(SocketEventName.PageMigrationError);
       socket?.off(SocketEventName.PageMigrationError);
     };
     };
-  }, [socket]);
+  }, [socket, t]);
 
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
     const instance = searchPageBaseRef.current;
     const instance = searchPageBaseRef.current;
@@ -315,10 +314,10 @@ const PrivateLegacyPages = (): JSX.Element => {
         closeModal();
         closeModal();
         mutateMigrationStatus();
         mutateMigrationStatus();
         mutate();
         mutate();
-        advancePt();
+        mutatePageTree();
       },
       },
     );
     );
-  }, [data, mutate, openModal, closeModal, mutateMigrationStatus]);
+  }, [data, openModal, t, closeModal, mutateMigrationStatus, mutate]);
 
 
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
   const pagingSizeChangedHandler = useCallback((pagingSize: number) => {
     setOffset(0);
     setOffset(0);
@@ -381,7 +380,8 @@ const PrivateLegacyPages = (): JSX.Element => {
         {isAdmin && renderOpenModalButton()}
         {isAdmin && renderOpenModalButton()}
       </div>
       </div>
     );
     );
-  }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
+  // eslint-disable-next-line max-len
+  }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isAdmin, isControlEnabled, renderOpenModalButton, selectAllCheckboxChangedHandler, t]);
 
 
   const searchControl = useMemo(() => {
   const searchControl = useMemo(() => {
     return (
     return (
@@ -455,7 +455,7 @@ const PrivateLegacyPages = (): JSX.Element => {
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));
             setOpenConvertModal(false);
             setOpenConvertModal(false);
             mutate();
             mutate();
-            advancePt();
+            mutatePageTree();
           }
           }
           catch (errs) {
           catch (errs) {
             if (errs.length === 1) {
             if (errs.length === 1) {

+ 2 - 4
packages/app/src/components/PutbackPageModal.jsx

@@ -7,9 +7,8 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
-import { PathAlreadyExistsError } from '~/server/models/errors';
 import { usePutBackPageModal } from '~/stores/modal';
 import { usePutBackPageModal } from '~/stores/modal';
-import { usePageInfoTermManager } from '~/stores/page';
+import { mutateAllPageInfo } from '~/stores/page';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
@@ -17,7 +16,6 @@ const PutBackPageModal = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
-  const { advance: advancePi } = usePageInfoTermManager();
   const { isOpened, page } = pageDataToRevert;
   const { isOpened, page } = pageDataToRevert;
   const { pageId, path } = page;
   const { pageId, path } = page;
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
@@ -43,7 +41,7 @@ const PutBackPageModal = () => {
         page_id: pageId,
         page_id: pageId,
         recursively,
         recursively,
       });
       });
-      advancePi();
+      mutateAllPageInfo();
 
 
       if (onPutBacked != null) {
       if (onPutBacked != null) {
         onPutBacked(response.page.path);
         onPutBacked(response.page.path);

+ 14 - 1
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -85,7 +85,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
     activateByHash(window.location.href);
     activateByHash(window.location.href);
   }, [activateByHash]);
   }, [activateByHash]);
 
 
-  // update isActive when hash is changed
+  // update isActive when hash is changed by next router
   useEffect(() => {
   useEffect(() => {
     router.events.on('hashChangeComplete', activateByHash);
     router.events.on('hashChangeComplete', activateByHash);
 
 
@@ -94,6 +94,19 @@ export const Header = (props: HeaderProps): JSX.Element => {
     };
     };
   }, [activateByHash, router.events]);
   }, [activateByHash, router.events]);
 
 
+  // update isActive when hash is changed
+  useEffect(() => {
+    const activeByHashWrapper = (e: HashChangeEvent) => {
+      activateByHash(e.newURL);
+    };
+
+    window.addEventListener('hashchange', activeByHashWrapper);
+
+    return () => {
+      window.removeEventListener('hashchange', activeByHashWrapper);
+    };
+  }, [activateByHash, router.events]);
+
   const showEditButton = !isGuestUser && !isSharedUser && shareLinkId == null;
   const showEditButton = !isGuestUser && !isSharedUser && shareLinkId == null;
 
 
   return (
   return (

+ 13 - 14
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,7 +1,5 @@
 import Link, { LinkProps } from 'next/link';
 import Link, { LinkProps } from 'next/link';
-import { Link as ScrollLink } from 'react-scroll';
 
 
-import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { useSiteUrl } from '~/stores/context';
 import { useSiteUrl } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -30,9 +28,10 @@ type Props = Omit<LinkProps, 'href'> & {
   className?: string,
   className?: string,
 };
 };
 
 
-export const NextLink = ({
-  href, children, className, ...props
-}: Props): JSX.Element => {
+export const NextLink = (props: Props): JSX.Element => {
+  const {
+    href, children, className, ...rest
+  } = props;
 
 
   const { data: siteUrl } = useSiteUrl();
   const { data: siteUrl } = useSiteUrl();
 
 
@@ -40,29 +39,29 @@ export const NextLink = ({
     return <a className={className}>{children}</a>;
     return <a className={className}>{children}</a>;
   }
   }
 
 
+  // extract 'data-*' props
+  const dataAttributes = Object.fromEntries(
+    Object.entries(rest).filter(([key]) => key.startsWith('data-')),
+  );
+
   // when href is an anchor link
   // when href is an anchor link
   if (isAnchorLink(href)) {
   if (isAnchorLink(href)) {
-    const to = href.slice(1);
     return (
     return (
-      <Link href={href} scroll={false}>
-        <ScrollLink href={href} to={to} className={className} offset={-100} {...DEFAULT_AUTO_SCROLL_OPTS}>
-          {children}
-        </ScrollLink>
-      </Link>
+      <a href={href} className={className} {...dataAttributes}>{children}</a>
     );
     );
   }
   }
 
 
   if (isExternalLink(href, siteUrl)) {
   if (isExternalLink(href, siteUrl)) {
     return (
     return (
-      <a href={href} className={className} target="_blank" rel="noopener noreferrer">
+      <a href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
         {children}&nbsp;<i className='icon-share-alt small'></i>
         {children}&nbsp;<i className='icon-share-alt small'></i>
       </a>
       </a>
     );
     );
   }
   }
 
 
   return (
   return (
-    <Link {...props} href={href} prefetch={false}>
-      <a href={href} className={className}>{children}</a>
+    <Link {...rest} href={href} prefetch={false}>
+      <a href={href} className={className} {...dataAttributes}>{children}</a>
     </Link>
     </Link>
   );
   );
 };
 };

+ 1 - 2
packages/app/src/components/SearchPage.tsx

@@ -216,8 +216,7 @@ export const SearchPage = (): JSX.Element => {
         initialSearchConditions={initialSearchConditions}
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
         onSearchInvoked={searchInvokedHandler}
         allControl={allControl}
         allControl={allControl}
-      >
-      </SearchControl>
+      />
     );
     );
   }, [allControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
   }, [allControl, initialSearchConditions, isSearchServiceReachable, searchInvokedHandler]);
 
 

+ 2 - 2
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -1,5 +1,5 @@
 import React, {
 import React, {
-  FC, useCallback, useEffect, useState,
+  useCallback, useEffect, useState,
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -23,7 +23,7 @@ type Props = {
   allControl: React.ReactNode,
   allControl: React.ReactNode,
 }
 }
 
 
-const SearchControl: FC <Props> = React.memo((props: Props) => {
+const SearchControl = React.memo((props: Props): JSX.Element => {
 
 
   const {
   const {
     isSearchServiceReachable,
     isSearchServiceReachable,

+ 2 - 5
packages/app/src/components/SearchPage/SearchPageBase.tsx

@@ -11,7 +11,7 @@ import { IFormattedSearchResult, IPageWithSearchMeta } from '~/interfaces/search
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { usePageDeleteModal } from '~/stores/modal';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+import { mutatePageTree } from '~/stores/page-listing';
 
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 
@@ -228,9 +228,6 @@ export const usePageDeleteModalForBulkDeletion = (
 
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
 
-  // for PageTree mutation
-  const { advance: advancePt } = usePageTreeTermManager();
-
   return () => {
   return () => {
     if (data == null) {
     if (data == null) {
       return;
       return;
@@ -260,7 +257,7 @@ export const usePageDeleteModalForBulkDeletion = (
         else {
         else {
           toastSuccess(t('deleted_pages_completely', { path }));
           toastSuccess(t('deleted_pages_completely', { path }));
         }
         }
-        advancePt();
+        mutatePageTree();
 
 
         if (onDeleted != null) {
         if (onDeleted != null) {
           onDeleted(...args);
           onDeleted(...args);

+ 24 - 29
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -11,23 +11,23 @@ import { DropdownItem } from 'reactstrap';
 
 
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
-import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
-import { IPageWithSearchMeta } from '~/interfaces/search';
-import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
+import type { IPageWithSearchMeta } from '~/interfaces/search';
+import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useCurrentUser, useIsContainerFluid } from '~/stores/context';
 import { useCurrentUser, useIsContainerFluid } from '~/stores/context';
 import {
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
-import { useDescendantsPageListForCurrentPathTermManager, usePageTreeTermManager } from '~/stores/page-listing';
+import { mutatePageList, mutatePageTree } from '~/stores/page-listing';
 import { useSearchResultOptions } from '~/stores/renderer';
 import { useSearchResultOptions } from '~/stores/renderer';
-import { useFullTextSearchTermManager } from '~/stores/search';
+import { mutateSearching } from '~/stores/search';
 
 
-import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
-import { GrowiSubNavigationProps } from '../Navbar/GrowiSubNavigation';
-import { SubNavButtonsProps } from '../Navbar/SubNavButtons';
-import { ROOT_ELEM_ID as RevisionLoaderRoomElemId, RevisionLoaderProps } from '../Page/RevisionLoader';
-import { ROOT_ELEM_ID as PageCommentRootElemId, PageCommentProps } from '../PageComment';
-import { PageContentFooterProps } from '../PageContentFooter';
+import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import type { GrowiSubNavigationProps } from '../Navbar/GrowiSubNavigation';
+import type { SubNavButtonsProps } from '../Navbar/SubNavButtons';
+import { ROOT_ELEM_ID as RevisionLoaderRoomElemId, type RevisionLoaderProps } from '../Page/RevisionLoader';
+import { ROOT_ELEM_ID as PageCommentRootElemId, type PageCommentProps } from '../PageComment';
+import type { PageContentFooterProps } from '../PageContentFooter';
 
 
 import styles from './SearchResultContent.module.scss';
 import styles from './SearchResultContent.module.scss';
 
 
@@ -91,17 +91,12 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const [isRevisionLoaded, setRevisionLoaded] = useState(false);
   const [isRevisionLoaded, setRevisionLoaded] = useState(false);
   const [isPageCommentLoaded, setPageCommentLoaded] = useState(false);
   const [isPageCommentLoaded, setPageCommentLoaded] = useState(false);
 
 
-  // for mutation
-  const { advance: advancePt } = usePageTreeTermManager();
-  const { advance: advanceFts } = useFullTextSearchTermManager();
-  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
-
   // ***************************  Auto Scroll  ***************************
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
   useEffect(() => {
     const scrollElement = scrollElementRef.current;
     const scrollElement = scrollElementRef.current;
     if (scrollElement == null) return;
     if (scrollElement == null) return;
 
 
-    const observerCallback = (mutationRecords:MutationRecord[], thisObs: MutationObserver) => {
+    const observerCallback = (mutationRecords:MutationRecord[]) => {
       mutationRecords.forEach((record:MutationRecord) => {
       mutationRecords.forEach((record:MutationRecord) => {
         const target = record.target as HTMLElement;
         const target = record.target as HTMLElement;
 
 
@@ -167,23 +162,23 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       toastSuccess(t('duplicated_pages', { fromPath }));
       toastSuccess(t('duplicated_pages', { fromPath }));
 
 
-      advancePt();
-      advanceFts();
-      advanceDpl();
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
     };
     };
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
-  }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
+  }, [openDuplicateModal, t]);
 
 
   const renameItemClickedHandler = useCallback((pageToRename: IPageToRenameWithMeta) => {
   const renameItemClickedHandler = useCallback((pageToRename: IPageToRenameWithMeta) => {
     const renamedHandler: OnRenamedFunction = (path) => {
     const renamedHandler: OnRenamedFunction = (path) => {
       toastSuccess(t('renamed_pages', { path }));
       toastSuccess(t('renamed_pages', { path }));
 
 
-      advancePt();
-      advanceFts();
-      advanceDpl();
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
     };
     };
     openRenameModal(pageToRename, { onRenamed: renamedHandler });
     openRenameModal(pageToRename, { onRenamed: renamedHandler });
-  }, [advanceDpl, advanceFts, advancePt, openRenameModal, t]);
+  }, [openRenameModal, t]);
 
 
   const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
   const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
     if (typeof pathOrPathsToDelete !== 'string') {
     if (typeof pathOrPathsToDelete !== 'string') {
@@ -197,10 +192,10 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     else {
     else {
       toastSuccess(t('deleted_pages', { path }));
       toastSuccess(t('deleted_pages', { path }));
     }
     }
-    advancePt();
-    advanceFts();
-    advanceDpl();
-  }, [advanceDpl, advanceFts, advancePt, t]);
+    mutatePageTree();
+    mutateSearching();
+    mutatePageList();
+  }, [t]);
 
 
   const deleteItemClickedHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
   const deleteItemClickedHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });

+ 15 - 19
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -11,10 +11,9 @@ import {
   IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
   IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IPageSearchMeta, IPageWithSearchMeta } from '~/interfaces/search';
 import { IPageSearchMeta, IPageWithSearchMeta } from '~/interfaces/search';
-import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
-import { useSWRxPageInfoForList, usePageTreeTermManager } from '~/stores/page-listing';
-import { useFullTextSearchTermManager } from '~/stores/search';
+import { mutatePageTree, useSWRxPageInfoForList } from '~/stores/page-listing';
+import { mutateSearching } from '~/stores/search';
 
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 import { PageListItemL } from '../PageList/PageListItemL';
@@ -44,10 +43,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, null, true, true);
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, null, true, true);
 
 
-  // for mutation
-  const { advance: advancePt } = usePageTreeTermManager();
-  const { advance: advanceFts } = useFullTextSearchTermManager();
-
   const itemsRef = useRef<(ISelectable|null)[]>([]);
   const itemsRef = useRef<(ISelectable|null)[]>([]);
 
 
   // publish selectAll()
   // publish selectAll()
@@ -95,20 +90,21 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   }
   }
 
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  const duplicatedHandler : OnDuplicatedFunction = (fromPath, toPath) => {
+  const duplicatedHandler = useCallback((fromPath, toPath) => {
     toastSuccess(t('duplicated_pages', { fromPath }));
     toastSuccess(t('duplicated_pages', { fromPath }));
 
 
-    advancePt();
-    advanceFts();
-  };
+    mutatePageTree();
+    mutateSearching();
+  }, [t]);
 
 
-  const renamedHandler: OnRenamedFunction = (path) => {
+  const renamedHandler = useCallback((path) => {
     toastSuccess(t('renamed_pages', { path }));
     toastSuccess(t('renamed_pages', { path }));
 
 
-    advancePt();
-    advanceFts();
-  };
-  const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+    mutatePageTree();
+    mutateSearching();
+  }, [t]);
+
+  const deletedHandler = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
     if (typeof pathOrPathsToDelete !== 'string') {
     if (typeof pathOrPathsToDelete !== 'string') {
       return;
       return;
     }
     }
@@ -121,9 +117,9 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     else {
     else {
       toastSuccess(t('deleted_pages', { path }));
       toastSuccess(t('deleted_pages', { path }));
     }
     }
-    advancePt();
-    advanceFts();
-  };
+    mutatePageTree();
+    mutateSearching();
+  }, [t]);
 
 
   return (
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">

+ 0 - 64
packages/app/src/components/ShareLink/ShareLinkPageContents.tsx

@@ -1,64 +0,0 @@
-import React, { useEffect } from 'react';
-
-import type { IPagePopulatedToShowRevision } from '@growi/core';
-import type { HtmlElementNode } from 'rehype-toc';
-
-import { useViewOptions } from '~/stores/renderer';
-import { useCurrentPageTocNode } from '~/stores/ui';
-import { registerGrowiFacade } from '~/utils/growi-facade';
-import loggerFactory from '~/utils/logger';
-
-import RevisionRenderer from '../Page/RevisionRenderer';
-
-
-const logger = loggerFactory('growi:Page');
-
-
-export type ShareLinkPageContentsProps = {
-  page?: IPagePopulatedToShowRevision,
-}
-
-export const ShareLinkPageContents = (props: ShareLinkPageContentsProps): JSX.Element => {
-  const { page } = props;
-
-  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
-
-  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions((toc: HtmlElementNode) => {
-    mutateCurrentPageTocNode(toc);
-  });
-
-  // register to facade
-  useEffect(() => {
-    registerGrowiFacade({
-      markdownRenderer: {
-        optionsMutators: {
-          viewOptionsMutator: mutateRendererOptions,
-        },
-      },
-    });
-  }, [mutateRendererOptions]);
-
-
-  if (page == null || rendererOptions == null) {
-    const entries = Object.entries({
-      page, rendererOptions,
-    })
-      .map(([key, value]) => [key, value == null ? 'null' : undefined])
-      .filter(([, value]) => value != null);
-
-    logger.warn('Some of materials are missing.', Object.fromEntries(entries));
-
-    return <></>;
-  }
-
-  const { _id: revisionId, body: markdown } = page.revision;
-
-  return (
-    <>
-      { revisionId != null && (
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-      )}
-    </>
-  );
-
-};

+ 124 - 0
packages/app/src/components/ShareLink/ShareLinkPageView.tsx

@@ -0,0 +1,124 @@
+import React, { useEffect, useMemo } from 'react';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import dynamic from 'next/dynamic';
+
+import type { RendererConfig } from '~/interfaces/services/renderer';
+import type { IShareLinkHasId } from '~/interfaces/share-link';
+import { generateSSRViewOptions } from '~/services/renderer/renderer';
+import { useIsNotFound } from '~/stores/context';
+import { useViewOptions } from '~/stores/renderer';
+import { registerGrowiFacade } from '~/utils/growi-facade';
+import loggerFactory from '~/utils/logger';
+
+import { MainPane } from '../Layout/MainPane';
+import RevisionRenderer from '../Page/RevisionRenderer';
+import ShareLinkAlert from '../Page/ShareLinkAlert';
+import type { PageSideContentsProps } from '../PageSideContents';
+
+
+const logger = loggerFactory('growi:Page');
+
+
+const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
+const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
+
+
+type Props = {
+  pagePath: string,
+  rendererConfig: RendererConfig,
+  page?: IPagePopulatedToShowRevision,
+  shareLink?: IShareLinkHasId,
+  isExpired: boolean,
+  disableLinkSharing: boolean,
+}
+
+export const ShareLinkPageView = (props: Props): JSX.Element => {
+  const {
+    pagePath, rendererConfig,
+    page, shareLink,
+    isExpired, disableLinkSharing,
+  } = props;
+
+  const { data: isNotFoundMeta } = useIsNotFound();
+
+  const { data: viewOptions, mutate: mutateRendererOptions } = useViewOptions();
+
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
+  const isNotFound = isNotFoundMeta || page == null || shareLink == null;
+
+  const specialContents = useMemo(() => {
+    if (disableLinkSharing) {
+      return <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />;
+    }
+  }, [disableLinkSharing, props.disableLinkSharing]);
+
+  const sideContents = !isNotFound
+    ? (
+      <PageSideContents page={page} />
+    )
+    : null;
+
+
+  const Contents = () => {
+    if (isNotFound) {
+      return <></>;
+    }
+
+    if (isExpired) {
+      return (
+        <>
+          <h2 className="text-muted mt-4">
+            <i className="icon-ban" aria-hidden="true" />
+            <span> Page is expired</span>
+          </h2>
+        </>
+      );
+    }
+
+    const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
+    const markdown = page.revision.body;
+
+    return (
+      <>
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      </>
+    );
+  };
+
+  return (
+    <MainPane
+      sideContents={sideContents}
+    >
+      { specialContents }
+      { specialContents == null && (
+        <>
+          { isNotFound && (
+            <h2 className="text-muted mt-4">
+              <i className="icon-ban" aria-hidden="true" />
+              <span> Page is not found</span>
+            </h2>
+          ) }
+          { !isNotFound && (
+            <>
+              <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+              <div className="mb-5">
+                <Contents />
+              </div>
+            </>
+          ) }
+        </>
+      ) }
+    </MainPane>
+  );
+};

+ 6 - 4
packages/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -1,11 +1,12 @@
 import React, {
 import React, {
   Ref, useEffect, useState,
   Ref, useEffect, useState,
 } from 'react';
 } from 'react';
+
 import type { SWRInfiniteResponse } from 'swr/infinite';
 import type { SWRInfiniteResponse } from 'swr/infinite';
 
 
 type Props<T> = {
 type Props<T> = {
   swrInifiniteResponse : SWRInfiniteResponse<T>
   swrInifiniteResponse : SWRInfiniteResponse<T>
-  children: React.ReactChild | ((item: T) => React.ReactNode),
+  children: React.ReactNode,
   loadingIndicator?: React.ReactNode
   loadingIndicator?: React.ReactNode
   endingIndicator?: React.ReactNode
   endingIndicator?: React.ReactNode
   isReachingEnd?: boolean,
   isReachingEnd?: boolean,
@@ -39,7 +40,7 @@ const LoadingIndicator = (): React.ReactElement => {
 const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
 const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
   const {
   const {
     swrInifiniteResponse: {
     swrInifiniteResponse: {
-      setSize, data, isValidating,
+      setSize, isValidating,
     },
     },
     children,
     children,
     loadingIndicator,
     loadingIndicator,
@@ -54,11 +55,12 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
     if (intersecting && !isValidating && !isReachingEnd) {
     if (intersecting && !isValidating && !isReachingEnd) {
       setSize(size => size + 1);
       setSize(size => size + 1);
     }
     }
-  }, [setSize, intersecting]);
+  }, [setSize, intersecting, isValidating, isReachingEnd]);
 
 
   return (
   return (
     <>
     <>
-      {typeof children === 'function' ? data?.map(item => children(item)) : children}
+      { children }
+
       <div style={{ position: 'relative' }}>
       <div style={{ position: 'relative' }}>
         <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
         <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
         {isReachingEnd
         {isReachingEnd

+ 22 - 26
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -2,6 +2,8 @@ import React, {
   useEffect, useRef, useState, useMemo, useCallback,
   useEffect, useRef, useState, useMemo, useCallback,
 } from 'react';
 } from 'react';
 
 
+import path from 'path';
+
 import { Nullable } from '@growi/core';
 import { Nullable } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -16,11 +18,11 @@ import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import {
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
-import { useCurrentPagePath, usePageInfoTermManager, useSWRxCurrentPage } from '~/stores/page';
+import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import {
 import {
-  usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage, useDescendantsPageListForCurrentPathTermManager,
+  useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
-import { useFullTextSearchTermManager } from '~/stores/search';
+import { mutateSearching } from '~/stores/search';
 import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -117,11 +119,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
 
 
   // for mutation
   // for mutation
-  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const { advance: advancePt } = usePageTreeTermManager();
-  const { advance: advanceFts } = useFullTextSearchTermManager();
-  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
-  const { advance: advancePi } = usePageInfoTermManager();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
 
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
 
 
@@ -151,27 +149,27 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   }, [socket, ptDescCountMap, updatePtDescCountMap]);
   }, [socket, ptDescCountMap, updatePtDescCountMap]);
 
 
   const onRenamed = useCallback((fromPath: string | undefined, toPath: string) => {
   const onRenamed = useCallback((fromPath: string | undefined, toPath: string) => {
-    advancePt();
-    advanceFts();
-    advanceDpl();
+    mutatePageTree();
+    mutateSearching();
+    mutatePageList();
 
 
     if (currentPagePath === fromPath || currentPagePath === toPath) {
     if (currentPagePath === fromPath || currentPagePath === toPath) {
       mutateCurrentPage();
       mutateCurrentPage();
     }
     }
-  }, [advanceDpl, advanceFts, advancePt, currentPagePath, mutateCurrentPage]);
+  }, [currentPagePath, mutateCurrentPage]);
 
 
   const onClickDuplicateMenuItem = useCallback((pageToDuplicate: IPageForPageDuplicateModal) => {
   const onClickDuplicateMenuItem = useCallback((pageToDuplicate: IPageForPageDuplicateModal) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       toastSuccess(t('duplicated_pages', { fromPath }));
       toastSuccess(t('duplicated_pages', { fromPath }));
 
 
-      advancePt();
-      advanceFts();
-      advanceDpl();
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
     };
     };
 
 
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
     openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
-  }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
+  }, [openDuplicateModal, t]);
 
 
   const onClickDeleteMenuItem = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
   const onClickDeleteMenuItem = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -179,28 +177,26 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
         return;
         return;
       }
       }
 
 
-      const path = pathOrPathsToDelete;
-
       if (isCompletely) {
       if (isCompletely) {
-        toastSuccess(t('deleted_pages_completely', { path }));
+        toastSuccess(t('deleted_pages_completely', { path: pathOrPathsToDelete }));
       }
       }
       else {
       else {
-        toastSuccess(t('deleted_pages', { path }));
+        toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
       }
       }
 
 
-      advancePt();
-      advanceFts();
-      advanceDpl();
-      advancePi();
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
+      mutateAllPageInfo();
 
 
       if (currentPagePath === pathOrPathsToDelete) {
       if (currentPagePath === pathOrPathsToDelete) {
         mutateCurrentPage();
         mutateCurrentPage();
-        router.push(`/trash${pathOrPathsToDelete}`);
+        router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
       }
       }
     };
     };
 
 
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [advanceDpl, advanceFts, advancePi, advancePt, currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
+  }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
 
 
   // ***************************  Scroll on init ***************************
   // ***************************  Scroll on init ***************************
   const scrollOnInit = useCallback(() => {
   const scrollOnInit = useCallback(() => {

+ 12 - 11
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -10,7 +10,7 @@ import Link from 'next/link';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
-import { useSWRInifinitexRecentlyUpdated } from '~/stores/page-listing';
+import { useSWRINFxRecentlyUpdated } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import FormattedDistanceDate from '../FormattedDistanceDate';
@@ -19,7 +19,6 @@ import InfiniteScroll from './InfiniteScroll';
 import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
 import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
 import RecentChangesContentSkeleton from './Skeleton/RecentChangesContentSkeleton';
 import RecentChangesContentSkeleton from './Skeleton/RecentChangesContentSkeleton';
 
 
-import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import styles from './RecentChanges.module.scss';
 import styles from './RecentChanges.module.scss';
 
 
 
 
@@ -104,13 +103,14 @@ const RecentChanges = (): JSX.Element => {
 
 
   const PER_PAGE = 20;
   const PER_PAGE = 20;
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const swrInifinitexRecentlyUpdated = useSWRInifinitexRecentlyUpdated();
-  const { data: dataRecentlyUpdated, error, mutate: mutateRecentlyUpdated } = swrInifinitexRecentlyUpdated;
+  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(PER_PAGE);
+  const {
+    data, mutate, isLoading,
+  } = swrInifinitexRecentlyUpdated;
 
 
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-  const isEmpty = dataRecentlyUpdated?.[0].length === 0;
-  const isLoading = error == null && dataRecentlyUpdated === undefined;
-  const isReachingEnd = isEmpty || (dataRecentlyUpdated && dataRecentlyUpdated[dataRecentlyUpdated.length - 1]?.length < PER_PAGE);
+  const isEmpty = data?.[0]?.pages.length === 0;
+  const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
 
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
@@ -132,7 +132,7 @@ const RecentChanges = (): JSX.Element => {
     <div className="px-3" data-testid="grw-recent-changes">
     <div className="px-3" data-testid="grw-recent-changes">
       <div className="grw-sidebar-content-header py-3 d-flex">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>
         <h3 className="mb-0 text-nowrap">{t('Recent Changes')}</h3>
-        <SidebarHeaderReloadButton onClick={() => mutateRecentlyUpdated()}/>
+        <SidebarHeaderReloadButton onClick={() => mutate()}/>
         <div className="d-flex align-items-center">
         <div className="d-flex align-items-center">
           <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} custom-control custom-switch ml-1`}>
           <div className={`grw-recent-changes-resize-button ${styles['grw-recent-changes-resize-button']} custom-control custom-switch ml-1`}>
             <input
             <input
@@ -155,9 +155,10 @@ const RecentChanges = (): JSX.Element => {
                 swrInifiniteResponse={swrInifinitexRecentlyUpdated}
                 swrInifiniteResponse={swrInifinitexRecentlyUpdated}
                 isReachingEnd={isReachingEnd}
                 isReachingEnd={isReachingEnd}
               >
               >
-                {pages => pages.map(
-                  page => <PageItem key={page._id} page={page} isSmall={isRecentChangesSidebarSmall} />,
-                )
+                { data != null && data.map(apiResult => apiResult.pages).flat()
+                  .map(page => (
+                    <PageItem key={page._id} page={page} isSmall={isRecentChangesSidebarSmall} />
+                  ))
                 }
                 }
               </InfiniteScroll>
               </InfiniteScroll>
             </ul>
             </ul>

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

@@ -1,4 +1,6 @@
-import React, { FC, memo, useCallback } from 'react';
+import React, {
+  FC, memo, useCallback, useEffect, useState,
+} from 'react';
 
 
 import Link from 'next/link';
 import Link from 'next/link';
 
 
@@ -80,10 +82,14 @@ export const SidebarNav: FC<Props> = (props: Props) => {
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
-  const isAdmin = currentUser?.admin;
+  const [isAdmin, setAdmin] = useState(false);
 
 
   const { onItemSelected } = props;
   const { onItemSelected } = props;
 
 
+  useEffect(() => {
+    setAdmin(currentUser?.admin === true);
+  }, [currentUser?.admin]);
+
   return (
   return (
     <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`} data-vrt-blackout-sidebar-nav>
     <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`} data-vrt-blackout-sidebar-nav>
       <div className="grw-sidebar-nav-primary-container">
       <div className="grw-sidebar-nav-primary-container">

+ 2 - 1
packages/app/src/components/StickyStretchableScroller.tsx

@@ -15,6 +15,7 @@ export type StickyStretchableScrollerProps = {
   stickyElemSelector: string,
   stickyElemSelector: string,
   simplebarRef?: (ref: RefObject<SimpleBar>) => void,
   simplebarRef?: (ref: RefObject<SimpleBar>) => void,
   calcViewHeight?: (scrollElement: HTMLElement) => number,
   calcViewHeight?: (scrollElement: HTMLElement) => number,
+  children?: JSX.Element,
 }
 }
 
 
 /**
 /**
@@ -39,7 +40,7 @@ export type StickyStretchableScrollerProps = {
     </StickyStretchableScroller>
     </StickyStretchableScroller>
   );
   );
  */
  */
-export const StickyStretchableScroller: FC<StickyStretchableScrollerProps> = (props) => {
+export const StickyStretchableScroller = (props: StickyStretchableScrollerProps): JSX.Element => {
 
 
   const {
   const {
     children, stickyElemSelector, calcViewHeight, simplebarRef: setSimplebarRef,
     children, stickyElemSelector, calcViewHeight, simplebarRef: setSimplebarRef,

+ 24 - 6
packages/app/src/components/TrashPageList.tsx

@@ -1,6 +1,7 @@
-import React, { FC, useMemo, useCallback } from 'react';
+import React, { useMemo, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 
 
 import { toastSuccess } from '~/client/util/apiNotification';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
 import {
@@ -9,13 +10,18 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { useShowPageLimitationXL } from '~/stores/context';
 import { useShowPageLimitationXL } from '~/stores/context';
 import { useEmptyTrashModal } from '~/stores/modal';
 import { useEmptyTrashModal } from '~/stores/modal';
-import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
+import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page-listing';
 
 
+import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import { DescendantsPageListProps } from './DescendantsPageList';
 import EmptyTrashButton from './EmptyTrashButton';
 import EmptyTrashButton from './EmptyTrashButton';
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 
 
+
+const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+
+
 const convertToIDataWithMeta = (page) => {
 const convertToIDataWithMeta = (page) => {
   return { data: page };
   return { data: page };
 };
 };
@@ -23,7 +29,7 @@ const convertToIDataWithMeta = (page) => {
 const useEmptyTrashButton = () => {
 const useEmptyTrashButton = () => {
 
 
   const { data: limit } = useShowPageLimitationXL();
   const { data: limit } = useShowPageLimitationXL();
-  const { data: pagingResult, mutate: mutatePageLists } = useSWRxDescendantsPageListForCurrrentPath(1, limit);
+  const { data: pagingResult, mutate: mutatePageLists } = useSWRxPageList('/trash', 1, limit);
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { open: openEmptyTrashModal } = useEmptyTrashModal();
   const { open: openEmptyTrashModal } = useEmptyTrashModal();
 
 
@@ -59,7 +65,19 @@ const useEmptyTrashButton = () => {
   return emptyTrashButton;
   return emptyTrashButton;
 };
 };
 
 
-export const TrashPageList: FC = () => {
+const DescendantsPageListForTrash = (): JSX.Element => {
+  const { data: limit } = useShowPageLimitationXL();
+
+  return (
+    <DescendantsPageList
+      path="/trash"
+      limit={limit}
+      forceHideMenuItems={[MenuItemType.RENAME]}
+    />
+  );
+};
+
+export const TrashPageList = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const emptyTrashButton = useEmptyTrashButton();
   const emptyTrashButton = useEmptyTrashButton();
 
 
@@ -67,7 +85,7 @@ export const TrashPageList: FC = () => {
     return {
     return {
       pagelist: {
       pagelist: {
         Icon: PageListIcon,
         Icon: PageListIcon,
-        Content: DescendantsPageListForCurrentPath,
+        Content: DescendantsPageListForTrash,
         i18n: t('page_list'),
         i18n: t('page_list'),
         index: 0,
         index: 0,
       },
       },

+ 1 - 1
packages/app/src/interfaces/revision.ts

@@ -1,3 +1,3 @@
 export type {
 export type {
-  IRevision, IRevisionsForPagination, IRevisionOnConflict, HasRevisionShortbody,
+  IRevision, IRevisionHasId, IRevisionsForPagination, IRevisionOnConflict, HasRevisionShortbody,
 } from '@growi/core';
 } from '@growi/core';

+ 1 - 0
packages/app/src/interfaces/services/renderer.ts

@@ -1,6 +1,7 @@
 import { XssOptionConfig } from '~/services/xss/xssOption';
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
 
 export type RendererConfig = {
 export type RendererConfig = {
+  isSharedPage?: boolean
   isEnabledLinebreaks: boolean,
   isEnabledLinebreaks: boolean,
   isEnabledLinebreaksInComments: boolean,
   isEnabledLinebreaksInComments: boolean,
   adminPreferredIndentSize: number,
   adminPreferredIndentSize: number,

+ 36 - 0
packages/app/src/migrations/20230213090921-remove-presentation-configurations.js

@@ -0,0 +1,36 @@
+// eslint-disable-next-line import/no-named-as-default
+import Config from '~/server/models/config';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:remove-presentation-configurations');
+
+const mongoose = require('mongoose');
+
+module.exports = {
+  async up() {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    await Config.findOneAndDelete({ key: 'markdown:presentation:pageBreakSeparator' });
+    await Config.findOneAndDelete({ key: 'markdown:presentation:pageBreakCustomSeparator' });
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down() {
+    logger.info('Rollback migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const insertConfig = new Config({
+      ns: 'crowi',
+      key: 'markdown:presentation:pageBreakSeparator',
+      value: 2,
+    });
+
+    await insertConfig.save();
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 28 - 25
packages/app/src/pages/[[...path]].page.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react';
+import React, { ReactNode, useEffect } from 'react';
 
 
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
@@ -7,7 +7,7 @@ import {
   isClient, isIPageInfoForEntity, pagePathUtils, pathUtils,
   isClient, isIPageInfoForEntity, pagePathUtils, pathUtils,
 } from '@growi/core';
 } from '@growi/core';
 import type {
 import type {
-  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, IUser, IUserHasId,
+  IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, IUserHasId,
 } from '@growi/core';
 } from '@growi/core';
 import ExtensibleCustomError from 'extensible-custom-error';
 import ExtensibleCustomError from 'extensible-custom-error';
 import {
 import {
@@ -19,9 +19,8 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import superjson from 'superjson';
 import superjson from 'superjson';
 
 
-import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
+import { useCurrentGrowiLayoutFluidClassName, useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { PageView } from '~/components/Page/PageView';
-import RevisionRenderer from '~/components/Page/RevisionRenderer';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { EditorConfig } from '~/interfaces/editor-settings';
@@ -32,7 +31,6 @@ import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
-import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useHasDraftOnHackmd, usePageIdOnHackmd, useRevisionIdHackmdSynced } from '~/stores/hackmd';
 import { useHasDraftOnHackmd, usePageIdOnHackmd, useRevisionIdHackmdSynced } from '~/stores/hackmd';
 import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
@@ -46,7 +44,7 @@ import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/webs
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
 import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
-import { BasicLayoutWithEditorMode } from '../components/Layout/BasicLayout';
+import { BasicLayout } from '../components/Layout/BasicLayout';
 import GrowiContextualSubNavigationSubstance from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiContextualSubNavigationSubstance from '../components/Navbar/GrowiContextualSubNavigation';
 import type { GrowiSubNavigationSwitcherProps } from '../components/Navbar/GrowiSubNavigationSwitcher';
 import type { GrowiSubNavigationSwitcherProps } from '../components/Navbar/GrowiSubNavigationSwitcher';
 import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
 import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
@@ -65,7 +63,7 @@ import {
 
 
 import { NextPageWithLayout } from './_app.page';
 import { NextPageWithLayout } from './_app.page';
 import {
 import {
-  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage,
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig,
 } from './utils/commons';
 } from './utils/commons';
 
 
 
 
@@ -137,8 +135,6 @@ const PutbackPageModal = (): JSX.Element => {
 };
 };
 
 
 type Props = CommonProps & {
 type Props = CommonProps & {
-  currentUser: IUser,
-
   pageWithMeta: IPageToShowRevisionWithMeta | null,
   pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
   // pageUser?: any,
   redirectFrom?: string;
   redirectFrom?: string;
@@ -203,12 +199,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useEditorConfig(props.editorConfig);
   useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
 
 
-  // UserUISettings
-  usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
-  usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
-  useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
-  useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
-  useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
+  // init sidebar config with UserUISettings and sidebarConfig
+  useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
 
 
   // page
   // page
   useIsLatestRevision(props.isLatestRevision);
   useIsLatestRevision(props.isLatestRevision);
@@ -272,7 +264,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useSetupGlobalSocket();
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
   useSetupGlobalSocketForPage(pageId);
 
 
-  const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName();
+  const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName(pageWithMeta?.data);
 
 
   const shouldRenderPutbackPageModal = pageWithMeta != null
   const shouldRenderPutbackPageModal = pageWithMeta != null
     ? _isTrashPage(pageWithMeta.data.path)
     ? _isTrashPage(pageWithMeta.data.path)
@@ -300,10 +292,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
 
   const title = generateCustomTitleForPage(props, pagePath);
   const title = generateCustomTitleForPage(props, pagePath);
 
 
-  // TODO: show SSR body
-  // const rendererOptions = generateSSRViewOptions(props.rendererConfig, pagePath);
-  // const ssrBody = <RevisionRenderer rendererOptions={rendererOptions} markdown={revisionBody ?? ''} />;
-
   return (
   return (
     <>
     <>
       <Head>
       <Head>
@@ -327,9 +315,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
           pageView={
           pageView={
             <PageView
             <PageView
               pagePath={pagePath}
               pagePath={pagePath}
-              page={pageWithMeta?.data}
-              // TODO: show SSR body
-              // ssrBody={ssrBody}
+              initialPage={pageWithMeta?.data}
+              rendererConfig={props.rendererConfig}
             />
             />
           }
           }
         />
         />
@@ -342,14 +329,29 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   );
   );
 };
 };
 
 
+type LayoutProps = {
+  children?: ReactNode
+  className?: string
+}
+
+const Layout = ({ children }: LayoutProps): JSX.Element => {
+  const className = useEditorModeClassName();
+
+  return (
+    <BasicLayout className={className}>
+      {children}
+    </BasicLayout>
+  );
+};
+
 Page.getLayout = function getLayout(page) {
 Page.getLayout = function getLayout(page) {
   return (
   return (
     <>
     <>
       <DrawioViewerScript />
       <DrawioViewerScript />
 
 
-      <BasicLayoutWithEditorMode>
+      <Layout>
         {page}
         {page}
-      </BasicLayoutWithEditorMode>
+      </Layout>
       <UnsavedAlertDialog />
       <UnsavedAlertDialog />
       <DescendantsPageListModal />
       <DescendantsPageListModal />
       <DrawioModal />
       <DrawioModal />
@@ -494,6 +496,7 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
   else {
   else {
     props.isNotFound = page.isEmpty;
     props.isNotFound = page.isEmpty;
     props.isNotCreatable = false;
     props.isNotCreatable = false;
+    props.isForbidden = false;
     // /62a88db47fed8b2d94f30000 ==> /path/to/page
     // /62a88db47fed8b2d94f30000 ==> /path/to/page
     if (isPermalink && page.isEmpty) {
     if (isPermalink && page.isEmpty) {
       props.currentPathname = page.path;
       props.currentPathname = page.path;

+ 2 - 12
packages/app/src/pages/_app.page.tsx

@@ -1,6 +1,5 @@
 import React, { ReactElement, ReactNode, useEffect } from 'react';
 import React, { ReactElement, ReactNode, useEffect } from 'react';
 
 
-import { isServer } from '@growi/core';
 import { NextPage } from 'next';
 import { NextPage } from 'next';
 import { appWithTranslation } from 'next-i18next';
 import { appWithTranslation } from 'next-i18next';
 import { AppProps } from 'next/app';
 import { AppProps } from 'next/app';
@@ -13,8 +12,7 @@ import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
 import {
   useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useIsDefaultLogo, useForcedColorScheme,
   useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useIsDefaultLogo, useForcedColorScheme,
 } from '~/stores/context';
 } from '~/stores/context';
-import { SWRConfigValue, swrGlobalConfiguration } from '~/utils/swr-utils';
-
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
 import { CommonProps } from './utils/commons';
 import { CommonProps } from './utils/commons';
 import { registerTransformerForObjectId } from './utils/objectid-transformer';
 import { registerTransformerForObjectId } from './utils/objectid-transformer';
@@ -26,14 +24,6 @@ import '~/styles/theme/_apply-colors.scss';
 
 
 const isDev = process.env.NODE_ENV === 'development';
 const isDev = process.env.NODE_ENV === 'development';
 
 
-const swrConfig: SWRConfigValue = {
-  ...swrGlobalConfiguration,
-  // set the request scoped cache provider in server
-  provider: isServer()
-    ? cache => new Map(cache)
-    : undefined,
-};
-
 
 
 // eslint-disable-next-line @typescript-eslint/ban-types
 // eslint-disable-next-line @typescript-eslint/ban-types
 export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
 export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
@@ -72,7 +62,7 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   const getLayout = Component.getLayout ?? (page => page);
   const getLayout = Component.getLayout ?? (page => page);
 
 
   return (
   return (
-    <SWRConfig value={swrConfig}>
+    <SWRConfig value={swrGlobalConfiguration}>
       {getLayout(<Component {...pageProps} />)}
       {getLayout(<Component {...pageProps} />)}
     </SWRConfig>
     </SWRConfig>
   );
   );

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