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

Merge branch 'dev/7.0.x' into feat/126523-137811-editor-theme

reiji-h 2 лет назад
Родитель
Сommit
5ba22eca7d
69 измененных файлов с 1536 добавлено и 996 удалено
  1. 39 2
      CHANGELOG.md
  2. 2 0
      apps/app/package.json
  3. 1 1
      apps/app/public/static/locales/en_US/admin.json
  4. 1 0
      apps/app/public/static/locales/en_US/translation.json
  5. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  6. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  7. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  8. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  9. 5 0
      apps/app/src/components/Common/PageViewLayout.module.scss
  10. 1 1
      apps/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  11. 3 14
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  12. 3 5
      apps/app/src/components/InAppNotification/InAppNotificationList.tsx
  13. 1 3
      apps/app/src/components/InAppNotification/InAppNotificationPage.tsx
  14. 2 2
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  15. 7 15
      apps/app/src/components/PageHistory/PageRevisionTable.tsx
  16. 8 7
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  17. 6 0
      apps/app/src/components/PageSideContents/PageAccessoriesControl.module.scss
  18. 3 0
      apps/app/src/components/PageTags/TagLabels.module.scss
  19. 34 0
      apps/app/src/components/Sidebar/InAppNotification/InAppNotification.tsx
  20. 60 0
      apps/app/src/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  21. 1 0
      apps/app/src/components/Sidebar/InAppNotification/index.ts
  22. 17 13
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  23. 12 5
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  24. 3 5
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  25. 6 7
      apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx
  26. 7 9
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  27. 7 3
      apps/app/src/components/Sidebar/Sidebar.tsx
  28. 3 0
      apps/app/src/components/Sidebar/SidebarContents.tsx
  29. 1 0
      apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx
  30. 7 8
      apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx
  31. 0 69
      apps/app/src/components/TreeItem/NewPageCreateButton.tsx
  32. 0 104
      apps/app/src/components/TreeItem/NewPageInput.tsx
  33. 37 0
      apps/app/src/components/TreeItem/NewPageInput/NewPageCreateButton.tsx
  34. 81 0
      apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx
  35. 1 0
      apps/app/src/components/TreeItem/NewPageInput/index.ts
  36. 107 0
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  37. 13 0
      apps/app/src/components/TreeItem/NotDraggableForClosableTextInput.tsx
  38. 42 83
      apps/app/src/components/TreeItem/SimpleItem.tsx
  39. 0 43
      apps/app/src/components/TreeItem/UseNewPageInput.tsx
  40. 5 2
      apps/app/src/components/TreeItem/index.ts
  41. 30 0
      apps/app/src/components/TreeItem/interfaces/index.ts
  42. 4 1
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  43. 1 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  44. 4 4
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  45. 39 1
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  46. 8 1
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  47. 9 12
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  48. 1 0
      apps/app/src/interfaces/ui.ts
  49. 3 1
      apps/app/src/server/events/user.ts
  50. 1 0
      apps/app/src/server/models/page.ts
  51. 2 1
      apps/app/src/server/routes/apiv3/users.js
  52. 2 0
      apps/app/src/server/service/page/consts.ts
  53. 121 0
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  54. 122 0
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  55. 46 147
      apps/app/src/server/service/page/index.ts
  56. 12 0
      apps/app/src/server/service/page/page-service.ts
  57. 20 0
      apps/app/src/server/service/page/should-use-v4-process.ts
  58. 5 0
      apps/app/src/server/service/passport.ts
  59. 1 1
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  60. 10 10
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  61. 39 10
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts
  62. 6 6
      apps/app/test/cypress/e2e/60-home/60-home--home.cy.ts
  63. 1 1
      apps/app/test/cypress/support/commands.ts
  64. 1 0
      apps/app/test/integration/service/passport.test.js
  65. 173 138
      packages/preset-themes/src/styles/future.scss
  66. 168 133
      packages/preset-themes/src/styles/kibela.scss
  67. 158 123
      packages/preset-themes/src/styles/nature.scss
  68. 3 2
      packages/preset-themes/vite.themes.config.ts
  69. 16 1
      yarn.lock

+ 39 - 2
CHANGELOG.md

@@ -1,12 +1,47 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.3.0...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.3.0](https://github.com/weseek/growi/compare/v6.2.5...v6.3.0) - 2023-12-14
+
+### BREAKING CHANGES
+
+* support: Remove obsolete route for attachment on MongoDB GridFS (#8239) @yuki-takei
+
+### 💎 Features
+
+* feat: LDAP/Keycloak group sync (#7857) @arafubeatbox
+
+### 🚀 Improvement
+
+* imprv: Refactor DrawioViewer re-rendering by the resizing trigger (#8314) @yuki-takei
+* imprv: Apply content headers for attachment response (#8245) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: SAML callback action throws the field is undefined error when the ACL Rule string is only white space (#8322) @yuki-takei
+* fix: Remove groups not related to the user from the user groups that are specified automatically when creating child pages (#8266) @arafubeatbox
+* fix: Certify shared page attachment middleware (#8255) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Add test for delete-completely-user-home-by-system.ts (#8323) @jam411
+* ci(deps-dev): bump vite from 4.5.0 to 4.5.1 (#8302) @dependabot
+* support: TypeScriptize attachment codes (#8243) @yuki-takei
+* support: Remove obsolete route for attachment on MongoDB GridFS (#8239) @yuki-takei
+
+## [v6.2.5](https://github.com/weseek/growi/compare/v6.2.4...v6.2.5) - 2023-12-14
+
+### 🐛 Bug Fixes
+
+* fix: Update deleteCompletelyUserHomeBySystem for v4 process (#8289) @jam411
+
 ## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
 
 ### 💎 Features
+
 * feat: Show create date in Attachment Data list (#8229) @sakazuki
 
 ### 🚀 Improvement
@@ -14,11 +49,13 @@
 * imprv: Add Marp preset template for ja_JP and zh_CN (#8179) @AikaHiyama
 * imprv: Allow deletion of user homepage when the user is deleted (#8224) @jam411
 
+### 🐛 Bug Fixes
+* fix: Certify shared page attachment middleware (6.2.x) (#8256) @yuki-takei
+
 ### 🧰 Maintenance
 
 * support: Refactor deleteCompletelyUserHomeBySystem (#8262) @jam411
 
-
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 
 ### 🚀 Improvement

+ 2 - 0
apps/app/package.json

@@ -205,6 +205,7 @@
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unstated": "^2.1.1",
+    "unzip-stream": "^0.3.1",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
@@ -230,6 +231,7 @@
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/url-join": "^4.0.2",
+    "@types/unzip-stream": "^0.3.4",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",

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

@@ -1107,7 +1107,7 @@
       "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
-      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+      "auth_not_set": "Enable OIDC or SAML host that includes 'Host' and 'Group Realm' of group sync settings"
     },
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."

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

@@ -144,6 +144,7 @@
   "wide_view": "Wide View",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
+  "In-App Notification": "Notifications",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"Duplicated path",

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

@@ -1117,7 +1117,7 @@
       "group_sync_client_secret_detail": "Keycloak admin API にリクエストするための認証に使う client の secret",
       "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
       "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
-      "auth_not_set": "同期実行前にセキュリティ設定で Keycloak を使った OIDC または SAML 認証を有効にし、設定してください"
+      "auth_not_set": "グループ同期設定の Host と Group Realm が発行ホストに含まれる OIDC または SAML 認証をセキュリティ設定で有効にしてください"
     },
     "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
     "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"

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

@@ -145,6 +145,7 @@
   "wide_view": "ワイドビュー",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
+  "In-App Notification": "通知",
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",

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

@@ -1116,7 +1116,7 @@
       "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
-      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+      "auth_not_set": "Enable OIDC or SAML host that includes 'Host' and 'Group Realm' of group sync settings"
     },
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."

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

@@ -150,6 +150,7 @@
   "wide_view": "视野开阔",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
+  "In-App Notification": "通知",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"Duplicated path",

+ 5 - 0
apps/app/src/components/Common/PageViewLayout.module.scss

@@ -45,6 +45,11 @@ $page-view-layout-margin-top: 32px;
       min-width: 250px;
       margin-left: 30px;
     }
+
+    @include bs.media-breakpoint-down(sm) {
+      position: fixed;
+      right: 1rem;
+    }
   }
 }
 

+ 1 - 1
apps/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -91,7 +91,7 @@ export const InAppNotificationDropdown = (): JSX.Element => {
           // no items
           ? <DropdownItem disabled>{t('in_app_notification.mark_all_as_read')}</DropdownItem>
           // render DropdownItem
-          : <InAppNotificationList type="dropdown-item" inAppNotificationData={inAppNotificationData} />
+          : <InAppNotificationList inAppNotificationData={inAppNotificationData} />
         }
         <DropdownItem divider />
         <DropdownItem tag="a" href="/me/all-in-app-notifications">

+ 3 - 14
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -2,7 +2,6 @@ import React, { FC } from 'react';
 
 import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
-import { DropdownItem } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
@@ -11,11 +10,8 @@ import { useModelNotification } from './PageNotification';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
-  elemClassName?: string,
-  type?: 'button' | 'dropdown-item',
 }
 
-
 const InAppNotificationElm: FC<Props> = (props: Props) => {
 
   const { notification } = props;
@@ -57,16 +53,8 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     );
   };
 
-  const isDropdownItem = props.type === 'dropdown-item';
-
-  // determine tag
-  const TagElem = isDropdownItem
-    ? DropdownItem
-    // eslint-disable-next-line react/prop-types
-    : props => <button type="button" {...props}>{props.children}</button>;
-
   return (
-    <TagElem className={props.elemClassName} onClick={() => clickHandler(notification)}>
+    <div className="list-group-item list-group-item-action" onClick={() => clickHandler(notification)} style={{ cursor: 'pointer' }}>
       <div className="d-flex align-items-center">
         <span
           className={`${notification.status === InAppNotificationStatuses.STATUS_UNOPENED
@@ -75,12 +63,13 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
           } rounded-circle me-3`}
         >
         </span>
+
         {renderActionUserPictures()}
 
         <Notification />
 
       </div>
-    </TagElem>
+    </div>
   );
 };
 

+ 3 - 5
apps/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -9,8 +9,6 @@ import InAppNotificationElm from './InAppNotificationElm';
 
 type Props = {
   inAppNotificationData?: PaginateResult<IInAppNotification>,
-  elemClassName?: string,
-  type?: 'button' | 'dropdown-item',
 };
 
 const InAppNotificationList: FC<Props> = (props: Props) => {
@@ -29,13 +27,13 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
   const notifications = inAppNotificationData.docs;
 
   return (
-    <>
+    <div className="list-group">
       { notifications.map((notification: IInAppNotification & HasObjectId) => {
         return (
-          <InAppNotificationElm key={notification._id} notification={notification} type={props.type} elemClassName={props.elemClassName} />
+          <InAppNotificationElm key={notification._id} notification={notification} />
         );
       }) }
-    </>
+    </div>
   );
 };
 

+ 1 - 3
apps/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -100,9 +100,7 @@ export const InAppNotificationPage: FC = () => {
           ? t('in_app_notification.mark_all_as_read')
           // render list-group
           : (
-            <div className="list-group">
-              <InAppNotificationList inAppNotificationData={notificationData} type="button" elemClassName="list-group-item list-group-item-action" />
-            </div>
+            <InAppNotificationList inAppNotificationData={notificationData} />
           )
         }
 

+ 2 - 2
apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -25,7 +25,7 @@ import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
-import { ItemNode, SimpleItemProps } from '../TreeItem';
+import { ItemNode, type TreeItemProps } from '../TreeItem';
 
 import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
 
@@ -92,7 +92,7 @@ type ItemsTreeProps = {
   targetPath: string
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
-  CustomTreeItem: React.FunctionComponent<SimpleItemProps>
+  CustomTreeItem: React.FunctionComponent<TreeItemProps>
 }
 
 /*

+ 7 - 15
apps/app/src/components/PageHistory/PageRevisionTable.tsx

@@ -2,7 +2,7 @@ import React, {
   useEffect, useRef, useState,
 } from 'react';
 
-import type { IRevisionHasId, IRevisionHasPageId } from '@growi/core';
+import type { IRevisionHasPageId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import { useSWRxInfinitePageRevisions } from '~/stores/page';
@@ -96,27 +96,19 @@ export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element =>
   }, [isLoadingMore, isReachingEnd, setSize, size]);
 
 
-  const onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>> = (revision: IRevisionHasPageId) => {
-    setSourceRevision(revision);
-  };
-  const onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>> = (revision: IRevisionHasPageId) => {
-    setTargetRevision(revision);
-  };
-
-
   const renderRow = (revision: IRevisionHasPageId, previousRevision: IRevisionHasPageId, latestRevision: IRevisionHasPageId,
       isOldestRevision: boolean, hasDiff: boolean) => {
 
     const revisionId = revision._id;
 
     const handleCompareLatestRevisionButton = () => {
-      onChangeSourceInvoked(revision);
-      onChangeTargetInvoked(latestRevision);
+      setSourceRevision(revision);
+      setTargetRevision(latestRevision);
     };
 
     const handleComparePreviousRevisionButton = () => {
-      onChangeSourceInvoked(previousRevision);
-      onChangeTargetInvoked(revision);
+      setSourceRevision(previousRevision);
+      setTargetRevision(revision);
     };
 
     return (
@@ -165,7 +157,7 @@ export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element =>
                 name="compareSource"
                 value={revisionId}
                 checked={revisionId === sourceRevision?._id}
-                onChange={() => onChangeSourceInvoked(revision)}
+                onChange={() => setSourceRevision(revision)}
               />
               <label className="form-label form-check-label" htmlFor={`compareSource-${revisionId}`} />
             </div>
@@ -181,7 +173,7 @@ export const PageRevisionTable = (props: PageRevisionTableProps): JSX.Element =>
                 name="compareTarget"
                 value={revisionId}
                 checked={revisionId === targetRevision?._id}
-                onChange={() => onChangeTargetInvoked(revision)}
+                onChange={() => setTargetRevision(revision)}
               />
               <label className="form-label form-check-label" htmlFor={`compareTarget-${revisionId}`} />
             </div>

+ 8 - 7
apps/app/src/components/PageSelectModal/TreeItemForModal.tsx

@@ -1,15 +1,16 @@
-import React, { FC } from 'react';
+import React, { type FC } from 'react';
 
 import {
-  SimpleItem, SimpleItemProps, SimpleItemTool, useNewPageInput,
+  SimpleItem, SimpleItemTool, useNewPageInput, type TreeItemProps,
 } from '../TreeItem';
 
-type Optional = 'itemRef' | 'itemClass' | 'mainClassName';
-type PageTreeItemProps = Omit<SimpleItemProps, Optional> & {key};
+type PageTreeItemProps = TreeItemProps & {
+  key?: React.Key | null,
+};
 
 export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
 
-  const { NewPageInputWrapper, NewPageCreateButtonWrapper } = useNewPageInput();
+  const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
   return (
     <SimpleItem
@@ -22,9 +23,9 @@ export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
       onRenamed={props.onRenamed}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
-      customNextComponents={[NewPageInputWrapper]}
+      customNextComponents={[NewPageInput]}
       itemClass={TreeItemForModal}
-      customEndComponents={[SimpleItemTool, NewPageCreateButtonWrapper]}
+      customEndComponents={[SimpleItemTool, NewPageCreateButton]}
     />
   );
 };

+ 6 - 0
apps/app/src/components/PageSideContents/PageAccessoriesControl.module.scss

@@ -12,6 +12,12 @@
   }
 }
 
+@include bs.media-breakpoint-down(sm) {
+  .btn-page-accessories :global {
+    box-shadow: 0px 3px 6px rgba(black, 0.15);
+  }
+}
+
 // apply larger font when smaller than lg
 @include bs.media-breakpoint-down(lg) {
   .btn-page-accessories :global {

+ 3 - 0
apps/app/src/components/PageTags/TagLabels.module.scss

@@ -22,4 +22,7 @@ $grw-tag-label-font-size: 12px;
 
 .grw-tag-icon-button {
   padding: 6px 8px;
+  @include bs.media-breakpoint-down(sm) {
+    box-shadow: 0px 3px 6px rgba(black, 0.15);
+  }
 }

+ 34 - 0
apps/app/src/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -0,0 +1,34 @@
+import React, { Suspense, useState } from 'react';
+
+import dynamic from 'next/dynamic';
+import { useTranslation } from 'react-i18next';
+
+import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
+
+import { InAppNotificationForms } from './InAppNotificationSubstance';
+
+const InAppNotificationContent = dynamic(() => import('./InAppNotificationSubstance').then(mod => mod.InAppNotificationContent), { ssr: false });
+
+export const InAppNotification = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isUnreadNotificationsVisible, setUnreadNotificationsVisible] = useState(false);
+
+  return (
+    <div className="px-3">
+      <div className="grw-sidebar-content-header py-3 d-flex">
+        <h3 className="mb-0">
+          {t('In-App Notification')}
+        </h3>
+      </div>
+
+      <InAppNotificationForms
+        onChangeUnreadNotificationsVisible={() => { setUnreadNotificationsVisible(!isUnreadNotificationsVisible) }}
+      />
+
+      <Suspense fallback={<ItemsTreeContentSkeleton />}>
+        <InAppNotificationContent isUnreadNotificationsVisible={isUnreadNotificationsVisible} />
+      </Suspense>
+    </div>
+  );
+};

+ 60 - 0
apps/app/src/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -0,0 +1,60 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import InAppNotificationList from '~/components/InAppNotification/InAppNotificationList';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { useSWRxInAppNotifications } from '~/stores/in-app-notification';
+
+
+type InAppNotificationFormsProps = {
+  onChangeUnreadNotificationsVisible: () => void
+}
+export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.Element => {
+  const { onChangeUnreadNotificationsVisible } = props;
+
+  return (
+    <div className="my-2">
+      <div className="form-check form-switch">
+        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">Only unread</label>
+        <input
+          id="flexSwitchCheckDefault"
+          className="form-check-input"
+          type="checkbox"
+          role="switch"
+          onChange={onChangeUnreadNotificationsVisible}
+        />
+      </div>
+    </div>
+  );
+};
+
+
+type InAppNotificationContentProps = {
+  isUnreadNotificationsVisible: boolean
+}
+export const InAppNotificationContent = (props: InAppNotificationContentProps): JSX.Element => {
+  const { isUnreadNotificationsVisible } = props;
+  const { t } = useTranslation('commons');
+
+  // TODO: Infinite scroll implemented (https://redmine.weseek.co.jp/issues/138057)
+  const { data: inAppNotificationData } = useSWRxInAppNotifications(
+    6,
+    undefined,
+    isUnreadNotificationsVisible ? InAppNotificationStatuses.STATUS_UNREAD : undefined,
+    { revalidateOnFocus: true },
+  );
+
+  return (
+    <>
+      {inAppNotificationData != null && inAppNotificationData.docs.length === 0
+      // no items
+        ? t('in_app_notification.mark_all_as_read')
+      // render list-group
+        : (
+          <InAppNotificationList inAppNotificationData={inAppNotificationData} />
+        )
+      }
+    </>
+  );
+};

+ 1 - 0
apps/app/src/components/Sidebar/InAppNotification/index.ts

@@ -0,0 +1 @@
+export * from './InAppNotification';

+ 17 - 13
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -5,18 +5,18 @@ import { useTranslation } from 'react-i18next';
 import { LabelType } from '~/interfaces/template';
 
 type DropendMenuProps = {
-  todaysPath: string,
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
   onClickTemplateButtonHandler: (label: LabelType) => Promise<void>
+  todaysPath: string | null,
 }
 
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
   const {
-    todaysPath,
     onClickCreateNewPageButtonHandler,
     onClickCreateTodaysButtonHandler,
     onClickTemplateButtonHandler,
+    todaysPath,
   } = props;
 
   const { t } = useTranslation('commons');
@@ -32,17 +32,21 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
           {t('create_page_dropdown.new_page')}
         </button>
       </li>
-      <li><hr className="dropdown-divider" /></li>
-      <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
-      <li>
-        <button
-          className="dropdown-item"
-          onClick={onClickCreateTodaysButtonHandler}
-          type="button"
-        >
-          {todaysPath}
-        </button>
-      </li>
+      {todaysPath != null && (
+        <>
+          <li><hr className="dropdown-divider" /></li>
+          <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
+          <li>
+            <button
+              className="dropdown-item"
+              onClick={onClickCreateTodaysButtonHandler}
+              type="button"
+            >
+              {todaysPath}
+            </button>
+          </li>
+        </>
+      )}
       <li><hr className="dropdown-divider" /></li>
       <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
       <li>

+ 12 - 5
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,5 +1,6 @@
 import React, { useState, useCallback } from 'react';
 
+import type { IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
@@ -15,6 +16,12 @@ import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
+const generateTodaysPath = (currentUser: IUserHasId, parentDirName: string) => {
+  const now = format(new Date(), 'yyyy/MM/dd');
+  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
+  return `${userHomepagePath}/${parentDirName}/${now}`;
+};
+
 export const PageCreateButton = React.memo((): JSX.Element => {
   const { t } = useTranslation('commons');
 
@@ -23,12 +30,12 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const [isHovered, setIsHovered] = useState(false);
 
-  const now = format(new Date(), 'yyyy/MM/dd');
-  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  const todaysPath = `${userHomepagePath}/${t('create_page_dropdown.todays.memo')}/${now}`;
+  const todaysPath = currentUser == null
+    ? null
+    : generateTodaysPath(currentUser, t('create_page_dropdown.todays.memo'));
 
   const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(currentPagePath, isLoading);
-  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);
+  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath);
   const { onClickHandler: onClickTemplateButton, isPageCreating: isTemplatePageCreating } = useOnTemplateButtonClicked(currentPagePath, isLoading);
 
   const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
@@ -69,10 +76,10 @@ export const PageCreateButton = React.memo((): JSX.Element => {
             aria-expanded="false"
           />
           <DropendMenu
-            todaysPath={todaysPath}
             onClickCreateNewPageButtonHandler={onClickNewButton}
             onClickCreateTodaysButtonHandler={onClickTodaysButton}
             onClickTemplateButtonHandler={onClickTemplateButtonHandler}
+            todaysPath={todaysPath}
           />
         </div>
       )}

+ 3 - 5
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -1,6 +1,5 @@
 import { useCallback, useState } from 'react';
 
-import type { Nullable, IUserHasId } from '@growi/core';
 import { useRouter } from 'next/router';
 
 import { createPage, exist } from '~/client/services/page-operation';
@@ -51,8 +50,7 @@ export const useOnNewButtonClicked = (
 };
 
 export const useOnTodaysButtonClicked = (
-    todaysPath: string,
-    currentUser?: Nullable<IUserHasId> | undefined,
+    todaysPath: string | null,
 ): {
   onClickHandler: () => Promise<void>,
   isPageCreating: boolean
@@ -61,7 +59,7 @@ export const useOnTodaysButtonClicked = (
   const [isPageCreating, setIsPageCreating] = useState(false);
 
   const onClickHandler = useCallback(async() => {
-    if (currentUser == null) {
+    if (todaysPath == null) {
       return;
     }
 
@@ -89,7 +87,7 @@ export const useOnTodaysButtonClicked = (
     finally {
       setIsPageCreating(false);
     }
-  }, [currentUser, router, todaysPath]);
+  }, [router, todaysPath]);
 
   return { onClickHandler, isPageCreating };
 };

+ 6 - 7
apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx

@@ -15,27 +15,26 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
-import { IPageForItem } from '~/interfaces/page';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
 import ClosableTextInput from '../../Common/ClosableTextInput';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import {
-  SimpleItemToolProps, NotDraggableForClosableTextInput, SimpleItemTool,
+  type TreeItemToolProps, NotDraggableForClosableTextInput, SimpleItemTool,
 } from '../../TreeItem';
 
-type EllipsisProps = SimpleItemToolProps & {page: IPageForItem};
-
-export const Ellipsis: FC<EllipsisProps> = (props) => {
+export const Ellipsis: FC<TreeItemToolProps> = (props) => {
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const { t } = useTranslation();
 
   const {
-    page, onRenamed, onClickDuplicateMenuItem,
+    itemNode, onRenamed, onClickDuplicateMenuItem,
     onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
   } = props;
 
+  const { page } = itemNode;
+
   const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
 
@@ -141,7 +140,7 @@ export const Ellipsis: FC<EllipsisProps> = (props) => {
           </NotDraggableForClosableTextInput>
         </div>
       ) : (
-        <SimpleItemTool page={page} isEnableActions={false} isReadOnlyUser={false} />
+        <SimpleItemTool itemNode={itemNode} isEnableActions={false} isReadOnlyUser={false} />
       )}
       <NotAvailableForGuest>
         <div className="grw-pagetree-control d-flex">

+ 7 - 9
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -1,5 +1,6 @@
 import React, {
-  useCallback, useState, FC,
+  useCallback, useState,
+  type FC,
 } from 'react';
 
 import nodePath from 'path';
@@ -15,17 +16,14 @@ import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
 import {
-  SimpleItem, type SimpleItemProps, useNewPageInput, ItemNode,
+  SimpleItem, useNewPageInput, ItemNode, type TreeItemProps,
 } from '../../TreeItem';
 
 import { Ellipsis } from './Ellipsis';
 
 const logger = loggerFactory('growi:cli:Item');
 
-type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
-type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional>;
-
-export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
+export const PageTreeItem: FC<TreeItemProps> = (props) => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
     const pageTitle = nodePath.basename(droppedPagePath);
     return nodePath.join(newParentPagePath, pageTitle);
@@ -154,7 +152,7 @@ export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 
   const mainClassName = `${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`;
 
-  const { NewPageInputWrapper, NewPageCreateButtonWrapper } = useNewPageInput();
+  const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
   return (
     <SimpleItem
@@ -169,8 +167,8 @@ export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
       itemRef={itemRef}
       itemClass={PageTreeItem}
       mainClassName={mainClassName}
-      customEndComponents={[Ellipsis, NewPageCreateButtonWrapper]}
-      customNextComponents={[NewPageInputWrapper]}
+      customEndComponents={[Ellipsis, NewPageCreateButton]}
+      customNextComponents={[NewPageInput]}
     />
   );
 };

+ 7 - 3
apps/app/src/components/Sidebar/Sidebar.tsx

@@ -143,15 +143,19 @@ const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Elemen
 
 });
 
+// for data-* attributes
+type HTMLElementProps = JSX.IntrinsicElements &
+  Record<keyof JSX.IntrinsicElements, { [p: `data-${string}`]: string | number }>;
 
 type DrawableContainerProps = {
+  divProps?: HTMLElementProps['div'],
   className?: string,
   children?: React.ReactNode,
 }
 
 const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
 
-  const { className, children } = props;
+  const { divProps, className, children } = props;
 
   const { data: isDrawerOpened, mutate } = useDrawerOpened();
 
@@ -159,7 +163,7 @@ const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
 
   return (
     <>
-      <div className={`${className} ${openClass}`}>
+      <div {...divProps} className={`${className} ${openClass}`}>
         {children}
       </div>
       { isDrawerOpened && (
@@ -201,7 +205,7 @@ export const Sidebar = (): JSX.Element => {
         </DrawerToggler>
       ) }
       { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
-      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} data-testid="grw-sidebar">
+      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
         <ResizableContainer>
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
           <SidebarHead />

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

@@ -6,6 +6,7 @@ import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode }
 
 import { Bookmarks } from './Bookmarks';
 import { CustomSidebar } from './Custom';
+import { InAppNotification } from './InAppNotification';
 import { PageTree } from './PageTree';
 import { RecentChanges } from './RecentChanges';
 import Tag from './Tag';
@@ -29,6 +30,8 @@ export const SidebarContents = memo(() => {
         return Tag;
       case SidebarContentsType.BOOKMARKS:
         return Bookmarks;
+      case SidebarContentsType.NOTIFICATION:
+        return InAppNotification;
       default:
         return PageTree;
     }

+ 1 - 0
apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx

@@ -37,6 +37,7 @@ export const ToggleCollapseButton = memo((): JSX.Element => {
       type="button"
       className={`btn btn-primary ${styles['btn-toggle-collapse']} p-2`}
       onClick={isDrawerMode() ? toggleDrawer : toggleCollapsed}
+      data-testid="btn-toggle-collapse"
     >
       <span className={`material-symbols-outlined fs-2 ${rotationClass}`}>{icon}</span>
     </button>

+ 7 - 8
apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx

@@ -1,17 +1,10 @@
 import { FC, memo, useCallback } from 'react';
 
-import dynamic from 'next/dynamic';
-
 import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
 import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode } from '~/stores/ui';
 
 import styles from './PrimaryItems.module.scss';
 
-
-const InAppNotificationDropdown = dynamic(() => import('../../InAppNotification/InAppNotificationDropdown')
-  .then(mod => mod.InAppNotificationDropdown), { ssr: false });
-
-
 /**
  * @returns String for className to switch the indicator is active or not
  */
@@ -104,7 +97,13 @@ export const PrimaryItems = memo((props: Props) => {
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmarks" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
-      <InAppNotificationDropdown />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.NOTIFICATION}
+        label="In-App Notification"
+        iconName="notifications"
+        onHover={onItemHover}
+      />
     </div>
   );
 });

+ 0 - 69
apps/app/src/components/TreeItem/NewPageCreateButton.tsx

@@ -1,69 +0,0 @@
-import React, {
-  useCallback, FC,
-} from 'react';
-
-import { pagePathUtils } from '@growi/core/dist/utils';
-
-import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
-import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnlyUser';
-import { IPageForItem } from '~/interfaces/page';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-
-import { ItemNode } from './ItemNode';
-
-type StateHandlersType = {
-  isOpen: boolean,
-  setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
-  isCreating: boolean,
-  setCreating: React.Dispatch<React.SetStateAction<boolean>>,
-};
-
-export type NewPageCreateButtonProps = {
-  page: IPageForItem,
-  currentChildren: ItemNode[],
-  stateHandlers: StateHandlersType,
-  isNewPageInputShown?: boolean,
-  setNewPageInputShown: React.Dispatch<React.SetStateAction<boolean>>,
-};
-
-export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
-  const {
-    page, currentChildren, stateHandlers, setNewPageInputShown,
-  } = props;
-
-  const { setIsOpen } = stateHandlers;
-
-  // descendantCount
-  const { getDescCount } = usePageTreeDescCountMap();
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-  const onClickPlusButton = useCallback(() => {
-    setNewPageInputShown(true);
-
-    if (hasDescendants) {
-      setIsOpen(true);
-    }
-  }, [hasDescendants, setIsOpen]);
-
-  return (
-    <>
-      {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
-        <NotAvailableForGuest>
-          <NotAvailableForReadOnlyUser>
-            <button
-              id="page-create-button-in-page-tree"
-              type="button"
-              className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-              onClick={onClickPlusButton}
-            >
-              <i className="icon-plus d-block p-0" />
-            </button>
-          </NotAvailableForReadOnlyUser>
-        </NotAvailableForGuest>
-      )}
-    </>
-  );
-};

+ 0 - 104
apps/app/src/components/TreeItem/NewPageInput.tsx

@@ -1,104 +0,0 @@
-import React, { FC, useCallback, useEffect } from 'react';
-
-import nodePath from 'path';
-
-
-import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { ValidationTarget } from '~/client/util/input-validator';
-import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
-import ClosableTextInput from '~/components/Common/ClosableTextInput';
-import { useSWRxPageChildren } from '~/stores/page-listing';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-
-import { NewPageCreateButtonProps } from './NewPageCreateButton';
-import { NotDraggableForClosableTextInput } from './SimpleItem';
-
-type NewPageInputProps = NewPageCreateButtonProps & {isEnableActions: boolean};
-
-export const NewPageInput: FC<NewPageInputProps> = (props) => {
-  const { t } = useTranslation();
-
-  const {
-    page, isEnableActions, currentChildren, stateHandlers, isNewPageInputShown, setNewPageInputShown,
-  } = props;
-
-  const { isOpen, setIsOpen, setCreating } = stateHandlers;
-
-  const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
-
-  const { getDescCount } = usePageTreeDescCountMap();
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-  const onPressEnterForCreateHandler = async(inputText: string) => {
-    setNewPageInputShown(false);
-    // closeNewPageInput();
-    const parentPath = pathUtils.addTrailingSlash(page.path as string);
-    const newPagePath = nodePath.resolve(parentPath, inputText);
-    const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
-
-    if (!isCreatable) {
-      toastWarning(t('you_can_not_create_page_with_this_name'));
-      return;
-    }
-
-    try {
-      setCreating(true);
-
-      await apiv3Post('/pages/', {
-        path: newPagePath,
-        body: undefined,
-        grant: page.grant,
-        // grantUserGroupId: page.grantedGroup,
-        grantUserGroupIds: page.grantedGroups,
-      });
-
-      mutateChildren();
-
-      if (!hasDescendants) {
-        setIsOpen(true);
-      }
-
-      toastSuccess(t('successfully_saved_the_page'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setCreating(false);
-    }
-  };
-
-  const onPressEscHandler = useCallback((event) => {
-    if (event.keyCode === 27) {
-      setNewPageInputShown(false);
-    }
-  }, []);
-
-  useEffect(() => {
-    document.addEventListener('keydown', onPressEscHandler, false);
-    return () => {
-      document.removeEventListener('keydown', onPressEscHandler, false);
-    };
-  }, [onPressEscHandler]);
-
-  return (
-    <>
-      {isEnableActions && isNewPageInputShown && (
-        <NotDraggableForClosableTextInput>
-          <ClosableTextInput
-            placeholder={t('Input page name')}
-            onClickOutside={() => { setNewPageInputShown(false) }}
-            onPressEnter={onPressEnterForCreateHandler}
-            validationTarget={ValidationTarget.PAGE}
-          />
-        </NotDraggableForClosableTextInput>
-      )}
-    </>
-  );
-};

+ 37 - 0
apps/app/src/components/TreeItem/NewPageInput/NewPageCreateButton.tsx

@@ -0,0 +1,37 @@
+import React, { type FC } from 'react';
+
+import { pagePathUtils } from '@growi/core/dist/utils';
+
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnlyUser';
+import type { IPageForItem } from '~/interfaces/page';
+
+type NewPageCreateButtonProps = {
+  page: IPageForItem,
+  onClick?: () => void,
+};
+
+export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
+  const {
+    page, onClick,
+  } = props;
+
+  return (
+    <>
+      {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
+        <NotAvailableForGuest>
+          <NotAvailableForReadOnlyUser>
+            <button
+              id="page-create-button-in-page-tree"
+              type="button"
+              className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+              onClick={onClick}
+            >
+              <i className="icon-plus d-block p-0" />
+            </button>
+          </NotAvailableForReadOnlyUser>
+        </NotAvailableForGuest>
+      )}
+    </>
+  );
+};

+ 81 - 0
apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx

@@ -0,0 +1,81 @@
+import React, { type FC, useCallback, useEffect } from 'react';
+
+import nodePath from 'path';
+
+import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+
+import { ValidationTarget } from '~/client/util/input-validator';
+import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
+import ClosableTextInput from '~/components/Common/ClosableTextInput';
+import type { IPageForItem } from '~/interfaces/page';
+
+import { NotDraggableForClosableTextInput } from '../NotDraggableForClosableTextInput';
+
+type Props = {
+  page: IPageForItem,
+  isEnableActions: boolean,
+  onSubmit?: (newPagePath: string) => Promise<void>,
+  onSubmittionFailed?: () => void,
+  onCanceled?: () => void,
+};
+
+export const NewPageInput: FC<Props> = (props) => {
+  const { t } = useTranslation();
+
+  const {
+    page, isEnableActions,
+    onSubmit, onSubmittionFailed,
+    onCanceled,
+  } = props;
+
+  const onPressEnterForCreateHandler = async(inputText: string) => {
+    const parentPath = pathUtils.addTrailingSlash(page.path as string);
+    const newPagePath = nodePath.resolve(parentPath, inputText);
+    const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
+
+    if (!isCreatable) {
+      toastWarning(t('you_can_not_create_page_with_this_name'));
+      return;
+    }
+
+    try {
+      onSubmit?.(newPagePath);
+      toastSuccess(t('successfully_saved_the_page'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      onSubmittionFailed?.();
+    }
+  };
+
+  const onPressEscHandler = useCallback((event) => {
+    if (event.keyCode === 27) {
+      onCanceled?.();
+    }
+  }, [onCanceled]);
+
+  useEffect(() => {
+    document.addEventListener('keydown', onPressEscHandler, false);
+    return () => {
+      document.removeEventListener('keydown', onPressEscHandler, false);
+    };
+  }, [onPressEscHandler]);
+
+  return (
+    <>
+      {isEnableActions && (
+        <NotDraggableForClosableTextInput>
+          <ClosableTextInput
+            placeholder={t('Input page name')}
+            onClickOutside={onCanceled}
+            onPressEnter={onPressEnterForCreateHandler}
+            validationTarget={ValidationTarget.PAGE}
+          />
+        </NotDraggableForClosableTextInput>
+      )}
+    </>
+  );
+};

+ 1 - 0
apps/app/src/components/TreeItem/NewPageInput/index.ts

@@ -0,0 +1 @@
+export * from './use-new-page-input';

+ 107 - 0
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -0,0 +1,107 @@
+import React, { useState, type FC, useCallback } from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useSWRxPageChildren } from '~/stores/page-listing';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+
+import type { TreeItemToolProps } from '../interfaces';
+
+import { NewPageCreateButton } from './NewPageCreateButton';
+import { NewPageInput } from './NewPageInput';
+
+type UseNewPageInput = {
+  Input: FC<TreeItemToolProps>,
+  CreateButton: FC<TreeItemToolProps>,
+  isProcessingSubmission: boolean,
+}
+
+export const useNewPageInput = (): UseNewPageInput => {
+
+  const [showInput, setShowInput] = useState(false);
+  const [isProcessingSubmission, setProcessingSubmission] = useState(false);
+
+  const { getDescCount } = usePageTreeDescCountMap();
+
+  const CreateButton: FC<TreeItemToolProps> = (props) => {
+
+    const { itemNode, stateHandlers } = props;
+    const { page, children } = itemNode;
+
+    // descendantCount
+    const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+    const isChildrenLoaded = children?.length > 0;
+    const hasDescendants = descendantCount > 0 || isChildrenLoaded;
+
+    const onClick = useCallback(() => {
+      setShowInput(true);
+
+      if (hasDescendants) {
+        stateHandlers?.setIsOpen(true);
+      }
+    }, [hasDescendants, stateHandlers]);
+
+    return (
+      <NewPageCreateButton
+        page={page}
+        onClick={onClick}
+      />
+    );
+  };
+
+  const Input: FC<TreeItemToolProps> = (props) => {
+
+    const { itemNode, stateHandlers } = props;
+    const { page, children } = itemNode;
+
+    const { mutate: mutateChildren } = useSWRxPageChildren(stateHandlers?.isOpen ? page._id : null);
+
+    const { getDescCount } = usePageTreeDescCountMap();
+    const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+    const isChildrenLoaded = children?.length > 0;
+    const hasDescendants = descendantCount > 0 || isChildrenLoaded;
+
+    const submitHandler = useCallback(async(newPagePath: string) => {
+      setProcessingSubmission(true);
+
+      setShowInput(false);
+
+      await apiv3Post('/pages/', {
+        path: newPagePath,
+        body: undefined,
+        grant: page.grant,
+        // grantUserGroupId: page.grantedGroup,
+        grantUserGroupIds: page.grantedGroups,
+      });
+
+      mutateChildren();
+
+      if (!hasDescendants) {
+        stateHandlers?.setIsOpen(true);
+      }
+    }, [hasDescendants, mutateChildren, page.grant, page.grantedGroups, stateHandlers]);
+
+    const submittionFailedHandler = useCallback(() => {
+      setProcessingSubmission(false);
+    }, []);
+
+    return showInput
+      ? (
+        <NewPageInput
+          page={page}
+          isEnableActions={props.isEnableActions}
+          onSubmit={submitHandler}
+          onSubmittionFailed={submittionFailedHandler}
+          onCanceled={() => setShowInput(false)}
+        />
+      )
+      : <></>;
+  };
+
+  return {
+    Input,
+    CreateButton,
+    isProcessingSubmission,
+  };
+};

+ 13 - 0
apps/app/src/components/TreeItem/NotDraggableForClosableTextInput.tsx

@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react';
+
+type NotDraggableProps = {
+  children: ReactNode,
+};
+
+/**
+ * Component wrapper to make a child element not draggable
+ * @see https://github.com/react-dnd/react-dnd/issues/335
+ */
+export const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element => {
+  return <div draggable onDragStart={e => e.preventDefault()}>{props.children}</div>;
+};

+ 42 - 83
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -1,17 +1,16 @@
 import React, {
-  useCallback, useState, FC, useEffect, ReactNode,
+  useCallback, useState, useEffect,
+  type FC, type RefObject, type RefCallback,
 } from 'react';
 
 import nodePath from 'path';
 
-import type { Nullable, IPageToDeleteWithMeta } from '@growi/core';
+import type { Nullable } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { IPageForItem } from '~/interfaces/page';
-import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
@@ -19,24 +18,10 @@ import { shouldRecoverPagePaths } from '~/utils/page-operation';
 import CountBadge from '../Common/CountBadge';
 
 import { ItemNode } from './ItemNode';
+import { useNewPageInput } from './NewPageInput';
+import type { TreeItemProps, TreeItemToolProps } from './interfaces';
 
 
-export type SimpleItemProps = {
-  isEnableActions: boolean
-  isReadOnlyUser: boolean
-  itemNode: ItemNode
-  targetPathOrId?: Nullable<string>
-  isOpen?: boolean
-  onRenamed?(fromPath: string | undefined, toPath: string): void
-  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
-  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void
-  itemRef?
-  itemClass?: React.FunctionComponent<SimpleItemProps>
-  mainClassName?: string
-  customEndComponents?: Array<React.FunctionComponent<SimpleItemToolProps>>
-  customNextComponents?: Array<React.FunctionComponent<SimpleItemToolProps>>
-};
-
 // Utility to mark target
 const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
   if (targetPathOrId == null) {
@@ -54,39 +39,14 @@ const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): vo
   });
 };
 
-/**
- * Return new page path after the droppedPagePath is moved under the newParentPagePath
- * @param droppedPagePath
- * @param newParentPagePath
- * @returns
- */
-
-/**
- * Return whether the fromPage could be moved under the newParentPage
- * @param fromPage
- * @param newParentPage
- * @param printLog
- * @returns
- */
-
-// Component wrapper to make a child element not draggable
-// https://github.com/react-dnd/react-dnd/issues/335
-type NotDraggableProps = {
-  children: ReactNode,
-};
-export const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element => {
-  return <div draggable onDragStart={e => e.preventDefault()}>{props.children}</div>;
-};
-
-type SimpleItemToolPropsOptional = 'itemNode' | 'targetPathOrId' | 'isOpen' | 'itemRef' | 'itemClass' | 'mainClassName';
-export type SimpleItemToolProps = Omit<SimpleItemProps, SimpleItemToolPropsOptional> & {page: IPageForItem};
 
-export const SimpleItemTool: FC<SimpleItemToolProps> = (props) => {
+export const SimpleItemTool: FC<TreeItemToolProps> = (props) => {
   const { t } = useTranslation();
   const router = useRouter();
+
   const { getDescCount } = usePageTreeDescCountMap();
 
-  const page = props.page;
+  const { page } = props.itemNode;
 
   const pageName = nodePath.basename(page.path ?? '') || '/';
 
@@ -130,6 +90,10 @@ export const SimpleItemTool: FC<SimpleItemToolProps> = (props) => {
   );
 };
 
+type SimpleItemProps = TreeItemProps & {
+  itemRef?: RefObject<any> | RefCallback<any>,
+}
+
 export const SimpleItem: FC<SimpleItemProps> = (props) => {
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false,
@@ -139,19 +103,13 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
   const { page, children } = itemNode;
 
+  const { isProcessingSubmission } = useNewPageInput();
+
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
-  const [isCreating, setCreating] = useState(false);
 
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
 
-  const stateHandlers = {
-    isOpen,
-    setIsOpen,
-    isCreating,
-    setCreating,
-  };
-
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
@@ -197,7 +155,11 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
   const ItemClassFixed = itemClass ?? SimpleItem;
 
-  const commonProps = {
+  const CustomEndComponents = props.customEndComponents;
+
+  const SimpleItemContent = CustomEndComponents ?? [SimpleItemTool];
+
+  const baseProps: Omit<TreeItemProps, 'itemNode'> = {
     isEnableActions,
     isReadOnlyUser,
     isOpen: false,
@@ -205,23 +167,11 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
     onRenamed,
     onClickDuplicateMenuItem,
     onClickDeleteMenuItem,
-    stateHandlers,
   };
 
-  const CustomEndComponents = props.customEndComponents;
-
-  const SimpleItemContent = CustomEndComponents ?? [SimpleItemTool];
-
-  const SimpleItemContentProps = {
+  const toolProps: TreeItemToolProps = {
+    ...baseProps,
     itemNode,
-    page,
-    onRenamed,
-    onClickDuplicateMenuItem,
-    onClickDeleteMenuItem,
-    isEnableActions,
-    isReadOnlyUser,
-    children,
-    stateHandlers,
   };
 
   const CustomNextComponents = props.customNextComponents;
@@ -254,26 +204,35 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
         </div>
         {SimpleItemContent.map((ItemContent, index) => (
           // eslint-disable-next-line react/no-array-index-key
-          <ItemContent key={index} {...SimpleItemContentProps} />
+          <ItemContent key={index} {...toolProps} />
         ))}
       </li>
 
       {CustomNextComponents?.map((UnderItemContent, index) => (
         // eslint-disable-next-line react/no-array-index-key
-        <UnderItemContent key={index} {...SimpleItemContentProps} />
+        <UnderItemContent key={index} {...toolProps} />
       ))}
 
       {
-        isOpen && hasChildren() && currentChildren.map((node, index) => (
-          <div key={node.page._id} className="grw-pagetree-item-children">
-            <ItemClassFixed itemNode={node} {...commonProps} />
-            {isCreating && (currentChildren.length - 1 === index) && (
-              <div className="text-muted text-center">
-                <i className="fa fa-spinner fa-pulse mr-1"></i>
-              </div>
-            )}
-          </div>
-        ))
+        isOpen && hasChildren() && currentChildren.map((node, index) => {
+          const itemProps = {
+            ...baseProps,
+            itemNode: node,
+            itemClass,
+            mainClassName,
+          };
+
+          return (
+            <div key={node.page._id} className="grw-pagetree-item-children">
+              <ItemClassFixed {...itemProps} />
+              {isProcessingSubmission && (currentChildren.length - 1 === index) && (
+                <div className="text-muted text-center">
+                  <i className="fa fa-spinner fa-pulse mr-1"></i>
+                </div>
+              )}
+            </div>
+          );
+        })
       }
     </div>
   );

+ 0 - 43
apps/app/src/components/TreeItem/UseNewPageInput.tsx

@@ -1,43 +0,0 @@
-import React, { useState, FC } from 'react';
-
-import { ItemNode } from './ItemNode';
-import { NewPageCreateButton } from './NewPageCreateButton';
-import { NewPageInput } from './NewPageInput';
-import { SimpleItemToolProps } from './SimpleItem';
-
-type UseNewPageInputProps = SimpleItemToolProps & {children: ItemNode[], stateHandlers};
-
-export const useNewPageInput = () => {
-
-  const [isNewPageInputShown, setNewPageInputShown] = useState(false);
-
-  const NewPageCreateButtonWrapper: FC<UseNewPageInputProps> = (props) => {
-    return (
-      <NewPageCreateButton
-        page={props.page}
-        currentChildren={props.children}
-        stateHandlers={props.stateHandlers}
-        setNewPageInputShown={setNewPageInputShown}
-      />
-    );
-  };
-
-  const NewPageInputWrapper = (props) => {
-    return (
-      <NewPageInput
-        page={props.page}
-        isEnableActions={props.isEnableActions}
-        currentChildren={props.chilren}
-        stateHandlers={props.stateHandlers}
-        isNewPageInputShown={isNewPageInputShown}
-        setNewPageInputShown={setNewPageInputShown}
-      />
-    );
-  };
-
-
-  return {
-    NewPageInputWrapper,
-    NewPageCreateButtonWrapper,
-  };
-};

+ 5 - 2
apps/app/src/components/TreeItem/index.ts

@@ -1,3 +1,6 @@
-export { useNewPageInput } from './UseNewPageInput';
-export * from './SimpleItem';
+export * from './interfaces';
+
+export * from './NewPageInput';
 export * from './ItemNode';
+export * from './SimpleItem';
+export * from './NotDraggableForClosableTextInput';

+ 30 - 0
apps/app/src/components/TreeItem/interfaces/index.ts

@@ -0,0 +1,30 @@
+import type { IPageToDeleteWithMeta } from '@growi/core';
+import type { Nullable } from 'vitest';
+
+import type { IPageForPageDuplicateModal } from '~/stores/modal';
+
+import type { ItemNode } from '../ItemNode';
+
+type TreeItemBaseProps = {
+  itemNode: ItemNode,
+  isEnableActions: boolean,
+  isReadOnlyUser: boolean,
+  onRenamed?(fromPath: string | undefined, toPath: string): void,
+  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
+  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void,
+  stateHandlers?: {
+    isOpen: boolean,
+    setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
+  },
+}
+
+export type TreeItemToolProps = TreeItemBaseProps;
+
+export type TreeItemProps = TreeItemBaseProps & {
+  targetPathOrId?: Nullable<string>,
+  isOpen?: boolean,
+  itemClass?: React.FunctionComponent<TreeItemProps>,
+  mainClassName?: string,
+  customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  customNextComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+};

+ 4 - 1
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -344,7 +344,10 @@ module.exports = (crowi: Crowi): Router => {
     }
 
     const getAuthProviderType = () => {
-      const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      let kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      if (kcHost?.endsWith('/')) {
+        kcHost = kcHost.slice(0, -1);
+      }
       const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
 
       // starts with kcHost, contains kcGroupRealm in path

+ 1 - 0
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts

@@ -148,6 +148,7 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
   beforeAll(async() => {
     await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
+    keycloakUserGroupSyncService.init('oidc');
   });
 
   it('creates ExternalUserGroupTrees', async() => {

+ 4 - 4
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -30,18 +30,18 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   constructor(s2sMessagingService: S2sMessagingService | null, socketIoService) {
+    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
+  }
+
+  init(authProviderType: 'oidc' | 'saml'): void {
     const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
     const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
     const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
     const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
 
-    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
-  }
-
-  init(authProviderType: 'oidc' | 'saml'): void {
     this.authProviderType = authProviderType;
     this.isInitialized = true;
   }

+ 39 - 1
apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts

@@ -63,6 +63,44 @@ describe('archiveUrl()', () => {
     const { archiveUrl } = githubUrl;
 
     // then
-    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix/bug.zip');
+    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix%2Fbug.zip');
   });
 });
+
+describe('extractedArchiveDirName()', () => {
+
+  describe('certain characters in the branch name are converted to slashes, and if they are consecutive, they become a single hyphen', () => {
+    it.concurrent.each`
+      branchName
+      ${'a"\'!,;-=@`]<>|&{}()$%+#/b'}
+      ${'a---b'}
+    `("'$branchName'", ({ branchName }) => {
+      // setup
+      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+
+      // when
+      const { extractedArchiveDirName } = githubUrl;
+
+      // then
+      expect(extractedArchiveDirName).toEqual('a-b');
+    });
+  });
+
+  describe('when no certain characters in the branch name', () => {
+    it.concurrent.each`
+      branchName
+      ${'a.b'}
+      ${'a_b'}
+    `("'$branchName'", ({ branchName }) => {
+      // setup
+      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+
+      // when
+      const { extractedArchiveDirName } = githubUrl;
+
+      // then
+      expect(extractedArchiveDirName).toEqual(branchName);
+    });
+  });
+
+});

+ 8 - 1
apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -2,6 +2,8 @@ import sanitize from 'sanitize-filename';
 
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
+// https://regex101.com/r/YhZVsj/1
+const sanitizeChars = new RegExp(/[^a-zA-Z_.]+/g);
 
 export class GitHubUrl {
 
@@ -24,10 +26,15 @@ export class GitHubUrl {
   }
 
   get archiveUrl(): string {
-    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${this.branchName}.zip`, 'https://github.com');
+    const encodedBranchName = encodeURIComponent(this.branchName);
+    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${encodedBranchName}.zip`, 'https://github.com');
     return ghUrl.toString();
   }
 
+  get extractedArchiveDirName(): string {
+    return this._branchName.replaceAll(sanitizeChars, '-');
+  }
+
   constructor(url: string, branchName = 'main') {
 
     let matched;

+ 9 - 12
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -8,9 +8,8 @@ import { importPackageJson, validateGrowiDirective } from '@growi/pluginkit/dist
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import mongoose from 'mongoose';
-import sanitize from 'sanitize-filename';
 import streamToPromise from 'stream-to-promise';
-import unzipper from 'unzipper';
+import unzipStream from 'unzip-stream';
 
 import loggerFactory from '~/utils/logger';
 
@@ -77,11 +76,11 @@ export class GrowiPluginService implements IGrowiPluginService {
 
           // TODO: imprv Document version and repository version possibly different.
           const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.ghBranch);
-          const { reposName, branchName, archiveUrl } = ghUrl;
+          const { reposName, archiveUrl, extractedArchiveDirName } = ghUrl;
 
-          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${branchName}.zip`);
+          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${extractedArchiveDirName}.zip`);
           const unzippedPath = PLUGIN_STORING_PATH;
-          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${branchName}`);
+          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${extractedArchiveDirName}`);
 
           try {
             // download github repository to local file system
@@ -111,16 +110,14 @@ export class GrowiPluginService implements IGrowiPluginService {
   async install(origin: IGrowiPluginOrigin): Promise<string> {
     const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
     const {
-      organizationName, reposName, branchName, archiveUrl,
+      organizationName, reposName, archiveUrl, extractedArchiveDirName,
     } = ghUrl;
 
-    const sanitizedBranchName = sanitize(branchName);
-
     const installedPath = `${organizationName}/${reposName}`;
 
     const organizationPath = path.join(PLUGIN_STORING_PATH, organizationName);
-    const zipFilePath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}.zip`);
-    const temporaryReposPath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}`);
+    const zipFilePath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}.zip`);
+    const temporaryReposPath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}`);
     const reposPath = path.join(organizationPath, reposName);
 
     if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
@@ -205,9 +202,9 @@ export class GrowiPluginService implements IGrowiPluginService {
   private async unzip(zipFilePath: fs.PathLike, destPath: fs.PathLike): Promise<void> {
     try {
       const stream = fs.createReadStream(zipFilePath);
-      const unzipStream = stream.pipe(unzipper.Extract({ path: destPath }));
+      const unzipFileStream = stream.pipe(unzipStream.Extract({ path: destPath.toString() }));
 
-      await streamToPromise(unzipStream);
+      await streamToPromise(unzipFileStream);
     }
     catch (err) {
       logger.error(err);

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

@@ -14,6 +14,7 @@ export const SidebarContentsType = {
   TREE: 'tree',
   TAG: 'tag',
   BOOKMARKS: 'bookmarks',
+  NOTIFICATION: 'notification',
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 3 - 1
apps/app/src/server/events/user.ts

@@ -7,6 +7,8 @@ import mongoose from 'mongoose';
 import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
+import { deleteCompletelyUserHomeBySystem } from '../service/page/delete-completely-user-home-by-system';
+
 const logger = loggerFactory('growi:events:user');
 
 class UserEvent extends EventEmitter {
@@ -30,7 +32,7 @@ class UserEvent extends EventEmitter {
       // Since the type of page.creator is 'any', we resort to the following comparison,
       // checking if page.creator.toString() is not equal to user._id.toString(). Our code covers null, string, or object types.
       if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
-        await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
+        await deleteCompletelyUserHomeBySystem(userHomepagePath, this.crowi.pageService);
         page = null;
       }
 

+ 1 - 0
apps/app/src/server/models/page.ts

@@ -66,6 +66,7 @@ export type CreateMethod = (path: string, body: string, user, options: PageCreat
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean): Promise<PageDocument[]>
+  findByPath(path: string, includeEmpty?: boolean): Promise<PageDocument | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>

+ 2 - 1
apps/app/src/server/routes/apiv3/users.js

@@ -8,6 +8,7 @@ import Activity from '~/server/models/activity';
 import ExternalAccount from '~/server/models/external-account';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { configManager } from '~/server/service/config-manager';
+import { deleteCompletelyUserHomeBySystem } from '~/server/service/page/delete-completely-user-home-by-system';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -819,7 +820,7 @@ module.exports = (crowi) => {
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
 
       if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
-        crowi.pageService.deleteCompletelyUserHomeBySystem(homepagePath);
+        deleteCompletelyUserHomeBySystem(homepagePath, crowi.pageService);
       }
 
       return res.apiv3({ user: serializedUser });

+ 2 - 0
apps/app/src/server/service/page/consts.ts

@@ -0,0 +1,2 @@
+export const BULK_REINDEX_SIZE = 100;
+export const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;

+ 121 - 0
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -0,0 +1,121 @@
+import type EventEmitter from 'events';
+
+import mongoose from 'mongoose';
+import { vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+
+import { getPageSchema } from '~/server/models/obsolete-page';
+import { configManager } from '~/server/service/config-manager';
+
+import pageModel from '../../models/page';
+
+import { deleteCompletelyUserHomeBySystem } from './delete-completely-user-home-by-system';
+import type { IPageService } from './page-service';
+
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
+
+describe('delete-completely-user-home-by-system test', () => {
+  let Page;
+
+  const initialEnv = process.env;
+
+  const userId1 = new mongoose.Types.ObjectId();
+  const user1HomepageId = new mongoose.Types.ObjectId();
+
+  beforeAll(async() => {
+    // setup page model
+    getPageSchema(null);
+    pageModel(null);
+    Page = mongoose.model('Page');
+
+    // setup config
+    await configManager.loadConfigs();
+    await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
+    const isV5Compatible = configManager.getConfig('crowi', 'app:isV5Compatible');
+    expect(isV5Compatible).toBeTruthy();
+
+    // setup user documents
+    const user1 = await User.create({
+      _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
+    });
+
+    // setup page documents
+    await Page.insertMany([
+      {
+        _id: user1HomepageId,
+        path: '/user/user1',
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        parent: new mongoose.Types.ObjectId(),
+        descendantCount: 2,
+        isEmpty: false,
+        status: 'published',
+      },
+      {
+        path: '/user/user1/subpage1',
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        parent: user1HomepageId,
+      },
+      {
+        path: '/user/user1/subpage2',
+        grant: Page.GRANT_PUBLIC,
+        creator: user1,
+        lastUpdateUser: user1,
+        parent: user1HomepageId,
+      },
+    ]);
+  });
+
+  afterAll(() => {
+    process.env = initialEnv;
+    Page.deleteMany({});
+  });
+
+  describe('deleteCompletelyUserHomeBySystem()', () => {
+    // setup
+    const mockUpdateDescendantCountOfAncestors = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockDeleteCompletelyOperation = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockPageEvent = mock<EventEmitter>();
+    const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
+
+    const mockPageService: IPageService = {
+      updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
+      deleteCompletelyOperation: mockDeleteCompletelyOperation,
+      getEventEmitter: () => mockPageEvent,
+      deleteMultipleCompletely: mockDeleteMultipleCompletely,
+    };
+
+    it('should call used page service functions', async() => {
+      // when
+      const existsUserHomepagePath = '/user/user1';
+      await deleteCompletelyUserHomeBySystem(existsUserHomepagePath, mockPageService);
+
+      // then
+      expect(mockUpdateDescendantCountOfAncestors).toHaveBeenCalled();
+      expect(mockDeleteCompletelyOperation).toHaveBeenCalled();
+      expect(mockPageEvent.emit).toHaveBeenCalled();
+      expect(mockDeleteMultipleCompletely).toHaveBeenCalled();
+    });
+
+    it('should throw error if userHomepage is not exists', async() => {
+      // when
+      const notExistsUserHomepagePath = '/user/not_exists_user';
+      const deleteUserHomepageFunction = deleteCompletelyUserHomeBySystem(notExistsUserHomepagePath, mockPageService);
+
+      // then
+      expect(deleteUserHomepageFunction).rejects.toThrow('user homepage is not found.');
+    });
+  });
+});

+ 122 - 0
apps/app/src/server/service/page/delete-completely-user-home-by-system.ts

@@ -0,0 +1,122 @@
+import { Writable } from 'stream';
+
+import { getIdForRef } from '@growi/core';
+import type { IPage, Ref } from '@growi/core';
+import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
+import mongoose from 'mongoose';
+import streamToPromise from 'stream-to-promise';
+
+import type { PageModel } from '~/server/models/page';
+import { createBatchStream } from '~/server/util/batch-stream';
+import loggerFactory from '~/utils/logger';
+
+import { BULK_REINDEX_SIZE } from './consts';
+import type { IPageService } from './page-service';
+import { shouldUseV4Process } from './should-use-v4-process';
+
+const logger = loggerFactory('growi:services:page');
+
+
+type IPageUnderV5 = Omit<IPage, 'parent'> & { parent: Ref<IPage> }
+
+const _shouldUseV5Process = (page: IPage): page is IPageUnderV5 => {
+  return !shouldUseV4Process(page);
+};
+
+/**
+   * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
+   * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
+   *
+   * @param {string} userHomepagePath - The path of the user's homepage.
+   * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
+   * @throws {Error} - If an error occurs during the deletion process.
+   */
+export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string, pageService: IPageService): Promise<void> => {
+  if (!isUsersHomepage(userHomepagePath)) {
+    const msg = 'input value is not user homepage path.';
+    logger.error(msg);
+    throw new Error(msg);
+  }
+
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const userHomepage = await Page.findByPath(userHomepagePath, true);
+
+  if (userHomepage == null) {
+    const msg = 'user homepage is not found.';
+    logger.error(msg);
+    throw new Error(msg);
+  }
+
+  const shouldUseV5Process = _shouldUseV5Process(userHomepage);
+
+  const ids = [userHomepage._id];
+  const paths = [userHomepage.path];
+
+  try {
+    if (shouldUseV5Process) {
+      // Ensure consistency of ancestors
+      const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
+      await pageService.updateDescendantCountOfAncestors(getIdForRef(userHomepage.parent), inc, true);
+    }
+
+    // Delete the user's homepage
+    await pageService.deleteCompletelyOperation(ids, paths);
+
+    if (shouldUseV5Process) {
+      // Remove leaf empty pages
+      await Page.removeLeafEmptyPagesRecursively(getIdForRef(userHomepage.parent));
+    }
+
+    if (!userHomepage.isEmpty) {
+      // Emit an event for the search service
+      pageService.getEventEmitter().emit('deleteCompletely', userHomepage);
+    }
+
+    const { PageQueryBuilder } = Page;
+
+    // Find descendant pages with system deletion condition
+    const builder = new PageQueryBuilder(Page.find(), true)
+      .addConditionForSystemDeletion()
+      .addConditionToListOnlyDescendants(userHomepage.path, {});
+
+    // Stream processing to delete descendant pages
+    // ────────┤ start │─────────
+    const readStream = await builder
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    let count = 0;
+
+    const writeStream = new Writable({
+      objectMode: true,
+      async write(batch, encoding, callback) {
+        try {
+          count += batch.length;
+          // Delete multiple pages completely
+          await pageService.deleteMultipleCompletely(batch, undefined);
+          logger.debug(`Adding pages progressing: (count=${count})`);
+        }
+        catch (err) {
+          logger.error('addAllPages error on add anyway: ', err);
+        }
+        callback();
+      },
+      final(callback) {
+        logger.debug(`Adding pages has completed: (totalCount=${count})`);
+        callback();
+      },
+    });
+
+    readStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(writeStream);
+
+    await streamToPromise(writeStream);
+    // ────────┤ end │─────────
+  }
+  catch (err) {
+    logger.error('Error occurred while deleting user homepage and subpages.', err);
+    throw err;
+  }
+};

+ 46 - 147
apps/app/src/server/service/page.ts → apps/app/src/server/service/page/index.ts

@@ -1,3 +1,4 @@
+import type EventEmitter from 'events';
 import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 
@@ -5,7 +6,7 @@ import type {
   Ref, HasObjectId, IUserHasId, IUser,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
 } from '@growi/core';
-import { PageGrant, PageStatus, getIdForRef } from '@growi/core';
+import { PageGrant, PageStatus } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
@@ -32,21 +33,27 @@ import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
-import { Attachment } from '../models';
-import { PathAlreadyExistsError } from '../models/errors';
-import type { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-operation';
-import PageOperation, { type PageOperationDocument } from '../models/page-operation';
-import type { PageRedirectModel } from '../models/page-redirect';
-import { serializePageSecurely } from '../models/serializers/page-serializer';
-import ShareLink from '../models/share-link';
-import Subscription from '../models/subscription';
-import UserGroupRelation from '../models/user-group-relation';
-import { V5ConversionError } from '../models/vo/v5-conversion-error';
-import { divideByType } from '../util/granted-group';
-
-import { configManager } from './config-manager';
-import { preNotifyService } from './pre-notify';
+import { ObjectIdLike } from '../../interfaces/mongoose-utils';
+import { Attachment } from '../../models';
+import { PathAlreadyExistsError } from '../../models/errors';
+import { IOptionsForCreate, IOptionsForUpdate } from '../../models/interfaces/page-operation';
+import PageOperation, { PageOperationDocument } from '../../models/page-operation';
+import { PageRedirectModel } from '../../models/page-redirect';
+import { serializePageSecurely } from '../../models/serializers/page-serializer';
+import ShareLink from '../../models/share-link';
+import Subscription from '../../models/subscription';
+import UserGroupRelation from '../../models/user-group-relation';
+import { V5ConversionError } from '../../models/vo/v5-conversion-error';
+import { divideByType } from '../../util/granted-group';
+import { configManager } from '../config-manager';
+import { preNotifyService } from '../pre-notify';
+
+import { BULK_REINDEX_SIZE, LIMIT_FOR_MULTIPLE_PAGE_OP } from './consts';
+import { IPageService } from './page-service';
+import { shouldUseV4Process } from './should-use-v4-process';
+
+export * from './page-service';
+
 
 const debug = require('debug')('growi:services:page');
 
@@ -58,9 +65,6 @@ const {
 
 const { addTrailingSlash } = pathUtils;
 
-const BULK_REINDEX_SIZE = 100;
-const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
-
 // TODO: improve type
 class PageCursorsForDescendantsFactory {
 
@@ -142,11 +146,16 @@ class PageCursorsForDescendantsFactory {
 
 }
 
-class PageService {
+
+class PageService implements IPageService {
 
   crowi: any;
 
-  pageEvent: any;
+  pageEvent: EventEmitter & {
+    onCreate,
+    onCreateMany,
+    onAddSeenUsers,
+  };
 
   tagEvent: any;
 
@@ -173,6 +182,10 @@ class PageService {
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
   }
 
+  getEventEmitter(): EventEmitter {
+    return this.pageEvent;
+  }
+
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
     if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
@@ -373,20 +386,6 @@ class PageService {
     };
   }
 
-  private shouldUseV4Process(page): boolean {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    const isTrashPage = page.status === Page.STATUS_DELETED;
-    const isPageMigrated = page.parent != null;
-    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    const isRoot = isTopPage(page.path);
-    const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
-
-    const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
-
-    return shouldUseV4Process;
-  }
-
   private shouldUseV4ProcessForRevert(page): boolean {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
@@ -455,8 +454,8 @@ class PageService {
     }
 
     // Separate v4 & v5 process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.renamePageV4(page, newPagePath, user, options);
     }
 
@@ -1021,8 +1020,8 @@ class PageService {
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
     // 1. Separate v4 & v5 process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.duplicateV4(page, newPagePath, user, isRecursively);
     }
 
@@ -1446,8 +1445,8 @@ class PageService {
     const Page = mongoose.model('Page') as PageModel;
 
     // Separate v4 & v5 process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.deletePageV4(page, user, options, isRecursively);
     }
     // Validate
@@ -1773,7 +1772,7 @@ class PageService {
     return nDeletedNonEmptyPages;
   }
 
-  private async deleteCompletelyOperation(pageIds, pagePaths) {
+  async deleteCompletelyOperation(pageIds, pagePaths): Promise<void> {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Page = this.crowi.model('Page');
@@ -1784,7 +1783,7 @@ class PageService {
     const { attachmentService } = this.crowi;
     const attachments = await Attachment.find({ page: { $in: pageIds } });
 
-    return Promise.all([
+    await Promise.all([
       Bookmark.deleteMany({ page: { $in: pageIds } }),
       Comment.deleteMany({ page: { $in: pageIds } }),
       PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
@@ -1797,7 +1796,7 @@ class PageService {
   }
 
   // delete multiple pages
-  private async deleteMultipleCompletely(pages, user, options = {}) {
+  async deleteMultipleCompletely(pages, user) {
     const ids = pages.map(page => (page._id));
     const paths = pages.map(page => (page.path));
 
@@ -1825,8 +1824,8 @@ class PageService {
     }
 
     // v4 compatible process
-    const shouldUseV4Process = this.shouldUseV4Process(page);
-    if (shouldUseV4Process) {
+    const isShouldUseV4Process = shouldUseV4Process(page);
+    if (isShouldUseV4Process) {
       return this.deleteCompletelyV4(page, user, options, isRecursively, preventEmitting);
     }
 
@@ -2004,7 +2003,7 @@ class PageService {
 
         try {
           count += batch.length;
-          await deleteMultipleCompletely(batch, user, options);
+          await deleteMultipleCompletely(batch, user);
           const subscribedUsers = await Subscription.getSubscriptions(batch);
           subscribedUsers.forEach((eachUser) => {
             descendantsSubscribedSets.add(eachUser);
@@ -2056,106 +2055,6 @@ class PageService {
     }
   }
 
-  /**
-   * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
-   * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
-   *
-   * @param {string} userHomepagePath - The path of the user's homepage.
-   * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
-   * @throws {Error} - If an error occurs during the deletion process.
-   */
-  async deleteCompletelyUserHomeBySystem(userHomepagePath: string): Promise<void> {
-    if (!isUsersHomepage(userHomepagePath)) {
-      const msg = 'input value is not user homepage path.';
-      logger.error(msg);
-      throw new Error(msg);
-    }
-
-    const Page = mongoose.model<IPage, PageModel>('Page');
-    const userHomepage = await Page.findByPath(userHomepagePath, true);
-
-    if (userHomepage == null) {
-      const msg = 'user homepage is not found.';
-      logger.error(msg);
-      throw new Error(msg);
-    }
-
-    const shouldUseV4Process = this.shouldUseV4Process(userHomepage);
-
-    const ids = [userHomepage._id];
-    const paths = [userHomepage.path];
-    const parentId = getIdForRef(userHomepage.parent);
-
-    try {
-      if (!shouldUseV4Process) {
-        // Ensure consistency of ancestors
-        const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
-        await this.updateDescendantCountOfAncestors(parentId, inc, true);
-      }
-
-      // Delete the user's homepage
-      await this.deleteCompletelyOperation(ids, paths);
-
-      if (!shouldUseV4Process) {
-        // Remove leaf empty pages
-        await Page.removeLeafEmptyPagesRecursively(parentId);
-      }
-
-      if (!userHomepage.isEmpty) {
-        // Emit an event for the search service
-        this.pageEvent.emit('deleteCompletely', userHomepage);
-      }
-
-      const { PageQueryBuilder } = Page;
-
-      // Find descendant pages with system deletion condition
-      const builder = new PageQueryBuilder(Page.find(), true)
-        .addConditionForSystemDeletion()
-        .addConditionToListOnlyDescendants(userHomepage.path, {});
-
-      // Stream processing to delete descendant pages
-      // ────────┤ start │─────────
-      const readStream = await builder
-        .query
-        .lean()
-        .cursor({ batchSize: BULK_REINDEX_SIZE });
-
-      let count = 0;
-
-      const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
-      const writeStream = new Writable({
-        objectMode: true,
-        async write(batch, encoding, callback) {
-          try {
-            count += batch.length;
-            // Delete multiple pages completely
-            await deleteMultipleCompletely(batch, null, {});
-            logger.debug(`Adding pages progressing: (count=${count})`);
-          }
-          catch (err) {
-            logger.error('addAllPages error on add anyway: ', err);
-          }
-          callback();
-        },
-        final(callback) {
-          logger.debug(`Adding pages has completed: (totalCount=${count})`);
-          callback();
-        },
-      });
-
-      readStream
-        .pipe(createBatchStream(BULK_REINDEX_SIZE))
-        .pipe(writeStream);
-
-      await streamToPromise(writeStream);
-      // ────────┤ end │─────────
-    }
-    catch (err) {
-      logger.error('Error occurred while deleting user homepage and subpages.', err);
-      throw err;
-    }
-  }
-
   // use the same process in both v4 and v5
   private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');

+ 12 - 0
apps/app/src/server/service/page/page-service.ts

@@ -0,0 +1,12 @@
+import type EventEmitter from 'events';
+
+import type { IUser } from '@growi/core';
+
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+export interface IPageService {
+  updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
+  deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
+  getEventEmitter: () => EventEmitter,
+  deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+}

+ 20 - 0
apps/app/src/server/service/page/should-use-v4-process.ts

@@ -0,0 +1,20 @@
+import type { IPage } from '@growi/core';
+import { isTopPage } from '@growi/core/dist/utils/page-path-utils';
+import mongoose from 'mongoose';
+
+import { PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+
+export const shouldUseV4Process = (page: IPage): boolean => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+
+  const isTrashPage = page.status === Page.STATUS_DELETED;
+  const isPageMigrated = page.parent != null;
+  const isV5Compatible = configManager.getConfig('crowi', 'app:isV5Compatible');
+  const isRoot = isTopPage(page.path);
+  const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
+
+  const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
+
+  return shouldUseV4Process;
+};

+ 5 - 0
apps/app/src/server/service/passport.ts

@@ -849,6 +849,11 @@ class PassportService implements S2sMessageHandlable {
     }
 
     const { field, term } = luceneRule;
+
+    if (field == null) {
+      return true;
+    }
+
     const unescapedField = this.literalUnescape(field);
     if (unescapedField === '<implicit>') {
       return attributes[term] != null;

+ 1 - 1
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -106,7 +106,7 @@ context('Editor while uploading to a new page', () => {
 
 });
 
-context.skip('Editor while navigation', () => {
+context('Editor while navigation', () => {
 
   const ssPrefix = 'editor-while-navigation-';
 

+ 10 - 10
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -36,9 +36,8 @@ describe('Access to sidebar', () => {
           });
         });
 
-        // TODO: rewrite test case with grw-switch-collapse-button
         it('Successfully collapse sidebar', () => {
-          cy.getByTestid('grw-switch-collapse-button').click({force: true});
+          cy.getByTestid('btn-toggle-collapse').click({force: true});
 
           cy.getByTestid('grw-sidebar-contents').should('not.be.visible');
 
@@ -205,18 +204,19 @@ describe('Access to sidebar', () => {
           });
         });
 
-        it('Successfully redirect to editor', () => {
-          const content = '# HELLO \n ## Hello\n ### Hello';
+        // TODO: fix by https://redmine.weseek.co.jp/issues/138562
+        // it('Successfully redirect to editor', () => {
+        //   const content = '# HELLO \n ## Hello\n ### Hello';
 
-          cy.get('.grw-sidebar-content-header > h3 > a').should('be.visible').click();
+        //   cy.get('.grw-sidebar-content-header > h3 > a').should('be.visible').click();
 
-          cy.get('.layout-root').should('have.class', 'editing');
-          cy.get('.CodeMirror textarea').type(content, {force: true});
+        //   cy.get('.layout-root').should('have.class', 'editing');
+        //   cy.get('.CodeMirror textarea').type(content, {force: true});
 
-          cy.screenshot(`${ssPrefix}custom-sidebar-2-redirect-to-editor`, { blackout: blackoutOverride });
+        //   cy.screenshot(`${ssPrefix}custom-sidebar-2-redirect-to-editor`, { blackout: blackoutOverride });
 
-          cy.getByTestid('save-page-btn').click();
-        });
+        //   cy.getByTestid('save-page-btn').click();
+        // });
 
         it('Successfully create custom sidebar content', () => {
           cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar')

+ 39 - 10
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts

@@ -9,28 +9,57 @@ const blackoutOverride = [
 context('Switch sidebar mode', () => {
   const ssPrefix = 'switch-sidebar-mode-';
 
-  before(() => {
+  beforeEach(() => {
     // login
     cy.fixture("user-admin.json").then(user => {
       cy.login(user.username, user.password);
     });
+    cy.visit('/');
   });
 
   it('Switching sidebar mode', () => {
-    cy.visit('/');
-    cy.get('.grw-apperance-mode-dropdown').first().click();
-
-    cy.get('[for="swSidebarMode"]').click({force: true});
-    cy.get('.grw-sidebar-nav').should('not.be.visible');
-    cy.screenshot(`${ssPrefix}-switch-sidebar-mode`, {
+    cy.collapseSidebar(false);
+    cy.screenshot(`${ssPrefix}-doc-mode-opened`, {
       blackout: blackoutOverride,
     });
 
-    cy.get('[for="swSidebarMode"]').click({force: true});
-    cy.get('.grw-sidebar-nav').should('be.visible');
-    cy.screenshot(`${ssPrefix}-switch-sidebar-mode-back`, {
+    cy.collapseSidebar(true);
+    cy.screenshot(`${ssPrefix}-doc-mode-closed`, {
       blackout: blackoutOverride,
     });
   });
 
 });
+
+context('Switch viewport size', () => {
+  const ssPrefix = 'switch-viewport-size-';
+
+  const sizes = {
+    'xl': [1200, 1024],
+    'lg': [992, 1024],
+    'md': [768, 1024],
+    'sm': [576, 1024],
+    'xs': [575, 1024],
+    'iphone-x': [375, 812],
+  };
+
+  Object.entries(sizes).forEach(([screenLabel, size]) => {
+    it(`on ${screenLabel} screen`, () => {
+      cy.viewport(size[0], size[1]);
+
+      // login
+      cy.fixture("user-admin.json").then(user => {
+        cy.login(user.username, user.password);
+      });
+      cy.visit('/');
+
+      cy.get('.layout-root').should('be.visible');
+
+      cy.screenshot(`${ssPrefix}-${screenLabel}`, {
+        blackout: blackoutOverride,
+      });
+    });
+  });
+
+});
+

+ 6 - 6
apps/app/test/cypress/e2e/60-home/60-home--home.cy.ts

@@ -64,7 +64,7 @@ context('Access User settings', () => {
 
   it('Access External account', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(1) a').click();
-    cy.scrollTo('top');
+    cy.scrollTo('top', {ensureScrollable: false});
     cy.screenshot(`${ssPrefix}-external-account-1`);
     cy.getByTestid('grw-external-account-add-button').click();
     cy.getByTestid('grw-associate-modal').should('be.visible');
@@ -76,7 +76,7 @@ context('Access User settings', () => {
       cy.get('.Toastify__close-button').should('be.visible').click();
       cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
     });
-    cy.getByTestid('grw-associate-modal').find('.close').click();
+    cy.getByTestid('grw-associate-modal').find('[aria-label="Close"]').click();
     cy.screenshot(`${ssPrefix}-external-account-4`);
 
       cy.get('.Toastify__toast').should('not.be.visible');
@@ -84,7 +84,7 @@ context('Access User settings', () => {
 
   it('Access Password setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(2) a').click();
-    cy.scrollTo('top');
+    cy.scrollTo('top', {ensureScrollable: false});
     cy.screenshot(`${ssPrefix}-password-settings-1`);
     cy.getByTestid('grw-password-settings-update-button').click();
     cy.get('.Toastify__toast').should('be.visible');
@@ -100,7 +100,7 @@ context('Access User settings', () => {
 
   it('Access API setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(3) a').click();
-    cy.scrollTo('top');
+    cy.scrollTo('top', {ensureScrollable: false});
     cy.screenshot(`${ssPrefix}-api-setting-1`);
     cy.getByTestid('grw-api-settings-update-button').click();
     cy.getByTestid('grw-api-settings-input').should('be.visible');
@@ -115,7 +115,7 @@ context('Access User settings', () => {
 
   it('Access In-app notification setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(4) a').click();
-    cy.scrollTo('top');
+    cy.scrollTo('top', {ensureScrollable: false});
     cy.screenshot(`${ssPrefix}-in-app-notification-setting-1`);
     cy.getByTestid('grw-in-app-notification-settings-update-button').click();
     cy.get('.Toastify__toast').should('be.visible');
@@ -124,7 +124,7 @@ context('Access User settings', () => {
 
   it('Access Other setting', () => {
     cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(5) a').click();
-    cy.scrollTo('top');
+    cy.scrollTo('top', {ensureScrollable: false});
     cy.screenshot(`${ssPrefix}-other-setting-1`);
     cy.getByTestid('grw-questionnaire-settings-update-btn').click();
     cy.get('.Toastify__toast').should('be.visible').invoke('attr', 'style', 'display: none');

+ 1 - 1
apps/app/test/cypress/support/commands.ts

@@ -88,7 +88,7 @@ Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean, waitUntilSaving =
 
     cy.waitUntil(() => {
       // do
-      cy.getByTestid("grw-switch-collapse-button").click({force: true});
+      cy.getByTestid("btn-toggle-collapse").click({force: true});
       // wait until saving UserUISettings
       if (waitUntilSaving) {
         // eslint-disable-next-line cypress/no-unnecessary-waiting

+ 1 - 0
apps/app/test/integration/service/passport.test.js

@@ -24,6 +24,7 @@ describe('PassportService test', () => {
     let i = 0;
     describe.each`
       conditionId | departments   | positions     | ruleStr                                                         | expected
+      ${i++}      | ${undefined}  | ${undefined}  | ${' '}                                                          | ${true}
       ${i++}      | ${undefined}  | ${undefined}  | ${'Department: A'}                                              | ${false}
       ${i++}      | ${[]}         | ${['Leader']} | ${'Position'}                                                   | ${true}
       ${i++}      | ${[]}         | ${['Leader']} | ${'Position: Leader'}                                           | ${true}

+ 173 - 138
packages/preset-themes/src/styles/future.scss

@@ -1,139 +1,174 @@
-@use '@growi/core/scss/bootstrap/init' as bs;
-
-@use './variables' as var;
-@use './theme/mixins/page-editor-mode-manager';
-@use './theme/hsl-functions' as hsl;
-
-:root[data-bs-theme='dark']{
-  --primary: hsl(var(--primary-hs),var(--primary-l)) !important;
-  --primary-hs: 181,100%;
-  --primary-l: 36%;
-  --secondary: hsl(var(--secondary-hs),var(--secondary-l)) !important;
-  --secondary-hs: 208,7%;
-  --secondary-l: 46%;
-  --themecolor: hsl(var(--themecolor-hs),var(--themecolor-l));
-  --themecolor-hs: 193,34%;
-  --themecolor-l: 13%;
-  --accentcolor: hsl(var(--accentcolor-hs),var(--accentcolor-l));
-  --accentcolor-hs: 178,100%;
-  --accentcolor-l: 50%;
-
-  // Background colors
-  --bgcolor-global: var(--themecolor);
-  --bgcolor-global-hs: var(--themecolor-hs);
-  --bgcolor-global-l: var(--themecolor-l);
-  --bgcolor-inline-code: #1f1f22; //optional
-  --bgcolor-card: #{hsl.darken(var(--themecolor), 5%)};
-  --bgcolor-blinked-section: #{hsl.alpha(var(--primary), 40%)};
-  --bgcolor-keyword-highlighted: #{darken(var.$grw-marker-red, 30%)};
-
-  // Font colors
-  --color-global: #{hsl(var(--color-global-hs),var(--color-global-l))};
-  --color-global-hs: 204,21%;
-  --color-global-l: 66%;
-  --color-reversal: #{bs.$gray-900};
-  --color-header: var(--color-global);
-  --color-link: var(--accentcolor);
-  --color-link-hover: #{hsl.lighten(var(--accentcolor), 20%)};
-  --color-link-wiki: var(--accentcolor);
-  --color-link-wiki-hover: #{hsl.darken(var(--accentcolor), 5%)};
-  --color-link-nabvar: #a7a7a7;
-  --color-inline-code: #c7254e; // optional
-  --color-search: var(--primary);
-
-  // List Group colors
-  // --color-list: var(--color-global);
-  --bgcolor-list: transparent;
-  // --color-list-hover: var(--color-reversal);
-  --color-list-active: white;
-  --bgcolor-list-active: var(--primary);
-  // --color-page-list-group-item-meta: #{$gray-500}; // optional
-
-  // Table colors
-  // --color-table: #; // optional
-  --bgcolor-table: #{hsl.darken(var(--themecolor), 3%)}; // optional
-  --border-color-table: #{hsl.lighten(var(--themecolor), 10%)};; // optional
-  // --color-table-hover: #; // optional
-  // --bgcolor-table-hover: #; // optional
-
-  // Navbar
-  --bgcolor-navbar: hsl(var(--bgcolor-navbar-hs),var(--bgcolor-navbar-l));
-  --bgcolor-navbar-hs: 185,93%;
-  --bgcolor-navbar-l: 5%;
-  --bgcolor-search-top-dropdown: hsl(var(--bgcolor-search-top-dropdown-hs),var(--bgcolor-search-top-dropdown-l));
-  --bgcolor-search-top-dropdown-hs: 181,100%;
-  --bgcolor-search-top-dropdown-l: 38%;
-  --border-image-navbar: linear-gradient(90deg, #6cfff9 0%, #0034c1 45%, #6cfff9 100%);
-
-  // Logo colors
-  --bgcolor-logo: #{hsl.darken(var(--themecolor), 10%)};
-  --fillcolor-logo-mark: #dedede;
-
-  // Sidebar
-  --bgcolor-sidebar: hsl(var(--bgcolor-sidebar-hs),var(--bgcolor-sidebar-l));
-  --bgcolor-sidebar-hs: 181,81%;
-  --bgcolor-sidebar-l: 10%;
-  --bgcolor-sidebar-nav-item-active: rgba(#969494, 0.3); // optional
-  --text-shadow-sidebar-nav-item-active: 0px 0px 10px #969494; // optional
-
-  // Sidebar resize button
-  --color-resize-button: #0e2329;
-  --bgcolor-resize-button: var(--bgcolor-search-top-dropdown);
-  --bgcolor-resize-button-hs: var(--bgcolor-search-top-dropdown-hs);
-  --bgcolor-resize-button-l: var(--bgcolor-search-top-dropdown-l);
-  --color-resize-button-hover: #0e2329;
-  --bgcolor-resize-button-hover: #{hsl.lighten(var(--bgcolor-search-top-dropdown), 5%)};
-
-  // Sidebar contents
-  --color-sidebar-context: hsl(var(--color-sidebar-context-hs),var(--color-sidebar-context-l));
-  --color-sidebar-context-hs: 181,100%;
-  --color-sidebar-context-l: 59%;
-  --bgcolor-sidebar-context: hsl(var(--bgcolor-sidebar-context-hs),var(--bgcolor-sidebar-context-l));
-  --bgcolor-sidebar-context-hs: 180,45%;
-  --bgcolor-sidebar-context-l: 17%;
-
-  // Sidebar list group
-  --bgcolor-sidebar-list-group: #162126; // optional
-
-  // Subnavigation
-  --bgcolor-subnav: hsl(var(--bgcolor-subnav-hs),var(--bgcolor-subnav-l));
-  --bgcolor-subnav-hs: var(--bgcolor-global-hs);
-  --bgcolor-subnav-l: calc(var(--bgcolor-global-l) - 3%);
-
-  // Tabs
-  --bordercolor-nav-tabs: #4c9eb4; // optional
-  // --color-nav-tabs-link-active: #; //optional
-  --bordercolor-nav-tabs-hover: #295561 #295561 var(--bordercolor-nav-tabs); // optional
-  // --bordercolor-nav-tabs-active: # # var(--bgcolor-global); // optional
-
-  // Tags
-  // --color-tags: #; //optional
-  // --bgcolor-tags: #; //optional
-
-  // Icon colors
-  --color-editor-icons: var(--color-global);
-
-  // Border colors
-  --border-color-theme: #407483;
-  --bordercolor-inline-code: #4d4d4d; // optional
-
-  // Dropdown colors
-  --bgcolor-dropdown-link-active: #{var.$growi-blue};
-
-  // admin theme box
-  --color-theme-color-box: #{hsl.lighten(var(--primary), 20%)};
-
-  //Button
-  .btn-group.grw-page-editor-mode-manager {
-    .btn.btn-outline-primary {
-      @include page-editor-mode-manager.btn-page-editor-mode-manager(#{hsl.lighten(var(--primary), 10%)}, var(--primary), #{hsl.darken(var(--primary), 10%)}, #{hsl.darken(var(--primary), 20%)});
-    }
-  }
-
-  // headers
-  @for $i from 1 through 6 {
-    h#{$i} {
-      color: white;
-    }
-  }
+:root[data-bs-theme] {
+  @import '@growi/core/scss/bootstrap/init-stage-1';
+  @import '@growi/core/scss/bootstrap/theming/variables';
+  @import '@growi/core/scss/bootstrap/theming/utils/color-palette';
+
+  $primary: #15A3A7;
+  $highlight: #406C65;
+
+  @include generate-color-palette('primary', $primary, black, white);
+  @include generate-color-palette('highlight', $highlight, black, white);
+
+  $body-color-dark:                   mix(#406C65, white, 20%);
+  $body-bg-dark:                      mix(#15A3A7, black, 20%);
+
+  $body-secondary-color-dark:         rgba($body-color-dark, .75);
+  $body-secondary-bg-dark:            $gray-800;
+
+  $body-tertiary-color-dark:          rgba($body-color-dark, .5);
+  $body-tertiary-bg-dark:             mix($gray-800, $gray-900, 50%);
+
+  $border-color-dark:                 $gray-700;
+
+  $link-color-dark:                   $gray-400;
+
+  @import 'bootstrap/scss/variables';
+  @import 'bootstrap/scss/variables-dark';
+
+  @import '@growi/core/scss/bootstrap/init-stage-2';
+
+  @import '@growi/core/scss/bootstrap/theming/apply-dark';
+
+  --grw-wiki-link-color-rgb: var(--grw-primary-400-rgb);
+  --grw-wiki-link-hover-color-rgb: var(--grw-primary-300-rgb);
 }
+
+// @use '@growi/core/scss/bootstrap/init' as bs;
+
+// @use './variables' as var;
+// @use './theme/mixins/page-editor-mode-manager';
+// @use './theme/hsl-functions' as hsl;
+
+// :root[data-bs-theme='dark']{
+//   --primary: hsl(var(--primary-hs),var(--primary-l)) !important;
+//   --primary-hs: 181,100%;
+//   --primary-l: 36%;
+//   --secondary: hsl(var(--secondary-hs),var(--secondary-l)) !important;
+//   --secondary-hs: 208,7%;
+//   --secondary-l: 46%;
+//   --themecolor: hsl(var(--themecolor-hs),var(--themecolor-l));
+//   --themecolor-hs: 193,34%;
+//   --themecolor-l: 13%;
+//   --accentcolor: hsl(var(--accentcolor-hs),var(--accentcolor-l));
+//   --accentcolor-hs: 178,100%;
+//   --accentcolor-l: 50%;
+
+//   // Background colors
+//   --bgcolor-global: var(--themecolor);
+//   --bgcolor-global-hs: var(--themecolor-hs);
+//   --bgcolor-global-l: var(--themecolor-l);
+//   --bgcolor-inline-code: #1f1f22; //optional
+//   --bgcolor-card: #{hsl.darken(var(--themecolor), 5%)};
+//   --bgcolor-blinked-section: #{hsl.alpha(var(--primary), 40%)};
+//   --bgcolor-keyword-highlighted: #{darken(var.$grw-marker-red, 30%)};
+
+//   // Font colors
+//   --color-global: #{hsl(var(--color-global-hs),var(--color-global-l))};
+//   --color-global-hs: 204,21%;
+//   --color-global-l: 66%;
+//   --color-reversal: #{bs.$gray-900};
+//   --color-header: var(--color-global);
+//   --color-link: var(--accentcolor);
+//   --color-link-hover: #{hsl.lighten(var(--accentcolor), 20%)};
+//   --color-link-wiki: var(--accentcolor);
+//   --color-link-wiki-hover: #{hsl.darken(var(--accentcolor), 5%)};
+//   --color-link-nabvar: #a7a7a7;
+//   --color-inline-code: #c7254e; // optional
+//   --color-search: var(--primary);
+
+//   // List Group colors
+//   // --color-list: var(--color-global);
+//   --bgcolor-list: transparent;
+//   // --color-list-hover: var(--color-reversal);
+//   --color-list-active: white;
+//   --bgcolor-list-active: var(--primary);
+//   // --color-page-list-group-item-meta: #{$gray-500}; // optional
+
+//   // Table colors
+//   // --color-table: #; // optional
+//   --bgcolor-table: #{hsl.darken(var(--themecolor), 3%)}; // optional
+//   --border-color-table: #{hsl.lighten(var(--themecolor), 10%)};; // optional
+//   // --color-table-hover: #; // optional
+//   // --bgcolor-table-hover: #; // optional
+
+//   // Navbar
+//   --bgcolor-navbar: hsl(var(--bgcolor-navbar-hs),var(--bgcolor-navbar-l));
+//   --bgcolor-navbar-hs: 185,93%;
+//   --bgcolor-navbar-l: 5%;
+//   --bgcolor-search-top-dropdown: hsl(var(--bgcolor-search-top-dropdown-hs),var(--bgcolor-search-top-dropdown-l));
+//   --bgcolor-search-top-dropdown-hs: 181,100%;
+//   --bgcolor-search-top-dropdown-l: 38%;
+//   --border-image-navbar: linear-gradient(90deg, #6cfff9 0%, #0034c1 45%, #6cfff9 100%);
+
+//   // Logo colors
+//   --bgcolor-logo: #{hsl.darken(var(--themecolor), 10%)};
+//   --fillcolor-logo-mark: #dedede;
+
+//   // Sidebar
+//   --bgcolor-sidebar: hsl(var(--bgcolor-sidebar-hs),var(--bgcolor-sidebar-l));
+//   --bgcolor-sidebar-hs: 181,81%;
+//   --bgcolor-sidebar-l: 10%;
+//   --bgcolor-sidebar-nav-item-active: rgba(#969494, 0.3); // optional
+//   --text-shadow-sidebar-nav-item-active: 0px 0px 10px #969494; // optional
+
+//   // Sidebar resize button
+//   --color-resize-button: #0e2329;
+//   --bgcolor-resize-button: var(--bgcolor-search-top-dropdown);
+//   --bgcolor-resize-button-hs: var(--bgcolor-search-top-dropdown-hs);
+//   --bgcolor-resize-button-l: var(--bgcolor-search-top-dropdown-l);
+//   --color-resize-button-hover: #0e2329;
+//   --bgcolor-resize-button-hover: #{hsl.lighten(var(--bgcolor-search-top-dropdown), 5%)};
+
+//   // Sidebar contents
+//   --color-sidebar-context: hsl(var(--color-sidebar-context-hs),var(--color-sidebar-context-l));
+//   --color-sidebar-context-hs: 181,100%;
+//   --color-sidebar-context-l: 59%;
+//   --bgcolor-sidebar-context: hsl(var(--bgcolor-sidebar-context-hs),var(--bgcolor-sidebar-context-l));
+//   --bgcolor-sidebar-context-hs: 180,45%;
+//   --bgcolor-sidebar-context-l: 17%;
+
+//   // Sidebar list group
+//   --bgcolor-sidebar-list-group: #162126; // optional
+
+//   // Subnavigation
+//   --bgcolor-subnav: hsl(var(--bgcolor-subnav-hs),var(--bgcolor-subnav-l));
+//   --bgcolor-subnav-hs: var(--bgcolor-global-hs);
+//   --bgcolor-subnav-l: calc(var(--bgcolor-global-l) - 3%);
+
+//   // Tabs
+//   --bordercolor-nav-tabs: #4c9eb4; // optional
+//   // --color-nav-tabs-link-active: #; //optional
+//   --bordercolor-nav-tabs-hover: #295561 #295561 var(--bordercolor-nav-tabs); // optional
+//   // --bordercolor-nav-tabs-active: # # var(--bgcolor-global); // optional
+
+//   // Tags
+//   // --color-tags: #; //optional
+//   // --bgcolor-tags: #; //optional
+
+//   // Icon colors
+//   --color-editor-icons: var(--color-global);
+
+//   // Border colors
+//   --border-color-theme: #407483;
+//   --bordercolor-inline-code: #4d4d4d; // optional
+
+//   // Dropdown colors
+//   --bgcolor-dropdown-link-active: #{var.$growi-blue};
+
+//   // admin theme box
+//   --color-theme-color-box: #{hsl.lighten(var(--primary), 20%)};
+
+//   //Button
+//   .btn-group.grw-page-editor-mode-manager {
+//     .btn.btn-outline-primary {
+//       @include page-editor-mode-manager.btn-page-editor-mode-manager(#{hsl.lighten(var(--primary), 10%)}, var(--primary), #{hsl.darken(var(--primary), 10%)}, #{hsl.darken(var(--primary), 20%)});
+//     }
+//   }
+
+//   // headers
+//   @for $i from 1 through 6 {
+//     h#{$i} {
+//       color: white;
+//     }
+//   }
+// }

+ 168 - 133
packages/preset-themes/src/styles/kibela.scss

@@ -1,134 +1,169 @@
-@use '@growi/core/scss/bootstrap/init' as bs;
-
-@use './variables' as var;
-@use './theme/mixins/page-editor-mode-manager';
-@use './theme/hsl-functions' as hsl;
-
-:root[data-bs-theme='light']{
-  --primary: hsl(var(--primary-hs),var(--primary-l)) !important;
-  --primary-hs: 212,80%;
-  --primary-l: 35%;
-  --secondary: hsl(var(--secondary-hs),var(--secondary-l)) !important;
-  --secondary-hs: 208,7%;
-  --secondary-l: 46%;
-  --subthemecolor: hsl(var(--subthemecolor-hs),var(--subthemecolor-l));
-  --subthemecolor-hs: 224,94%;
-  --subthemecolor-l: 66%;
-  --lightthemecolor: hsl(var(--lightthemecolor-hs),var(--lightthemecolor-l));
-  --lightthemecolor-hs: 220,80%;
-  --lightthemecolor-l: 84%;
-
-  // Background colors
-  --bgcolor-global: hsl(var(--bgcolor-global-hs),var(--bgcolor-global-l));
-  --bgcolor-global-hs: 210,10%;
-  --bgcolor-global-l: 96%;
-  --bgcolor-inline-code: #{hsl.lighten(var(--subthemecolor), 70%)};
-  --bgcolor-card: var(--lightthemecolor);
-  --bgcolor-blinked-section: #{hsl.alpha(var(--primary),20%)};
-  //--bgcolor-keyword-highlighted: #{$grw-marker-yellow};
-
-  // Font colors
-  --color-global: hsl(var(--color-global-hs),var(--color-global-l));
-  --color-global-hs: 217,23%;
-  --color-global-l: 31%;
-  --color-reversal: #{bs.$gray-100};
-  --color-header: var(--primary);
-  --color-link: hsl(var(--color-link-hs),var(--color-link-l));
-  --color-link-hs: 224,56%;
-  --color-link-l: 55%;
-  --color-link-hover: #{hsl.lighten(var(--color-link),12%)};
-  --color-link-wiki: #{hsl.lighten(var(--primary), 20%)};
-  --color-link-wiki-hover: #{hsl.lighten(var(--primary), 40%)};
-  --color-link-nabvar: var(--color-global);
-  --color-inline-code: var(--subthemecolor);
-
-  // List Group colors
-  --color-list: var(--color-global); // optional
-  --bgcolor-list: var(--bgcolor-global); // optional
-  --color-list-hover: var(--color-reversal);
-  --color-list-active: var(--color-reversal);
-  --bgcolor-list-active: var(--primary);
-
-  // Navbar
-  --bgcolor-navbar: hsl(var(--bgcolor-navbar-hs),var(--bgcolor-navbar-l));
-  --bgcolor-navbar-hs: 0,0%;
-  --bgcolor-navbar-l: 100%;
-  --bgcolor-search-top-dropdown: var(--primary);
-  --bgcolor-search-top-dropdown-hs: var(--primary-hs);
-  --bgcolor-search-top-dropdown-l: var(--primary-l);
-
-  // Logo colors
-  --bgcolor-logo: transparent;
-  --fillcolor-logo-mark: #{hsl.lighten(var(--primary), 20%)};
-
-  // Sidebar
-  --bgcolor-sidebar: var(--primary);
-  --bgcolor-sidebar-hs: var(--primary-hs);
-  --bgcolor-sidebar-l: var(--primary-l);
-  --bgcolor-sidebar-context: #{hsl.lighten(var(--primary), 10%)};
-
-  // Sidebar resize button
-  --color-resize-button: var(--color-reversal);
-  --bgcolor-resize-button: hsl(var(--bgcolor-resize-button-hs),var(--bgcolor-resize-button-l));
-  --bgcolor-resize-button-hs: 199,74%;
-  --bgcolor-resize-button-l: 49%;
-  --color-resize-button-hover: var(--color-reversal);
-  --bgcolor-resize-button-hover: #{hsl.lighten(var(--bgcolor-resize-button), 5%)};
-
-  // Sidebar contents
-  --color-sidebar-context: var(--color-global);
-  --color-sidebar-context-hs: var(--color-global-hs);
-  --color-sidebar-context-l: var(--color-global-l);
-  --bgcolor-sidebar-context: hsl(var(--bgcolor-sidebar-context-hs),var(--bgcolor-sidebar-context-l));
-  --bgcolor-sidebar-context-hs: 225,57%;
-  --bgcolor-sidebar-context-l: 97%;
-
-  // Sidebar list group
-  --bgcolor-sidebar-list-group: #fafbff; // optional
-
-  // Subnavigation
-  --bgcolor-subnav: hsl(var(--bgcolor-subnav-hs),var(--bgcolor-subnav-l));
-  --bgcolor-subnav-hs: var(--bgcolor-global-hs);
-  --bgcolor-subnav-l: calc(var(--bgcolor-global-l) - 3%);
-
-  // Icon colors
-  --color-editor-icons: var(--color-global);
-
-  // border colors
-  --border-color-theme: var(--lightthemecolor);
-  --thickborder: #5584e1;
-  --bordercolor-inline-code: var(--lightthemecolor);
-
-  // dropdown colors
-  --bgcolor-dropdown-link-active: #{var.$growi-blue};
-
-  // admin theme box
-  --color-theme-color-box: #{hsl.lighten(var(--primary), 20%)};
-
-  .main {
-    .container,
-    .container-sm,
-    .container-md,
-    .container-lg,
-    .container-fluid {
-      padding-top: 30px;
-      padding-bottom: 30px;
-      background-color: white;
-      border-radius: 0.35em;
-    }
-  }
-
-  .user-page-footer {
-    margin-top: 3rem;
-    margin-bottom: 3rem;
-    background-color: white;
-    border-radius: 0.35em;
-  }
-
-  //Button
-  .btn-group.grw-page-editor-mode-manager {
-    .btn.btn-outline-primary {
-      @include page-editor-mode-manager.btn-page-editor-mode-manager (#{hsl.darken(var(--primary), 15%)}, #{hsl.lighten(var(--primary), 45%)}, #{hsl.lighten(var(--primary), 50%)});
-    }
-  }
+:root[data-bs-theme] {
+  @import '@growi/core/scss/bootstrap/init-stage-1';
+  @import '@growi/core/scss/bootstrap/theming/variables';
+  @import '@growi/core/scss/bootstrap/theming/utils/color-palette';
+
+  $primary: #3780C0;
+  $highlight: #909090;
+
+  @include generate-color-palette('primary', $primary, black, #F5F5F5, 20%, 25%);
+  @include generate-color-palette('highlight', $highlight, black, white, 20%, 25%);
+
+  $body-color:                $gray-700;
+  $body-bg:                   white;
+
+  $body-secondary-color:      rgba($body-color, .75);
+  $body-secondary-bg:         $gray-200;
+
+  $body-tertiary-color:       rgba($body-color, .5);
+  $body-tertiary-bg:          $gray-100;
+
+  $border-color:              $gray-300;
+
+  $link-color:                $gray-800;
+
+  @import 'bootstrap/scss/variables';
+  @import 'bootstrap/scss/variables-dark';
+
+  @import '@growi/core/scss/bootstrap/init-stage-2';
+
+  @import '@growi/core/scss/bootstrap/theming/apply-light';
+
+  --grw-wiki-link-color-rgb: var(--grw-primary-400-rgb);
+  --grw-wiki-link-hover-color-rgb: var(--grw-primary-500-rgb);
 }
+
+// @use '@growi/core/scss/bootstrap/init' as bs;
+
+// @use './variables' as var;
+// @use './theme/mixins/page-editor-mode-manager';
+// @use './theme/hsl-functions' as hsl;
+
+// :root[data-bs-theme='light']{
+//   --primary: hsl(var(--primary-hs),var(--primary-l)) !important;
+//   --primary-hs: 212,80%;
+//   --primary-l: 35%;
+//   --secondary: hsl(var(--secondary-hs),var(--secondary-l)) !important;
+//   --secondary-hs: 208,7%;
+//   --secondary-l: 46%;
+//   --subthemecolor: hsl(var(--subthemecolor-hs),var(--subthemecolor-l));
+//   --subthemecolor-hs: 224,94%;
+//   --subthemecolor-l: 66%;
+//   --lightthemecolor: hsl(var(--lightthemecolor-hs),var(--lightthemecolor-l));
+//   --lightthemecolor-hs: 220,80%;
+//   --lightthemecolor-l: 84%;
+
+//   // Background colors
+//   --bgcolor-global: hsl(var(--bgcolor-global-hs),var(--bgcolor-global-l));
+//   --bgcolor-global-hs: 210,10%;
+//   --bgcolor-global-l: 96%;
+//   --bgcolor-inline-code: #{hsl.lighten(var(--subthemecolor), 70%)};
+//   --bgcolor-card: var(--lightthemecolor);
+//   --bgcolor-blinked-section: #{hsl.alpha(var(--primary),20%)};
+//   //--bgcolor-keyword-highlighted: #{$grw-marker-yellow};
+
+//   // Font colors
+//   --color-global: hsl(var(--color-global-hs),var(--color-global-l));
+//   --color-global-hs: 217,23%;
+//   --color-global-l: 31%;
+//   --color-reversal: #{bs.$gray-100};
+//   --color-header: var(--primary);
+//   --color-link: hsl(var(--color-link-hs),var(--color-link-l));
+//   --color-link-hs: 224,56%;
+//   --color-link-l: 55%;
+//   --color-link-hover: #{hsl.lighten(var(--color-link),12%)};
+//   --color-link-wiki: #{hsl.lighten(var(--primary), 20%)};
+//   --color-link-wiki-hover: #{hsl.lighten(var(--primary), 40%)};
+//   --color-link-nabvar: var(--color-global);
+//   --color-inline-code: var(--subthemecolor);
+
+//   // List Group colors
+//   --color-list: var(--color-global); // optional
+//   --bgcolor-list: var(--bgcolor-global); // optional
+//   --color-list-hover: var(--color-reversal);
+//   --color-list-active: var(--color-reversal);
+//   --bgcolor-list-active: var(--primary);
+
+//   // Navbar
+//   --bgcolor-navbar: hsl(var(--bgcolor-navbar-hs),var(--bgcolor-navbar-l));
+//   --bgcolor-navbar-hs: 0,0%;
+//   --bgcolor-navbar-l: 100%;
+//   --bgcolor-search-top-dropdown: var(--primary);
+//   --bgcolor-search-top-dropdown-hs: var(--primary-hs);
+//   --bgcolor-search-top-dropdown-l: var(--primary-l);
+
+//   // Logo colors
+//   --bgcolor-logo: transparent;
+//   --fillcolor-logo-mark: #{hsl.lighten(var(--primary), 20%)};
+
+//   // Sidebar
+//   --bgcolor-sidebar: var(--primary);
+//   --bgcolor-sidebar-hs: var(--primary-hs);
+//   --bgcolor-sidebar-l: var(--primary-l);
+//   --bgcolor-sidebar-context: #{hsl.lighten(var(--primary), 10%)};
+
+//   // Sidebar resize button
+//   --color-resize-button: var(--color-reversal);
+//   --bgcolor-resize-button: hsl(var(--bgcolor-resize-button-hs),var(--bgcolor-resize-button-l));
+//   --bgcolor-resize-button-hs: 199,74%;
+//   --bgcolor-resize-button-l: 49%;
+//   --color-resize-button-hover: var(--color-reversal);
+//   --bgcolor-resize-button-hover: #{hsl.lighten(var(--bgcolor-resize-button), 5%)};
+
+//   // Sidebar contents
+//   --color-sidebar-context: var(--color-global);
+//   --color-sidebar-context-hs: var(--color-global-hs);
+//   --color-sidebar-context-l: var(--color-global-l);
+//   --bgcolor-sidebar-context: hsl(var(--bgcolor-sidebar-context-hs),var(--bgcolor-sidebar-context-l));
+//   --bgcolor-sidebar-context-hs: 225,57%;
+//   --bgcolor-sidebar-context-l: 97%;
+
+//   // Sidebar list group
+//   --bgcolor-sidebar-list-group: #fafbff; // optional
+
+//   // Subnavigation
+//   --bgcolor-subnav: hsl(var(--bgcolor-subnav-hs),var(--bgcolor-subnav-l));
+//   --bgcolor-subnav-hs: var(--bgcolor-global-hs);
+//   --bgcolor-subnav-l: calc(var(--bgcolor-global-l) - 3%);
+
+//   // Icon colors
+//   --color-editor-icons: var(--color-global);
+
+//   // border colors
+//   --border-color-theme: var(--lightthemecolor);
+//   --thickborder: #5584e1;
+//   --bordercolor-inline-code: var(--lightthemecolor);
+
+//   // dropdown colors
+//   --bgcolor-dropdown-link-active: #{var.$growi-blue};
+
+//   // admin theme box
+//   --color-theme-color-box: #{hsl.lighten(var(--primary), 20%)};
+
+//   .main {
+//     .container,
+//     .container-sm,
+//     .container-md,
+//     .container-lg,
+//     .container-fluid {
+//       padding-top: 30px;
+//       padding-bottom: 30px;
+//       background-color: white;
+//       border-radius: 0.35em;
+//     }
+//   }
+
+//   .user-page-footer {
+//     margin-top: 3rem;
+//     margin-bottom: 3rem;
+//     background-color: white;
+//     border-radius: 0.35em;
+//   }
+
+//   //Button
+//   .btn-group.grw-page-editor-mode-manager {
+//     .btn.btn-outline-primary {
+//       @include page-editor-mode-manager.btn-page-editor-mode-manager (#{hsl.darken(var(--primary), 15%)}, #{hsl.lighten(var(--primary), 45%)}, #{hsl.lighten(var(--primary), 50%)});
+//     }
+//   }
+// }

+ 158 - 123
packages/preset-themes/src/styles/nature.scss

@@ -1,125 +1,160 @@
-@use '@growi/core/scss/bootstrap/init' as bs;
-
-@use './variables' as var;
-@use './theme/mixins/page-editor-mode-manager';
-@use './theme/hsl-functions' as hsl;
-
-.growi:not(.login-page) {
-  // add background-image
-  .page-editor-preview-container {
-    background-attachment: fixed;
-    background-position: center center;
-    background-size: cover;
-  }
-}
+:root[data-bs-theme] {
+  @import '@growi/core/scss/bootstrap/init-stage-1';
+  @import '@growi/core/scss/bootstrap/theming/variables';
+  @import '@growi/core/scss/bootstrap/theming/utils/color-palette';
+
+  $primary: #4FA529;
+  $highlight: #9DE201;
+
+  @include generate-color-palette('primary', $primary, black, white);
+  @include generate-color-palette('highlight', $highlight, black, white);
+
+  $body-color:                $gray-800;
+  $body-bg:                   mix(#9DE201, white, 5%);
+
+  $body-secondary-color:      rgba($body-color, .75);
+  $body-secondary-bg:         $gray-200;
+
+  $body-tertiary-color:       rgba($body-color, .5);
+  $body-tertiary-bg:          $gray-100;
 
-//== Light Mode
-//
-:root[data-bs-theme='light'] {
-  --primary: hsl(var(--primary-hs),var(--primary-l)) !important;
-  --primary-hs: 311,100%;
-  --primary-l: 14%;
-  --secondary: hsl(var(--secondary-hs),var(--secondary-l)) !important;
-  --secondary-hs: 208,7%;
-  --secondary-l: 46%;
-
-  // Background colors
-  --bgcolor-global: hsl(var(--bgcolor-global-hs),var(--bgcolor-global-l));
-  --bgcolor-global-hs: 0,0%;
-  --bgcolor-global-l: 99%;
-  --bgcolor-inline-code: #{bs.$gray-100}; //optional
-  --bgcolor-card: #f1ffe4;
-  --bgcolor-blinked-section: #{hsl.alpha(var(--primary), 10%)};
-  //--bgcolor-keyword-highlighted: #{$grw-marker-yellow};
-
-  // Font colors
-  --color-global: hsl(var(--color-global-hs),var(--color-global-l));
-  --color-global-hs: 311,100%;
-  --color-global-l: 14%;
-  --color-reversal: #eeeeee;
-  --color-link: hsl(var(--color-link-hs),var(--color-link-l));
-  --color-link-hs: 328,100%;
-  --color-link-l: 25%;
-  --color-link-hover: #{hsl.lighten(var(--color-link), 20%)};
-  --color-link-wiki: #{hsl.lighten(var(--primary), 20%)};
-  --color-link-wiki-hs: var(--primary-hs);
-  --color-link-wiki-l: calc(var(--primary-l) + 20%);
-  --color-link-wiki-hover: #{hsl.lighten(var(--primary), 40%)};
-  --color-link-nabvar: #a7a7a7;
-  --color-inline-code: #c7254e; // optional
-  --color-search: white;
-
-  // Navbar
-  --bgcolor-navbar: hsl(var(--bgcolor-navbar-hs),var(--bgcolor-navbar-l));
-  --bgcolor-navbar-hs: 158,30%;
-  --bgcolor-navbar-l: 20%;
-  --bgcolor-search-top-dropdown: hsl(var(--bgcolor-search-top-dropdown-hs),var(--bgcolor-search-top-dropdown-l));
-  --bgcolor-search-top-dropdown-hs: 115,95%;
-  --bgcolor-search-top-dropdown-l: 36%;
-  --border-image-navbar: linear-gradient(to right, #5c78ef 0%, #16bc42 50%, #5c78ef 100%);
-
-  // Logo colors
-  --bgcolor-logo: var(--bgcolor-navbar);
-  --fillcolor-logo-mark: #{lighten(desaturate(bs.$gray-100, 10%), 15%)};
-
-  // Sidebar
-  --bgcolor-sidebar: hsl(var(--bgcolor-sidebar-hs),var(--bgcolor-sidebar-l));
-  --bgcolor-sidebar-hs: 158,71%;
-  --bgcolor-sidebar-l: 33%;
-
-  // Sidebar contents
-  --color-sidebar-context: hsl(var(--color-sidebar-context-hs),var(--color-sidebar-context-l));
-  --color-sidebar-context-hs: 328,100%;
-  --color-sidebar-context-l: 25%;
-  --bgcolor-sidebar-context: hsl(var(--bgcolor-sidebar-context-hs),var(--bgcolor-sidebar-context-l));
-  --bgcolor-sidebar-context-hs: 67,31%;
-  --bgcolor-sidebar-context-l: 94%;
-
-  // Sidebar resize button
-  --color-resize-button: white;
-  --bgcolor-resize-button: hsl(var(--bgcolor-resize-button-hs),var(--bgcolor-resize-button-l));
-  --bgcolor-resize-button-hs: 115,95%;
-  --bgcolor-resize-button-l: 36%;
-  --color-resize-button-hover: var(--color-reversal);
-  --bgcolor-resize-button-hover: #{hsl.lighten(var(--bgcolor-resize-button), 5%)};
-
-  // Subnavigation
-  --bgcolor-subnav: hsl(var(--bgcolor-subnav-hs),var(--bgcolor-subnav-l));
-  --bgcolor-subnav-hs: 0,0%;
-  --bgcolor-subnav-l: 98%;
-
-  // Icon colors
-  --color-editor-icons: var(--color-global);
-
-  // Border colors
-  --border-color-theme: #{bs.$gray-300};
-  --bordercolor-inline-code: #ccc8c8; // optional
-
-  // Table colors
-  --border-color-table: #{bs.$gray-400}; // optional
-
-  // admin theme box
-  --color-theme-color-box: #{hsl.lighten(var(--primary), 20%)};
-
-  // Search Top
-  .grw-global-search {
-    .btn-secondary.dropdown-toggle {
-      color: var(--color-search);
-    }
-  }
-
-  // Navs
-  .nav-tabs .nav-link.active {
-    color: var(--color-link) !important;
-    &:hover {
-      color: var(--color-link-hover) !important;
-    }
-  }
-
-  // Button
-  .btn-group.grw-page-editor-mode-manager {
-    .btn.btn-outline-primary {
-      @include page-editor-mode-manager.btn-page-editor-mode-manager(var(--bgcolor-navbar), #{hsl.lighten(var(--bgcolor-navbar), 65%)}, #{hsl.lighten(var(--bgcolor-navbar), 70%)});
-    }
-  }
+  $border-color:              $gray-300;
+
+  $link-color:                $gray-800;
+
+  @import 'bootstrap/scss/variables';
+  @import 'bootstrap/scss/variables-dark';
+
+  @import '@growi/core/scss/bootstrap/init-stage-2';
+
+  @import '@growi/core/scss/bootstrap/theming/apply-light';
+
+  --grw-wiki-link-color-rgb: var(--grw-primary-600-rgb);
+  --grw-wiki-link-hover-color-rgb: var(--grw-primary-700-rgb);
 }
+
+// @use '@growi/core/scss/bootstrap/init' as bs;
+
+// @use './variables' as var;
+// @use './theme/mixins/page-editor-mode-manager';
+// @use './theme/hsl-functions' as hsl;
+
+// .growi:not(.login-page) {
+//   // add background-image
+//   .page-editor-preview-container {
+//     background-attachment: fixed;
+//     background-position: center center;
+//     background-size: cover;
+//   }
+// }
+
+// //== Light Mode
+// //
+// :root[data-bs-theme='light'] {
+//   --primary: hsl(var(--primary-hs),var(--primary-l)) !important;
+//   --primary-hs: 311,100%;
+//   --primary-l: 14%;
+//   --secondary: hsl(var(--secondary-hs),var(--secondary-l)) !important;
+//   --secondary-hs: 208,7%;
+//   --secondary-l: 46%;
+
+//   // Background colors
+//   --bgcolor-global: hsl(var(--bgcolor-global-hs),var(--bgcolor-global-l));
+//   --bgcolor-global-hs: 0,0%;
+//   --bgcolor-global-l: 99%;
+//   --bgcolor-inline-code: #{bs.$gray-100}; //optional
+//   --bgcolor-card: #f1ffe4;
+//   --bgcolor-blinked-section: #{hsl.alpha(var(--primary), 10%)};
+//   //--bgcolor-keyword-highlighted: #{$grw-marker-yellow};
+
+//   // Font colors
+//   --color-global: hsl(var(--color-global-hs),var(--color-global-l));
+//   --color-global-hs: 311,100%;
+//   --color-global-l: 14%;
+//   --color-reversal: #eeeeee;
+//   --color-link: hsl(var(--color-link-hs),var(--color-link-l));
+//   --color-link-hs: 328,100%;
+//   --color-link-l: 25%;
+//   --color-link-hover: #{hsl.lighten(var(--color-link), 20%)};
+//   --color-link-wiki: #{hsl.lighten(var(--primary), 20%)};
+//   --color-link-wiki-hs: var(--primary-hs);
+//   --color-link-wiki-l: calc(var(--primary-l) + 20%);
+//   --color-link-wiki-hover: #{hsl.lighten(var(--primary), 40%)};
+//   --color-link-nabvar: #a7a7a7;
+//   --color-inline-code: #c7254e; // optional
+//   --color-search: white;
+
+//   // Navbar
+//   --bgcolor-navbar: hsl(var(--bgcolor-navbar-hs),var(--bgcolor-navbar-l));
+//   --bgcolor-navbar-hs: 158,30%;
+//   --bgcolor-navbar-l: 20%;
+//   --bgcolor-search-top-dropdown: hsl(var(--bgcolor-search-top-dropdown-hs),var(--bgcolor-search-top-dropdown-l));
+//   --bgcolor-search-top-dropdown-hs: 115,95%;
+//   --bgcolor-search-top-dropdown-l: 36%;
+//   --border-image-navbar: linear-gradient(to right, #5c78ef 0%, #16bc42 50%, #5c78ef 100%);
+
+//   // Logo colors
+//   --bgcolor-logo: var(--bgcolor-navbar);
+//   --fillcolor-logo-mark: #{lighten(desaturate(bs.$gray-100, 10%), 15%)};
+
+//   // Sidebar
+//   --bgcolor-sidebar: hsl(var(--bgcolor-sidebar-hs),var(--bgcolor-sidebar-l));
+//   --bgcolor-sidebar-hs: 158,71%;
+//   --bgcolor-sidebar-l: 33%;
+
+//   // Sidebar contents
+//   --color-sidebar-context: hsl(var(--color-sidebar-context-hs),var(--color-sidebar-context-l));
+//   --color-sidebar-context-hs: 328,100%;
+//   --color-sidebar-context-l: 25%;
+//   --bgcolor-sidebar-context: hsl(var(--bgcolor-sidebar-context-hs),var(--bgcolor-sidebar-context-l));
+//   --bgcolor-sidebar-context-hs: 67,31%;
+//   --bgcolor-sidebar-context-l: 94%;
+
+//   // Sidebar resize button
+//   --color-resize-button: white;
+//   --bgcolor-resize-button: hsl(var(--bgcolor-resize-button-hs),var(--bgcolor-resize-button-l));
+//   --bgcolor-resize-button-hs: 115,95%;
+//   --bgcolor-resize-button-l: 36%;
+//   --color-resize-button-hover: var(--color-reversal);
+//   --bgcolor-resize-button-hover: #{hsl.lighten(var(--bgcolor-resize-button), 5%)};
+
+//   // Subnavigation
+//   --bgcolor-subnav: hsl(var(--bgcolor-subnav-hs),var(--bgcolor-subnav-l));
+//   --bgcolor-subnav-hs: 0,0%;
+//   --bgcolor-subnav-l: 98%;
+
+//   // Icon colors
+//   --color-editor-icons: var(--color-global);
+
+//   // Border colors
+//   --border-color-theme: #{bs.$gray-300};
+//   --bordercolor-inline-code: #ccc8c8; // optional
+
+//   // Table colors
+//   --border-color-table: #{bs.$gray-400}; // optional
+
+//   // admin theme box
+//   --color-theme-color-box: #{hsl.lighten(var(--primary), 20%)};
+
+//   // Search Top
+//   .grw-global-search {
+//     .btn-secondary.dropdown-toggle {
+//       color: var(--color-search);
+//     }
+//   }
+
+//   // Navs
+//   .nav-tabs .nav-link.active {
+//     color: var(--color-link) !important;
+//     &:hover {
+//       color: var(--color-link-hover) !important;
+//     }
+//   }
+
+//   // Button
+//   .btn-group.grw-page-editor-mode-manager {
+//     .btn.btn-outline-primary {
+//       @include page-editor-mode-manager.btn-page-editor-mode-manager(var(--bgcolor-navbar), #{hsl.lighten(var(--bgcolor-navbar), 65%)}, #{hsl.lighten(var(--bgcolor-navbar), 70%)});
+//     }
+//   }
+// }

+ 3 - 2
packages/preset-themes/vite.themes.config.ts

@@ -15,14 +15,15 @@ export default defineConfig(({ mode }) => {
           // '/src/styles/christmas.scss',
           '/src/styles/default.scss',
           '/src/styles/fire-red.scss',
-          // '/src/styles/future.scss',
+          '/src/styles/future.scss',
           // '/src/styles/halloween.scss',
           // '/src/styles/hufflepuff.scss',
           // '/src/styles/island.scss',
+          '/src/styles/kibela.scss',
           '/src/styles/jade-green.scss',
           // '/src/styles/kibela.scss',
           '/src/styles/mono-blue.scss',
-          // '/src/styles/nature.scss',
+          '/src/styles/nature.scss',
           // '/src/styles/spring.scss',
           // '/src/styles/wood.scss',
         ],

+ 16 - 1
yarn.lock

@@ -4300,6 +4300,13 @@
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
   integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
 
+"@types/unzip-stream@^0.3.4":
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/@types/unzip-stream/-/unzip-stream-0.3.4.tgz#6e762ef8b8fcf902ba7d7999a149a3af84064144"
+  integrity sha512-ud0vtsNRF+joUCyvNMyo0j5DKX2Lh/im+xVgRzBEsfHhQYZ+i4fKTveova9XxLzt6Jl6G0e/0mM4aC0gqZYSnA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/url-join@^4.0.2":
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-4.0.2.tgz#e8774924c7f492626ee3309baf6697f80e1414df"
@@ -5608,7 +5615,7 @@ binary-extensions@^2.0.0:
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
-binary@~0.3.0:
+binary@^0.3.0, binary@~0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
   integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=
@@ -18093,6 +18100,14 @@ untildify@^4.0.0:
   resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"
   integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==
 
+unzip-stream@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/unzip-stream/-/unzip-stream-0.3.1.tgz#2333b5cd035d29db86fb701ca212cf8517400083"
+  integrity sha512-RzaGXLNt+CW+T41h1zl6pGz3EaeVhYlK+rdAap+7DxW5kqsqePO8kRtWPaCiVqdhZc86EctSPVYNix30YOMzmw==
+  dependencies:
+    binary "^0.3.0"
+    mkdirp "^0.5.1"
+
 unzipper@^0.10.5:
   version "0.10.5"
   resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.5.tgz#4d189ae6f8af634b26efe1a1817c399e0dd4a1a0"