فهرست منبع

Merge branch 'dev/7.0.x' into imprv/131554-131771-file-drop-overlay

reiji-h 2 سال پیش
والد
کامیت
96bf7c9120
79فایلهای تغییر یافته به همراه1015 افزوده شده و 634 حذف شده
  1. 0 1
      .devcontainer/Dockerfile
  2. 1 1
      .devcontainer/devcontainer.json
  3. 3 0
      .github/workflows/reusable-app-prod.yml
  4. 0 17
      apps/app/_obsolete/src/components/Sidebar/NavigationResizeHexagon.tsx
  5. 13 0
      apps/app/public/static/locales/en_US/commons.json
  6. 0 2
      apps/app/public/static/locales/en_US/translation.json
  7. 13 0
      apps/app/public/static/locales/ja_JP/commons.json
  8. 0 2
      apps/app/public/static/locales/ja_JP/translation.json
  9. 13 0
      apps/app/public/static/locales/zh_CN/commons.json
  10. 0 2
      apps/app/public/static/locales/zh_CN/translation.json
  11. 17 1
      apps/app/src/client/services/page-operation.ts
  12. 3 3
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx
  13. 8 0
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.module.scss
  14. 0 2
      apps/app/src/components/Common/DrawerToggler/DrawerToggler.module.scss
  15. 7 0
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  16. 1 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  17. 0 32
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  18. 10 13
      apps/app/src/components/PageComment/Comment.tsx
  19. 4 1
      apps/app/src/components/PageComment/DeleteCommentModal.tsx
  20. 44 4
      apps/app/src/components/PageControls/PageControls.tsx
  21. 9 9
      apps/app/src/components/PageDeleteModal.tsx
  22. 14 19
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  23. 6 20
      apps/app/src/components/PageTags/PageTags.tsx
  24. 11 12
      apps/app/src/components/PageTags/RenderTagLabels.tsx
  25. 48 27
      apps/app/src/components/PageTags/TagEditModal.tsx
  26. 7 6
      apps/app/src/components/PageTags/TagsInput.tsx
  27. 1 1
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  28. 3 1
      apps/app/src/components/SearchPage/SearchResultContent.tsx
  29. 0 147
      apps/app/src/components/Sidebar/PageCreateButton.tsx
  30. 46 0
      apps/app/src/components/Sidebar/PageCreateButton/CreateButton.module.scss
  31. 24 0
      apps/app/src/components/Sidebar/PageCreateButton/CreateButton.tsx
  32. 69 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  33. 60 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.module.scss
  34. 24 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  35. 18 0
      apps/app/src/components/Sidebar/PageCreateButton/Hexagon.tsx
  36. 2 0
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.module.scss
  37. 205 0
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  38. 1 0
      apps/app/src/components/Sidebar/PageCreateButton/index.ts
  39. 1 1
      apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.module.scss
  40. 3 6
      apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.module.scss
  41. 1 1
      apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.tsx
  42. 2 5
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.module.scss
  43. 2 2
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx
  44. 2 2
      apps/app/src/components/Sidebar/SidebarNav/SkeletonItem.module.scss
  45. 0 1
      apps/app/src/components/Sidebar/SidebarNav/_variables.scss
  46. 10 3
      apps/app/src/components/Sidebar/_button-styles.scss
  47. 1 0
      apps/app/src/components/Sidebar/_variables.scss
  48. 6 0
      apps/app/src/features/comment/server/events/consts.ts
  49. 3 0
      apps/app/src/features/comment/server/events/event-emitter.ts
  50. 2 0
      apps/app/src/features/comment/server/events/index.ts
  51. 2 0
      apps/app/src/features/comment/server/index.ts
  52. 121 0
      apps/app/src/features/comment/server/models/comment.ts
  53. 1 0
      apps/app/src/features/comment/server/models/index.ts
  54. 5 6
      apps/app/src/interfaces/comment.ts
  55. 2 0
      apps/app/src/pages/[[...path]].page.tsx
  56. 0 1
      apps/app/src/server/crowi/index.js
  57. 0 26
      apps/app/src/server/events/comment.ts
  58. 0 122
      apps/app/src/server/models/comment.js
  59. 0 1
      apps/app/src/server/models/index.js
  60. 1 2
      apps/app/src/server/models/obsolete-page.js
  61. 2 4
      apps/app/src/server/routes/apiv3/pages.js
  62. 10 17
      apps/app/src/server/routes/comment.js
  63. 12 16
      apps/app/src/server/service/comment.ts
  64. 0 3
      apps/app/src/server/service/in-app-notification.ts
  65. 1 1
      apps/app/src/server/service/page.ts
  66. 1 1
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  67. 4 4
      apps/app/src/server/service/search.ts
  68. 49 1
      apps/app/src/stores/modal.tsx
  69. 2 1
      apps/app/src/styles/_editor.scss
  70. 1 11
      apps/app/src/styles/_layout.scss
  71. 0 2
      apps/app/test/integration/service/v5.public-page.test.ts
  72. 60 0
      apps/app/turbo.json
  73. 17 4
      packages/core/scss/_flex-expand.scss
  74. 1 0
      packages/core/scss/bootstrap/_variables.scss
  75. 0 17
      packages/core/scss/placeholders/_flex-expand.scss
  76. 1 1
      packages/editor/src/components/playground/Playground.tsx
  77. 1 0
      packages/editor/turbo.json
  78. 2 0
      packages/ui/scss/atoms/_btn-muted.scss
  79. 1 47
      turbo.json

+ 0 - 1
.devcontainer/Dockerfile

@@ -50,7 +50,6 @@ RUN apt-get update \
     && rm -rf /var/lib/apt/lists/*
 ENV DEBIAN_FRONTEND=dialog
 
-RUN git-lfs pull
 RUN yarn global add turbo
 RUN yarn global add node-gyp
 

+ 1 - 1
.devcontainer/devcontainer.json

@@ -34,7 +34,7 @@
   // "shutdownAction": "none",
 
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "yarn global add turbo node-gyp && yarn install",
+  "postCreateCommand": "git-lfs pull & yarn install",
 
   // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
   "remoteUser": "node"

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

@@ -27,6 +27,9 @@ jobs:
 
     steps:
     - uses: actions/checkout@v3
+      with:
+        # retrieve local font files
+        lfs: true
 
     - uses: actions/setup-node@v3
       with:

+ 0 - 17
apps/app/_obsolete/src/components/Sidebar/NavigationResizeHexagon.tsx

@@ -1,17 +0,0 @@
-import React from 'react';
-
-export const NavigationResizeHexagon = React.memo((): JSX.Element => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 27.691 23.999"
-  >
-    <g className="background" transform="translate(0 0)">
-      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
-    </g>
-    <g className="icon" transform="translate(10 6)">
-      { /* eslint-disable-next-line max-len */ }
-      <path d="M2.124,9.114l5.28,5.34a.647.647,0,0,0,.922,0l.616-.623a.665.665,0,0,0,0-.932L4.759,8.648,8.943,4.4a.665.665,0,0,0,0-.932l-.616-.623a.647.647,0,0,0-.922,0l-5.28,5.34A.665.665,0,0,0,2.124,9.114Z" transform="translate(-1.933 -2.648)"></path>
-    </g>
-  </svg>
-));
-NavigationResizeHexagon.displayName = 'NavigationResizeHexagon';

+ 13 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -68,6 +68,19 @@
     "feedback": "Feedback"
   },
 
+  "create_page_dropdown": {
+    "new_page": "Create New Page",
+    "todays": {
+      "desc": "Create today's ...",
+      "memo": "memo"
+    },
+    "template": {
+      "desc": "Create/Edit template page",
+      "children": "Template for children",
+      "descendants": "Template for descendants"
+    }
+  },
+
   "copy_to_clipboard": {
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",

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

@@ -106,8 +106,6 @@
   "Disclose E-mail": "Disclose E-mail",
   "page exists": "this page already exists",
   "Error occurred": "Error occurred",
-  "Create today's": "Create today's ...",
-  "Memo": "memo",
   "Input page name": "Input page name",
   "Input page name (optional)": "Input page name (optional)",
   "New Page": "New page",

+ 13 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -70,6 +70,19 @@
     "feedback": "ご意見・ご要望"
   },
 
+  "create_page_dropdown": {
+    "new_page": "新規ページ作成",
+    "todays": {
+      "desc": "今日の◯◯を作成",
+      "memo": "メモ"
+    },
+    "template": {
+      "desc": "テンプレートページの作成/編集",
+      "children": "同一階層テンプレート",
+      "decendants": "下位層テンプレート"
+    }
+  },
+
   "copy_to_clipboard": {
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",

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

@@ -105,8 +105,6 @@
   "Disclose E-mail": "メールアドレスの公開",
   "page exists": "このページはすでに存在しています",
   "Error occurred": "エラーが発生しました",
-  "Create today's": "今日の◯◯を作成",
-  "Memo": "メモ",
   "Input page name": "ページ名を入力",
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",

+ 13 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -71,6 +71,19 @@
     "feedback": "意见和要求"
   },
 
+  "create_page_dropdown": {
+    "new_page": "新页面",
+    "todays": {
+      "desc": "Create today's ...",
+      "memo": "memo"
+    },
+    "template": {
+      "desc": "创建/编辑模板页",
+      "children": "子模板",
+      "descendants": "子代模板"
+    }
+  },
+
 	"copy_to_clipboard": {
 		"Copy to clipboard": "复制到剪贴板",
 		"Page path": "页面路径",

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

@@ -111,8 +111,6 @@
 	"Disclose E-mail": "显示邮箱",
 	"page exists": "页面已存在",
 	"Error occurred": "Error occurred",
-	"Create today's": "Create today's ...",
-	"Memo": "memo",
 	"Input page name": "Input page name",
 	"Input page name (optional)": "Input page name (optional)",
 	"New Page": "新页面",

+ 17 - 1
apps/app/src/client/services/page-operation.ts

@@ -9,7 +9,7 @@ import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stor
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { apiPost } from '../util/apiv1-client';
+import { apiGet, apiPost } from '../util/apiv1-client';
 import { apiv3Post, apiv3Put } from '../util/apiv3-client';
 import { toastError } from '../util/toastr';
 
@@ -203,3 +203,19 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
 export const unlink = async(path: string): Promise<void> => {
   await apiPost('/pages.unlink', { path });
 };
+
+
+interface PageExistRequest {
+  pagePaths: string;
+}
+
+interface PageExistResponse {
+  pages: Record<string, boolean>;
+  ok: boolean
+}
+
+export const exist = async(pagePaths: string): Promise<PageExistResponse> => {
+  const request: PageExistRequest = { pagePaths };
+  const res = await apiGet<PageExistResponse>('/pages.exist', request);
+  return res;
+};

+ 3 - 3
apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -110,10 +110,10 @@ export const CopyDropdown = (props) => {
 
   return (
     <>
-      <Dropdown className={`${styles['grw-copy-dropdown']} grw-copy-dropdown d-print-none`} isOpen={dropdownOpen} toggle={toggleDropdown}>
+      <Dropdown className={`${styles['grw-copy-dropdown']} grw-copy-dropdown d-print-none`} isOpen={dropdownOpen} size="sm" toggle={toggleDropdown}>
         <DropdownToggle
-          caret
-          className={dropdownToggleClassName}
+          caret={isShareLinkMode}
+          className={`btn-copy ${dropdownToggleClassName}`}
         >
           <span id={dropdownToggleId}>{children}</span>
         </DropdownToggle>

+ 8 - 0
apps/app/src/components/Common/CopyDropdown/CopyDropdown.module.scss

@@ -1,4 +1,12 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '@growi/ui/scss/atoms/btn-muted';
+
 .grw-copy-dropdown :global {
+  .btn.btn-copy {
+    @include btn-muted.colorize(bs.$gray-500);
+  }
+
   .dropdown-menu {
     min-width: 310px;
 

+ 0 - 2
apps/app/src/components/Common/DrawerToggler/DrawerToggler.module.scss

@@ -1,7 +1,5 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
-@use '@growi/ui/scss/atoms/btn-muted';
-
 @use '~/styles/variables' as var;
 
 

+ 7 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -1,5 +1,7 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '@growi/ui/scss/atoms/btn-muted';
+
 .grw-mr-02em {
   margin-right: 0.2em;
 }
@@ -19,3 +21,8 @@
   }
 }
 
+.grw-page-path-nav :global {
+  .btn-copy {
+    @include btn-muted.colorize(bs.$orange);
+  }
+}

+ 1 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -76,7 +76,6 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   }
 
   const copyDropdownId = `copydropdown-${pageId}`;
-  const copyDropdownToggleClassName = 'd-block btn-outline-secondary btn-copy border-0 text-muted p-2';
 
   return (
     <div>
@@ -87,7 +86,7 @@ export const PagePathNav: FC<Props> = (props: Props) => {
         </h1>
         { pageId != null && !isNotFound && (
           <div className="mx-2">
-            <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
+            <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
               <i className="ti ti-clipboard"></i>
             </CopyDropdown>
           </div>

+ 0 - 32
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -202,12 +202,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // eslint-disable-next-line max-len
-  // const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
-  // const { data: templateTagData } = useTemplateTagData();
-
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -217,36 +211,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const grant = currentPage?.grant ?? grantData?.grant;
   const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // useEffect(() => {
-  //   // Run only when tagsInfoData has been updated
-  //   if (templateTagData == null) {
-  //     syncPageTagsForEditors();
-  //   }
-  //   // eslint-disable-next-line react-hooks/exhaustive-deps
-  // }, [tagsInfoData?.tags]);
-
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // useEffect(() => {
-  //   if (pageId === null && templateTagData != null) {
-  //     mutatePageTagsForEditors(templateTagData);
-  //   }
-  // }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
-
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
   const { isLinkSharingDisabled } = props;
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
-  //   // It will not be reflected in the DB until the page is refreshed
-  //   mutatePageTagsForEditors(newTags);
-  //   return;
-  // }, [mutatePageTagsForEditors]);
-
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       router.push(toPath);

+ 10 - 13
apps/app/src/components/PageComment/Comment.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useMemo, useState } from 'react';
 
-import type { IUser } from '@growi/core';
+import { isPopulated, type IUser } from '@growi/core';
 import * as pathUtils from '@growi/core/dist/utils/path-utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format, parseISO } from 'date-fns';
@@ -51,8 +51,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const [isReEdit, setIsReEdit] = useState(false);
 
   const commentId = comment._id;
-  const creator = comment.creator;
-  const isMarkdown = comment.isMarkdown;
+  const creator = isPopulated(comment.creator) ? comment.creator : undefined;
   const createdAt = new Date(comment.createdAt);
   const updatedAt = new Date(comment.updatedAt);
   const isEdited = createdAt < updatedAt;
@@ -122,16 +121,14 @@ export const Comment = (props: CommentProps): JSX.Element => {
       return <></>;
     }
 
-    return isMarkdown
-      ? (
-        <RevisionRenderer
-          rendererOptions={rendererOptions}
-          markdown={markdown}
-          additionalClassName="comment"
-        />
-      )
-      : renderText(comment.comment);
-  }, [comment, isMarkdown, markdown, rendererOptions]);
+    return (
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+        additionalClassName="comment"
+      />
+    );
+  }, [markdown, rendererOptions]);
 
   const rootClassName = getRootClassName(comment);
   const revHref = `?revisionId=${comment.revision}`;

+ 4 - 1
apps/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
 import {
@@ -47,6 +48,8 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
     const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
 
+    const creator = isPopulated(comment.creator) ? comment.creator : undefined;
+
     let commentBody = comment.comment;
     if (commentBody.length > OMIT_BODY_THRES) { // omit
       commentBody = `${commentBody.substr(0, OMIT_BODY_THRES)}...`;
@@ -55,7 +58,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
     return (
       <>
-        <UserPicture user={comment.creator} size="xs" /> <strong><Username user={comment.creator}></Username></strong> wrote on {commentDate}:
+        <UserPicture user={creator} size="xs" /> <strong><Username user={creator}></Username></strong> wrote on {commentDate}:
         <p className="card custom-card comment-body mt-2 p-2">{commentBodyElement}</p>
       </>
     );

+ 44 - 4
apps/app/src/components/PageControls/PageControls.tsx

@@ -14,9 +14,10 @@ import {
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
-import type { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
-import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import {
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
@@ -31,6 +32,26 @@ import SubscribeButton from './SubscribeButton';
 
 import styles from './PageControls.module.scss';
 
+type TagsProps = {
+  onClickEditTagsButton: () => void,
+}
+
+const Tags = (props: TagsProps): JSX.Element => {
+  const { onClickEditTagsButton } = props;
+
+  return (
+    <div className="grw-taglabels-container d-flex align-items-center">
+      <button
+        type="button"
+        className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
+        onClick={onClickEditTagsButton}
+      >
+        <i className="icon-tag me-2" />
+        Tags
+      </button>
+    </div>
+  );
+};
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
   onClickMenuItem: (newValue: boolean) => void,
@@ -84,6 +105,7 @@ type PageControlsSubstanceProps = CommonProps & {
   path?: string | null,
   pageInfo: IPageInfoForOperation,
   expandContentWidth?: boolean,
+  onClickEditTagsButton: () => void,
 }
 
 const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element => {
@@ -91,11 +113,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     pageInfo,
     pageId, revisionId, path, shareLinkId, expandContentWidth,
     disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
+    onClickEditTagsButton, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { data: editorMode } = useEditorMode();
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
@@ -214,8 +237,15 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     MenuItemType.REVERT,
   ];
 
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
     <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+      {revisionId != null && !isViewMode && (
+        <Tags
+          onClickEditTagsButton={onClickEditTagsButton}
+        />
+      )}
       {revisionId != null && (
         <SubscribeButton
           status={pageInfo.subscriptionStatus}
@@ -266,7 +296,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 type PageControlsProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
-  revisionId?: string | null,
+  revisionId?: string,
   path?: string | null,
   expandContentWidth?: boolean,
 };
@@ -278,6 +308,15 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { open: openTagEditModal } = useTagEditModal();
+
+  const onClickEditTagsButton = useCallback(() => {
+    if (tagsInfoData == null || revisionId == null) {
+      return;
+    }
+    openTagEditModal(tagsInfoData.tags, pageId, revisionId);
+  }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
   if (error != null) {
     return <></>;
@@ -294,6 +333,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
       pageId={pageId}
       revisionId={revisionId ?? null}
       path={path}
+      onClickEditTagsButton={onClickEditTagsButton}
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}

+ 9 - 9
apps/app/src/components/PageDeleteModal.tsx

@@ -32,12 +32,12 @@ const logger = loggerFactory('growi:cli:PageDeleteModal');
 const deleteIconAndKey = {
   completely: {
     color: 'danger',
-    icon: 'fire',
+    icon: 'delete_forever',
     translationKey: 'completely',
   },
   temporary: {
-    color: 'primary',
-    icon: 'trash',
+    color: 'warning',
+    icon: 'delete',
     translationKey: 'page',
   },
 };
@@ -245,10 +245,10 @@ const PageDeleteModal: FC = () => {
     }
 
     return (
-      <>
-        <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
-        { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
-      </>
+      <span className={`text-${deleteIconAndKey[deleteMode].color} d-flex align-items-center`}>
+        <span className="material-symbols-outlined me-1">{deleteIconAndKey[deleteMode].icon}</span>
+        <b>{ t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }</b>
+      </span>
     );
   };
 
@@ -280,7 +280,7 @@ const PageDeleteModal: FC = () => {
         <ApiErrorMessageList errs={errs} />
         <button
           type="button"
-          className={`btn btn-${deleteIconAndKey[deleteMode].color}`}
+          className={`btn btn-outline-${deleteIconAndKey[deleteMode].color}`}
           disabled={!isDeletable}
           onClick={deleteButtonHandler}
           data-testid="delete-page-button"
@@ -294,7 +294,7 @@ const PageDeleteModal: FC = () => {
 
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal">
-      <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
+      <ModalHeader toggle={closeDeleteModal}>
         {headerContent()}
       </ModalHeader>
       <ModalBody>

+ 14 - 19
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -6,11 +6,8 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { scroller } from 'react-scroll';
 
-import { useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { apiPost } from '~/client/util/apiv1-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
-import { useDescendantsPageListModal } from '~/stores/modal';
+import { useDescendantsPageListModal, useTagEditModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { useIsAbleToShowTagLabel } from '~/stores/ui';
 
@@ -45,22 +42,14 @@ const Tags = (props: TagsProps): JSX.Element => {
   const { data: showTagLabel } = useIsAbleToShowTagLabel();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { open: openTagEditModal } = useTagEditModal();
 
-  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
-
-  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
-    try {
-      await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
-
-      updateStateAfterSave?.();
-
-      toastSuccess('updated tags successfully');
+  const onClickEditTagsButton = useCallback(() => {
+    if (tagsInfoData == null) {
+      return;
     }
-    catch (err) {
-      toastError(err);
-    }
-
-  }, [pageId, revisionId, updateStateAfterSave]);
+    openTagEditModal(tagsInfoData.tags, pageId, revisionId);
+  }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
   if (!showTagLabel) {
     return <></>;
@@ -71,7 +60,13 @@ const Tags = (props: TagsProps): JSX.Element => {
   return (
     <div className="grw-taglabels-container">
       { tagsInfoData?.tags != null
-        ? <PageTags tags={tagsInfoData.tags} isTagLabelsDisabled={isTagLabelsDisabled ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+        ? (
+          <PageTags
+            tags={tagsInfoData.tags}
+            isTagLabelsDisabled={isTagLabelsDisabled}
+            onClickEditTagsButton={onClickEditTagsButton}
+          />
+        )
         : <PageTagsSkeleton />
       }
     </div>

+ 6 - 20
apps/app/src/components/PageTags/PageTags.tsx

@@ -1,9 +1,8 @@
-import React, { FC, useState } from 'react';
+import React, { FC } from 'react';
 
 import { Skeleton } from '../Skeleton';
 
 import RenderTagLabels from './RenderTagLabels';
-import TagEditModal from './TagEditModal';
 
 import styles from './TagLabels.module.scss';
 
@@ -11,6 +10,7 @@ type Props = {
   tags?: string[],
   isTagLabelsDisabled: boolean,
   tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
+  onClickEditTagsButton: () => void,
 }
 
 export const PageTagsSkeleton = (): JSX.Element => {
@@ -18,17 +18,9 @@ export const PageTagsSkeleton = (): JSX.Element => {
 };
 
 export const PageTags:FC<Props> = (props: Props) => {
-  const { tags, isTagLabelsDisabled, tagsUpdateInvoked } = props;
-
-  const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
-
-  const openEditorModal = () => {
-    setIsTagEditModalShown(true);
-  };
-
-  const closeEditorModal = () => {
-    setIsTagEditModalShown(false);
-  };
+  const {
+    tags, isTagLabelsDisabled, onClickEditTagsButton,
+  } = props;
 
   if (tags == null) {
     return <PageTagsSkeleton />;
@@ -41,16 +33,10 @@ export const PageTags:FC<Props> = (props: Props) => {
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center ${printNoneClass}`} data-testid="grw-tag-labels">
         <RenderTagLabels
           tags={tags}
-          openEditorModal={openEditorModal}
           isTagLabelsDisabled={isTagLabelsDisabled}
+          onClickEditTagsButton={onClickEditTagsButton}
         />
       </div>
-      <TagEditModal
-        tags={tags}
-        isOpen={isTagEditModalShown}
-        onClose={closeEditorModal}
-        onTagsUpdated={tagsUpdateInvoked}
-      />
     </>
   );
 };

+ 11 - 12
apps/app/src/components/PageTags/RenderTagLabels.tsx

@@ -10,22 +10,17 @@ import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 type RenderTagLabelsProps = {
   tags: string[],
   isTagLabelsDisabled: boolean,
-  openEditorModal?: () => void,
+  onClickEditTagsButton: () => void,
 }
 
 const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
-  const { tags, isTagLabelsDisabled, openEditorModal } = props;
+  const {
+    tags, isTagLabelsDisabled, onClickEditTagsButton,
+  } = props;
   const { t } = useTranslation();
 
   const { pushState } = useKeywordManager();
 
-  function openEditorHandler() {
-    if (openEditorModal == null) {
-      return;
-    }
-    openEditorModal();
-  }
-
   const isTagsEmpty = tags.length === 0;
 
   return (
@@ -46,10 +41,14 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
         <NotAvailableForReadOnlyUser>
           <div id="edit-tags-btn-wrapper-for-tooltip" className="d-print-none">
             <a
-              className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isTagLabelsDisabled && 'disabled'}`}
-              onClick={openEditorHandler}
+              className={
+                `btn btn-link btn-edit-tags text-muted d-flex align-items-center
+                ${isTagsEmpty && 'no-tags'}
+                ${isTagLabelsDisabled && 'disabled'}`
+              }
+              onClick={onClickEditTagsButton}
             >
-              { isTagsEmpty && <>{ t('Add tags for this page') }</>}
+              {isTagsEmpty && <>{ t('Add tags for this page') }</>}
               <i className={`icon-plus ${isTagsEmpty && 'ms-1'}`} />
             </a>
           </div>

+ 48 - 27
apps/app/src/components/PageTags/TagEditModal.tsx

@@ -1,49 +1,62 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { apiPost } from '~/client/util/apiv1-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useTagEditModal, type TagEditModalStatus } from '~/stores/modal';
+
 import { TagsInput } from './TagsInput';
 
-type Props = {
-  tags: string[],
-  isOpen: boolean,
-  onClose?: () => void,
-  onTagsUpdated?: (tags: string[]) => Promise<void> | void,
-};
+type TagEditModalSubstanceProps = {
+  tagEditModalData: TagEditModalStatus,
+  closeTagEditModal: () => void,
+}
 
-function TagEditModal(props: Props): JSX.Element {
-  const { onClose, onTagsUpdated } = props;
+const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagEditModalSubstanceProps) => {
+  const { tagEditModalData, closeTagEditModal } = props;
+  const { t } = useTranslation();
+
+  const initTags = tagEditModalData.tags;
+  const isOpen = tagEditModalData.isOpen;
+  const pageId = tagEditModalData.pageId;
+  const revisionId = tagEditModalData.revisionId;
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
 
   const [tags, setTags] = useState<string[]>([]);
-  const { t } = useTranslation();
 
+  // use to take initTags when redirect to other page
   useEffect(() => {
-    setTags(props.tags);
-  }, [props.tags]);
+    setTags(initTags);
+  }, [initTags]);
 
-  const closeModalHandler = useCallback(() => {
-    onClose?.();
-  }, [onClose]);
+  const handleSubmit = useCallback(async() => {
 
-  const handleSubmit = useCallback(() => {
-    if (onTagsUpdated == null) {
-      return;
-    }
+    try {
+      await apiPost('/tags.update', { pageId, revisionId, tags });
+      updateStateAfterSave?.();
 
-    onTagsUpdated(tags);
-    closeModalHandler();
-  }, [closeModalHandler, onTagsUpdated, tags]);
+      toastSuccess('updated tags successfully');
+      closeTagEditModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [closeTagEditModal, tags, pageId, revisionId, updateStateAfterSave]);
 
   return (
-    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
+    <Modal isOpen={isOpen} toggle={closeTagEditModal} id="edit-tag-modal" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeTagEditModal} className="bg-primary text-light">
         {t('tag_edit_modal.edit_tags')}
       </ModalHeader>
       <ModalBody>
-        <TagsInput tags={tags} onTagsUpdated={tags => setTags(tags)} autoFocus />
+        <TagsInput tags={initTags} onTagsUpdated={tags => setTags(tags)} autoFocus />
       </ModalBody>
       <ModalFooter>
         <button type="button" className="btn btn-primary" onClick={handleSubmit}>
@@ -53,6 +66,14 @@ function TagEditModal(props: Props): JSX.Element {
     </Modal>
   );
 
-}
+};
 
-export default TagEditModal;
+export const TagEditModal: React.FC = () => {
+  const { data: tagEditModalData, close: closeTagEditModal } = useTagEditModal();
+
+  if (!tagEditModalData?.isOpen) {
+    return <></>;
+  }
+
+  return <TagEditModalSubstance tagEditModalData={tagEditModalData} closeTagEditModal={closeTagEditModal} />;
+};

+ 7 - 6
apps/app/src/components/PageTags/TagsInput.tsx

@@ -22,8 +22,9 @@ type Props = {
 
 export const TagsInput: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-  const tagsInputRef = useRef<TypeaheadInstance>(null);
+  const { tags, autoFocus, onTagsUpdated } = props;
 
+  const tagsInputRef = useRef<TypeaheadInstance>(null);
   const [resultTags, setResultTags] = useState<string[]>([]);
   const [searchQuery, setSearchQuery] = useState('');
 
@@ -32,10 +33,10 @@ export const TagsInput: FC<Props> = (props: Props) => {
   const isLoading = error == null && tagsSearch === undefined;
 
   const changeHandler = useCallback((selected: string[]) => {
-    if (props.onTagsUpdated != null) {
-      props.onTagsUpdated(selected);
+    if (onTagsUpdated != null) {
+      onTagsUpdated(selected);
     }
-  }, [props]);
+  }, [onTagsUpdated]);
 
   const searchHandler = useCallback(async(query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
@@ -64,7 +65,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
         id="tag-typeahead-asynctypeahead"
         ref={tagsInputRef}
         caseSensitive={false}
-        defaultSelected={props.tags ?? []}
+        defaultSelected={tags}
         isLoading={isLoading}
         minLength={1}
         multiple
@@ -74,7 +75,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
         onKeyDown={keyDownHandler}
         options={resultTags} // Search result (Some tag names)
         placeholder={t('tag_edit_modal.tags_input.tag_name')}
-        autoFocus={props.autoFocus}
+        autoFocus={autoFocus}
       />
     </div>
   );

+ 1 - 1
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -169,7 +169,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     : undefined;
 
   return (
-    <div className="search-result-base flex-expand-horiz" data-testid="search-result-base">
+    <div className="search-result-base flex-grow-1 d-flex flex-expand-vh-100" data-testid="search-result-base">
 
       <div className="flex-expand-vert border boder-gray search-result-list" id="search-result-list">
 

+ 3 - 1
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -31,6 +31,8 @@ import type { PageContentFooterProps } from '../PageContentFooter';
 
 import styles from './SearchResultContent.module.scss';
 
+const moduleClass = styles['search-result-content'];
+
 
 const SubNavButtons = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
 const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
@@ -210,7 +212,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     <div
       key={page._id}
       data-testid="search-result-content"
-      className={`dynamic-layout-root ${growiLayoutFluidClass} search-result-content ${styles['search-result-content']}`}
+      className={`dynamic-layout-root ${growiLayoutFluidClass} ${moduleClass}`}
     >
       <RightComponent />
 

+ 0 - 147
apps/app/src/components/Sidebar/PageCreateButton.tsx

@@ -1,147 +0,0 @@
-import React, { useCallback, useState } from 'react';
-
-import { useRouter } from 'next/router';
-
-import { createPage } from '~/client/services/page-operation';
-import { toastError } from '~/client/util/toastr';
-import { useSWRxCurrentPage } from '~/stores/page';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:cli:PageCreateButton');
-
-export const PageCreateButton = React.memo((): JSX.Element => {
-  const router = useRouter();
-  const { data: currentPage, isLoading } = useSWRxCurrentPage();
-
-  const [isHovered, setIsHovered] = useState(false);
-  const [isCreating, setIsCreating] = useState(false);
-
-  const onMouseEnterHandler = () => {
-    setIsHovered(true);
-  };
-
-  const onMouseLeaveHandler = () => {
-    setIsHovered(false);
-  };
-
-  const onCreateNewPageButtonHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsCreating(true);
-
-      const parentPath = currentPage == null
-        ? '/'
-        : currentPage.path;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: currentPage?.grant || 1,
-        pageTags: [],
-        grantUserGroupId: currentPage?.grantedGroup?._id,
-        shouldGeneratePath: true,
-      };
-
-      const response = await createPage(parentPath, '', params);
-
-      router.push(`${response.page.id}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
-  const onCreateTodaysButtonHandler = useCallback(() => {
-    // router.push(`${router.pathname}#edit`);
-  }, [router]);
-  const onTemplateForChildrenButtonHandler = useCallback(() => {
-    // router.push(`${router.pathname}/_template#edit`);
-  }, [router]);
-  const onTemplateForDescendantsButtonHandler = useCallback(() => {
-    // router.push(`${router.pathname}/__template#edit`);
-  }, [router]);
-
-  // TODO: update button design
-  // https://redmine.weseek.co.jp/issues/132683
-  // TODO: i18n
-  // https://redmine.weseek.co.jp/issues/132681
-  return (
-    <div
-      className="d-flex flex-row"
-      onMouseEnter={onMouseEnterHandler}
-      onMouseLeave={onMouseLeaveHandler}
-    >
-      <div className="btn-group">
-        <button
-          className="d-block btn btn-primary"
-          onClick={onCreateNewPageButtonHandler}
-          type="button"
-          data-testid="grw-sidebar-nav-page-create-button"
-          disabled={isCreating}
-        >
-          <i className="material-symbols-outlined">edit</i>
-        </button>
-      </div>
-      {isHovered && (
-        <div className="btn-group dropend">
-          <button
-            className="btn btn-secondary dropdown-toggle dropdown-toggle-split position-absolute"
-            type="button"
-            data-bs-toggle="dropdown"
-            aria-expanded="false"
-          />
-          <ul className="dropdown-menu">
-            <li>
-              <button
-                className="dropdown-item"
-                onClick={onCreateNewPageButtonHandler}
-                type="button"
-                disabled={isCreating}
-              >
-                Create New Page
-              </button>
-            </li>
-            <li><hr className="dropdown-divider" /></li>
-            <li><span className="text-muted px-3">Create today&apos;s ...</span></li>
-            {/* TODO: show correct create today's page path */}
-            {/* https://redmine.weseek.co.jp/issues/132682 */}
-            <li>
-              <button
-                className="dropdown-item"
-                onClick={onCreateTodaysButtonHandler}
-                type="button"
-              >
-                Create today&apos;s
-              </button>
-            </li>
-            <li><hr className="dropdown-divider" /></li>
-            <li><span className="text-muted px-3">Child page template</span></li>
-            <li>
-              <button
-                className="dropdown-item"
-                onClick={onTemplateForChildrenButtonHandler}
-                type="button"
-              >
-                Template for children
-              </button>
-            </li>
-            <li>
-              <button
-                className="dropdown-item"
-                onClick={onTemplateForDescendantsButtonHandler}
-                type="button"
-              >
-                Template for descendants
-              </button>
-            </li>
-          </ul>
-        </div>
-      )}
-    </div>
-  );
-});
-PageCreateButton.displayName = 'PageCreateButton';

+ 46 - 0
apps/app/src/components/Sidebar/PageCreateButton/CreateButton.module.scss

@@ -0,0 +1,46 @@
+@use '~/styles/variables' as var;
+
+@use '../button-styles';
+
+.btn-create :global {
+  @extend %btn-basis;
+
+  // centering
+  .icon {
+    top: 50%;
+    left: 50%;
+    transform: translateX(-50%) translateY(-50%);
+  }
+}
+
+// pointer-events
+.btn-create :global {
+  pointer-events: none;
+
+  svg .background {
+    pointer-events: fill;
+  }
+}
+
+// == Colors
+.btn-create {
+  background-color: transparent !important;
+}
+
+.btn-create :global {
+  svg {
+    fill: var(--bs-btn-bg);
+  }
+}
+
+.btn-create:hover :global {
+  svg {
+    fill: var(--bs-btn-hover-bg);
+  }
+}
+
+.btn-create:active :global {
+  svg {
+    fill: var(--bs-btn-active-bg);
+  }
+}

+ 24 - 0
apps/app/src/components/Sidebar/PageCreateButton/CreateButton.tsx

@@ -0,0 +1,24 @@
+import type { ButtonHTMLAttributes, DetailedHTMLProps } from 'react';
+
+import { Hexagon } from './Hexagon';
+
+import styles from './CreateButton.module.scss';
+
+const moduleClass = styles['btn-create'];
+
+
+type Props = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
+
+export const CreateButton = (props: Props): JSX.Element => {
+  return (
+    <button
+      type="button"
+      {...props}
+      className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
+      data-testid="grw-sidebar-nav-page-create-button"
+    >
+      <Hexagon />
+      <span className="icon material-symbols-outlined position-absolute">edit</span>
+    </button>
+  );
+};

+ 69 - 0
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+type DropendMenuProps = {
+  todaysPath: string,
+  onClickCreateNewPageButtonHandler: () => Promise<void>
+  onClickCreateTodaysButtonHandler: () => Promise<void>
+  onClickTemplateForChildrenButtonHandler: () => Promise<void>
+  onClickTemplateForDescendantsButtonHandler: () => Promise<void>
+}
+
+export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
+  const {
+    todaysPath,
+    onClickCreateNewPageButtonHandler,
+    onClickCreateTodaysButtonHandler,
+    onClickTemplateForChildrenButtonHandler,
+    onClickTemplateForDescendantsButtonHandler,
+  } = props;
+
+  const { t } = useTranslation('commons');
+
+  return (
+    <ul className="dropdown-menu">
+      <li>
+        <button
+          className="dropdown-item"
+          onClick={onClickCreateNewPageButtonHandler}
+          type="button"
+        >
+          {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>
+      <li><hr className="dropdown-divider" /></li>
+      <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
+      <li>
+        <button
+          className="dropdown-item"
+          onClick={onClickTemplateForChildrenButtonHandler}
+          type="button"
+        >
+          {t('create_page_dropdown.template.children')}
+        </button>
+      </li>
+      <li>
+        <button
+          className="dropdown-item"
+          onClick={onClickTemplateForDescendantsButtonHandler}
+          type="button"
+        >
+          {t('create_page_dropdown.template.decendants')}
+        </button>
+      </li>
+    </ul>
+  );
+});
+DropendMenu.displayName = 'DropendMenu';

+ 60 - 0
apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.module.scss

@@ -0,0 +1,60 @@
+@use '~/styles/variables' as var;
+
+@use '../button-styles';
+
+.btn-toggle :global {
+  @extend %btn-basis;
+
+  left: 12px;
+  padding: 0;
+
+  .icon {
+    top: 50%;
+    right: 0px;
+    font-size: 22px;
+    transform: translateY(-50%);
+  }
+}
+
+// no caret
+.btn-toggle {
+  &:global {
+    // no caret
+    &::after {
+      display: none !important;
+    }
+  }
+}
+
+// hitarea
+.btn-toggle :global {
+  .hitarea {
+    top: 0;
+    right: -10px;
+    bottom: 0;
+    left: 0;
+  }
+}
+
+// == Colors
+.btn-toggle {
+  background-color: transparent !important;
+}
+
+.btn-toggle :global {
+  svg {
+    fill: var(--grw-primary-400);
+  }
+}
+
+.btn-toggle:hover :global {
+  svg {
+    fill: var(--grw-primary-400);
+  }
+}
+
+.btn-toggle:active :global {
+  svg {
+    fill: var(--grw-primary-600);
+  }
+}

+ 24 - 0
apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -0,0 +1,24 @@
+import type { ButtonHTMLAttributes, DetailedHTMLProps } from 'react';
+
+import { Hexagon } from './Hexagon';
+
+import styles from './DropendToggle.module.scss';
+
+const moduleClass = styles['btn-toggle'];
+
+
+type Props = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
+
+export const DropendToggle = (props: Props): JSX.Element => {
+  return (
+    <button
+      type="button"
+      {...props}
+      className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
+    >
+      <Hexagon />
+      <div className="hitarea position-absolute" />
+      <span className="icon material-symbols-outlined position-absolute">chevron_right</span>
+    </button>
+  );
+};

+ 18 - 0
apps/app/src/components/Sidebar/PageCreateButton/Hexagon.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+type Props = {
+  className?: string,
+}
+
+export const Hexagon = React.memo((props: Props): JSX.Element => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 27.691 23.999"
+    height="36px"
+    className={props.className}
+  >
+    <g className="background" transform="translate(0 0)">
+      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
+    </g>
+  </svg>
+));

+ 2 - 0
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.module.scss

@@ -0,0 +1,2 @@
+.grw-page-create-button :global {
+}

+ 205 - 0
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -0,0 +1,205 @@
+import React, { useCallback, useState } from 'react';
+
+import { pagePathUtils } from '@growi/core/dist/utils';
+import { format } from 'date-fns';
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import { toastError } from '~/client/util/toastr';
+import { useCurrentUser } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+import { DropendMenu } from './DropendMenu';
+import { CreateButton } from './CreateButton';
+import { DropendToggle } from './DropendToggle';
+
+const logger = loggerFactory('growi:cli:PageCreateButton');
+
+export const PageCreateButton = React.memo((): JSX.Element => {
+  const router = useRouter();
+  const { data: currentPage, isLoading } = useSWRxCurrentPage();
+  const { data: currentUser } = useCurrentUser();
+
+  const [isHovered, setIsHovered] = useState(false);
+  const [isCreating, setIsCreating] = useState(false);
+
+  const now = format(new Date(), 'yyyy/MM/dd');
+  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
+  const todaysPath = `${userHomepagePath}/memo/${now}`;
+
+  const onMouseEnterHandler = () => {
+    setIsHovered(true);
+  };
+
+  const onMouseLeaveHandler = () => {
+    setIsHovered(false);
+  };
+
+  const onClickCreateNewPageButtonHandler = useCallback(async() => {
+    if (isLoading) return;
+
+    try {
+      setIsCreating(true);
+
+      const parentPath = currentPage == null
+        ? '/'
+        : currentPage.path;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: currentPage?.grant || 1,
+        pageTags: [],
+        grantUserGroupId: currentPage?.grantedGroup?._id,
+        shouldGeneratePath: true,
+      };
+
+      const response = await createPage(parentPath, '', params);
+
+      router.push(`${response.page.id}#edit`);
+    }
+    catch (err) {
+      logger.warn(err);
+      toastError(err);
+    }
+    finally {
+      setIsCreating(false);
+    }
+  }, [currentPage, isLoading, router]);
+
+  const onClickCreateTodaysButtonHandler = useCallback(async() => {
+    if (currentUser == null) {
+      return;
+    }
+
+    try {
+      setIsCreating(true);
+
+      // TODO: get grant, grantUserGroupId data from parent page
+      // https://redmine.weseek.co.jp/issues/133892
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 1,
+        pageTags: [],
+      };
+
+      const res = await exist(JSON.stringify([todaysPath]));
+      if (!res.pages[todaysPath]) {
+        await createPage(todaysPath, '', params);
+      }
+
+      router.push(`${todaysPath}#edit`);
+    }
+    catch (err) {
+      logger.warn(err);
+      toastError(err);
+    }
+    finally {
+      setIsCreating(false);
+    }
+  }, [currentUser, router, todaysPath]);
+
+  const onClickTemplateForChildrenButtonHandler = useCallback(async() => {
+    if (isLoading) return;
+
+    try {
+      setIsCreating(true);
+
+      const path = currentPage == null || currentPage.path === '/'
+        ? '/_template'
+        : `${currentPage.path}/_template`;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: currentPage?.grant || 1,
+        pageTags: [],
+        grantUserGroupId: currentPage?.grantedGroup?._id,
+      };
+
+      const res = await exist(JSON.stringify([path]));
+      if (!res.pages[path]) {
+        await createPage(path, '', params);
+      }
+
+      router.push(`${path}#edit`);
+    }
+    catch (err) {
+      logger.warn(err);
+      toastError(err);
+    }
+    finally {
+      setIsCreating(false);
+    }
+  }, [currentPage, isLoading, router]);
+
+  const onClickTemplateForDescendantsButtonHandler = useCallback(async() => {
+    if (isLoading) return;
+
+    try {
+      setIsCreating(true);
+
+      const path = currentPage == null || currentPage.path === '/'
+        ? '/__template'
+        : `${currentPage.path}/__template`;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: currentPage?.grant || 1,
+        pageTags: [],
+        grantUserGroupId: currentPage?.grantedGroup?._id,
+      };
+
+      const res = await exist(JSON.stringify([path]));
+      if (!res.pages[path]) {
+        await createPage(path, '', params);
+      }
+
+      router.push(`${path}#edit`);
+    }
+    catch (err) {
+      logger.warn(err);
+      toastError(err);
+    }
+    finally {
+      setIsCreating(false);
+    }
+  }, [currentPage, isLoading, router]);
+
+  // TODO: update button design
+  // https://redmine.weseek.co.jp/issues/132683
+  return (
+    <div
+      className="d-flex flex-row"
+      onMouseEnter={onMouseEnterHandler}
+      onMouseLeave={onMouseLeaveHandler}
+    >
+      <div className="btn-group flex-grow-1">
+        <CreateButton
+          className="z-2"
+          onClick={onClickCreateNewPageButtonHandler}
+          disabled={isCreating}
+        />
+      </div>
+      { isHovered && (
+        <div className="btn-group dropend position-absolute">
+          <DropendToggle
+            className="dropdown-toggle dropdown-toggle-split"
+            data-bs-toggle="dropdown"
+            aria-expanded="false"
+          />
+          <DropendMenu
+            todaysPath={todaysPath}
+            onClickCreateNewPageButtonHandler={onClickCreateNewPageButtonHandler}
+            onClickCreateTodaysButtonHandler={onClickCreateTodaysButtonHandler}
+            onClickTemplateForChildrenButtonHandler={onClickTemplateForChildrenButtonHandler}
+            onClickTemplateForDescendantsButtonHandler={onClickTemplateForDescendantsButtonHandler}
+          />
+        </div>
+      )}
+    </div>
+  );
+});

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

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

+ 1 - 1
apps/app/src/components/Sidebar/SidebarHead/ToggleCollapseButton.module.scss

@@ -5,7 +5,7 @@
 @use '../button-styles';
 
 .btn-toggle-collapse :global {
-  @extend %btn-primary-basis;
+  @extend %btn-basis;
 
   $height: var.$grw-sidebar-nav-width; // declare $height with the same value as the sidebar nav width
   height: $height;

+ 3 - 6
apps/app/src/components/Sidebar/SidebarNav/PrimaryItems.module.scss

@@ -1,15 +1,12 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
-@use '~/styles/variables' as var;
 @use '../button-styles';
 
-@use './variables' as sidebarNavVar;
+@use '../variables' as sidebarVar;
 
 .grw-primary-items :global {
   .btn {
-    @extend %btn-primary-basis;
-
-    height: sidebarNavVar.$grw-sidebar-primary-button-height;
+    @extend %btn-basis;
 
     i {
       opacity: 0.7;
@@ -24,7 +21,7 @@
 
 // Add indicator
 .grw-primary-items :global {
-  $btn-height: sidebarNavVar.$grw-sidebar-primary-button-height;
+  $btn-height: sidebarVar.$grw-sidebar-button-height;
   $btn-active-indicator-height: 34px;
 
   .btn {

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

@@ -74,7 +74,7 @@ const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
     <button
       type="button"
       data-testid={`grw-sidebar-nav-primary-${labelForTestId}`}
-      className={`d-block btn btn-primary ${indicatorClass}`}
+      className={`btn btn-primary ${indicatorClass}`}
       onClick={itemClickedHandler}
       onMouseEnter={mouseEnteredHandler}
     >

+ 2 - 5
apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.module.scss

@@ -2,15 +2,12 @@
 
 @use '../button-styles';
 
-@use './variables' as sidebarNavVar;
 
 .grw-secondary-items :global {
   .btn {
-    @extend %btn-primary-basis;
+    @extend %btn-basis;
 
-    height: sidebarNavVar.$grw-sidebar-primary-button-height;
-
-    i {
+    span {
       opacity: 0.6;
 
       &:hover,

+ 2 - 2
apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -29,11 +29,11 @@ const SecondaryItem: FC<SecondaryItemProps> = (props: SecondaryItemProps) => {
   return (
     <Link
       href={href}
-      className="d-block btn btn-primary"
+      className="d-block btn btn-primary d-flex align-items-center justify-content-center"
       target={`${isBlank ? '_blank' : ''}`}
       prefetch={false}
     >
-      <i className="material-symbols-outlined">{iconName}</i>
+      <span className="material-symbols-outlined">{iconName}</span>
     </Link>
   );
 };

+ 2 - 2
apps/app/src/components/Sidebar/SidebarNav/SkeletonItem.module.scss

@@ -1,9 +1,9 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
-@use './variables' as sidebarNavVar;
+@use '../variables' as sidebarVar;
 
 .grw-skeleton-item :global {
-  height: sidebarNavVar.$grw-sidebar-primary-button-height;
+  height: sidebarVar.$grw-sidebar-button-height;
   padding: .75rem;
 
   .grw-skeleton {

+ 0 - 1
apps/app/src/components/Sidebar/SidebarNav/_variables.scss

@@ -1 +0,0 @@
-$grw-sidebar-primary-button-height: 50px;

+ 10 - 3
apps/app/src/components/Sidebar/_button-styles.scss

@@ -1,8 +1,15 @@
 @use '~/styles/variables' as var;
 
-%btn-primary-basis {
-  padding-top: .75rem;
-  padding-bottom: .75rem;
+@use './variables' as sidebarVar;
+
+
+%btn-basis {
+  --bs-btn-padding-x: 0;
+  --bs-btn-padding-y: 0;
+
+  width: var.$grw-sidebar-nav-width;
+  height: sidebarVar.$grw-sidebar-button-height;
+
   line-height: 1em;
   border: 0;
   border-radius: 0;

+ 1 - 0
apps/app/src/components/Sidebar/_variables.scss

@@ -0,0 +1 @@
+$grw-sidebar-button-height: 50px;

+ 6 - 0
apps/app/src/features/comment/server/events/consts.ts

@@ -0,0 +1,6 @@
+export const CommentEvent = {
+  CREATE: 'create',
+  UPDATE: 'update',
+  DELETE: 'delete',
+} as const;
+export type CommentEvent = typeof CommentEvent[keyof typeof CommentEvent];

+ 3 - 0
apps/app/src/features/comment/server/events/event-emitter.ts

@@ -0,0 +1,3 @@
+import { EventEmitter } from 'events';
+
+export const commentEvent = new EventEmitter();

+ 2 - 0
apps/app/src/features/comment/server/events/index.ts

@@ -0,0 +1,2 @@
+export * from './consts';
+export * from './event-emitter';

+ 2 - 0
apps/app/src/features/comment/server/index.ts

@@ -0,0 +1,2 @@
+export * from './events';
+export * from './models';

+ 121 - 0
apps/app/src/features/comment/server/models/comment.ts

@@ -0,0 +1,121 @@
+import type { IUser } from '@growi/core/dist/interfaces';
+import {
+  Types, Document, Model, Schema, Query,
+} from 'mongoose';
+
+import { IComment } from '~/interfaces/comment';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:models:comment');
+
+export interface CommentDocument extends IComment, Document {
+  removeWithReplies: () => Promise<void>
+  findCreatorsByPage: (pageId: Types.ObjectId) => Promise<CommentDocument[]>
+}
+
+
+type Add = (
+  pageId: Types.ObjectId,
+  creatorId: Types.ObjectId,
+  revisionId: Types.ObjectId,
+  comment: string,
+  commentPosition: number,
+  replyTo?: Types.ObjectId | null,
+) => Promise<CommentDocument>;
+type FindCommentsByPageId = (pageId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
+type FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
+type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>
+type GetPageIdToCommentMap = (pageIds: Types.ObjectId[]) => Promise<Record<string, CommentDocument[]>>
+type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>
+
+export interface CommentModel extends Model<CommentDocument> {
+  add: Add
+  findCommentsByPageId: FindCommentsByPageId
+  findCommentsByRevisionId: FindCommentsByRevisionId
+  findCreatorsByPage: FindCreatorsByPage
+  getPageIdToCommentMap: GetPageIdToCommentMap
+  countCommentByPageId: CountCommentByPageId
+}
+
+const commentSchema = new Schema<CommentDocument, CommentModel>({
+  page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  revision: { type: Schema.Types.ObjectId, ref: 'Revision', index: true },
+  comment: { type: String, required: true },
+  commentPosition: { type: Number, default: -1 },
+  replyTo: { type: Schema.Types.ObjectId },
+}, {
+  timestamps: true,
+});
+
+const add: Add = async function(
+    this: CommentModel,
+    pageId,
+    creatorId,
+    revisionId,
+    comment,
+    commentPosition,
+    replyTo?,
+): Promise<CommentDocument> {
+  try {
+    const data = await this.create({
+      page: pageId.toString(),
+      creator: creatorId.toString(),
+      revision: revisionId.toString(),
+      comment,
+      commentPosition,
+      replyTo,
+    });
+    logger.debug('Comment saved.', data);
+
+    return data;
+  }
+  catch (err) {
+    logger.debug('Error on saving comment.', err);
+    throw err;
+  }
+};
+commentSchema.statics.add = add;
+
+commentSchema.statics.findCommentsByPageId = function(id) {
+  return this.find({ page: id }).sort({ createdAt: -1 });
+};
+
+commentSchema.statics.findCommentsByRevisionId = function(id) {
+  return this.find({ revision: id }).sort({ createdAt: -1 });
+};
+
+commentSchema.statics.findCreatorsByPage = async function(page) {
+  return this.distinct('creator', { page }).exec();
+};
+
+/**
+ * @return {object} key: page._id, value: comments
+ */
+commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
+  const results = await this.aggregate()
+    .match({ page: { $in: pageIds } })
+    .group({ _id: '$page', comments: { $push: '$comment' } });
+
+  // convert to map
+  const idToCommentMap = {};
+  results.forEach((result, i) => {
+    idToCommentMap[result._id] = result.comments;
+  });
+
+  return idToCommentMap;
+};
+
+commentSchema.statics.countCommentByPageId = async function(page) {
+  return this.count({ page });
+};
+
+commentSchema.methods.removeWithReplies = async function(comment) {
+  await this.remove({
+    $or: (
+      [{ replyTo: this._id }, { _id: this._id }]),
+  });
+};
+
+export const Comment = getOrCreateModel<CommentDocument, CommentModel>('Comment', commentSchema);

+ 1 - 0
apps/app/src/features/comment/server/models/index.ts

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

+ 5 - 6
apps/app/src/interfaces/comment.ts

@@ -1,18 +1,17 @@
 import type {
-  Nullable, Ref, HasObjectId,
+  Ref, HasObjectId,
   IPage, IRevision, IUser,
 } from '@growi/core';
 
 export type IComment = {
+  page: Ref<IPage>,
+  creator: Ref<IUser>,
+  revision: Ref<IRevision>,
   comment: string;
   commentPosition: number,
-  isMarkdown: boolean,
-  replyTo: Nullable<string>,
+  replyTo?: string,
   createdAt: Date,
   updatedAt: Date,
-  page: Ref<IPage>,
-  revision: Ref<IRevision>,
-  creator: IUser,
 };
 
 export interface ICommentPostArgs {

+ 2 - 0
apps/app/src/pages/[[...path]].page.tsx

@@ -76,6 +76,7 @@ const TemplateModal = dynamic(() => import('../components/TemplateModal').then(m
 const LinkEditModal = dynamic(() => import('../components/PageEditor/LinkEditModal').then(mod => mod.LinkEditModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
+const TagEditModal = dynamic(() => import('../components/PageTags/TagEditModal').then(mod => mod.TagEditModal), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -376,6 +377,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <QuestionnaireModalManager />
       <TemplateModal />
       <LinkEditModal />
+      <TagEditModal />
     </>
   );
 };

+ 0 - 1
apps/app/src/server/crowi/index.js

@@ -100,7 +100,6 @@ function Crowi() {
     page: new (require('../events/page'))(this),
     activity: new (require('../events/activity'))(this),
     bookmark: new (require('../events/bookmark'))(this),
-    comment: new (require('../events/comment'))(this),
     tag: new (require('../events/tag'))(this),
     admin: new (require('../events/admin'))(this),
   };

+ 0 - 26
apps/app/src/server/events/comment.ts

@@ -1,26 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:events:comment');
-
-const events = require('events');
-const util = require('util');
-
-
-function CommentEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(CommentEvent, events.EventEmitter);
-
-CommentEvent.prototype.onCreate = function(comment) {
-  logger.info('onCreate comment event fired');
-};
-CommentEvent.prototype.onUpdate = function(comment) {
-  logger.info('onUpdate comment event fired');
-};
-CommentEvent.prototype.onDelete = function(comment) {
-  logger.info('onDelete comment event fired');
-};
-
-module.exports = CommentEvent;

+ 0 - 122
apps/app/src/server/models/comment.js

@@ -1,122 +0,0 @@
-module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:comment');
-  const mongoose = require('mongoose');
-  const ObjectId = mongoose.Schema.Types.ObjectId;
-  const commentEvent = crowi.event('comment');
-
-  const commentSchema = new mongoose.Schema({
-    page: { type: ObjectId, ref: 'Page', index: true },
-    creator: { type: ObjectId, ref: 'User', index: true },
-    revision: { type: ObjectId, ref: 'Revision', index: true },
-    comment: { type: String, required: true },
-    commentPosition: { type: Number, default: -1 },
-    isMarkdown: { type: Boolean, default: false },
-    replyTo: { type: ObjectId },
-  }, {
-    timestamps: true,
-  });
-
-  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown, replyTo) {
-    const Comment = this;
-
-    return new Promise(((resolve, reject) => {
-      const newComment = new Comment();
-
-      newComment.page = pageId;
-      newComment.creator = creatorId;
-      newComment.revision = revisionId;
-      newComment.comment = comment;
-      newComment.commentPosition = position;
-      newComment.isMarkdown = isMarkdown || false;
-      newComment.replyTo = replyTo;
-
-      newComment.save((err, data) => {
-        if (err) {
-          debug('Error on saving comment.', err);
-          return reject(err);
-        }
-        debug('Comment saved.', data);
-        return resolve(data);
-      });
-    }));
-  };
-
-  commentSchema.statics.getCommentsByPageId = function(id) {
-    return this.find({ page: id }).sort({ createdAt: -1 });
-  };
-
-  commentSchema.statics.getCommentsByRevisionId = function(id) {
-    return this.find({ revision: id }).sort({ createdAt: -1 });
-  };
-
-
-  /**
-   * @return {object} key: page._id, value: comments
-   */
-  commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
-    const results = await this.aggregate()
-      .match({ page: { $in: pageIds } })
-      .group({ _id: '$page', comments: { $push: '$comment' } });
-
-    // convert to map
-    const idToCommentMap = {};
-    results.forEach((result, i) => {
-      idToCommentMap[result._id] = result.comments;
-    });
-
-    return idToCommentMap;
-  };
-
-  commentSchema.statics.countCommentByPageId = function(page) {
-    const self = this;
-
-    return new Promise(((resolve, reject) => {
-      self.count({ page }, (err, data) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(data);
-      });
-    }));
-  };
-
-  commentSchema.statics.updateCommentsByPageId = async function(comment, isMarkdown, commentId) {
-    const Comment = this;
-
-    const commentData = await Comment.findOneAndUpdate(
-      { _id: commentId },
-      { $set: { comment, isMarkdown } },
-    );
-
-    await commentEvent.emit('update', commentData);
-
-    return commentData;
-  };
-
-
-  /**
-   * post remove hook
-   */
-  commentSchema.post('reomove', async(savedComment) => {
-    await commentEvent.emit('delete', savedComment);
-  });
-
-  commentSchema.methods.removeWithReplies = async function(comment) {
-    const Comment = crowi.model('Comment');
-
-    await Comment.remove({
-      $or: (
-        [{ replyTo: this._id }, { _id: this._id }]),
-    });
-
-    await commentEvent.emit('delete', comment);
-    return;
-  };
-
-  commentSchema.statics.findCreatorsByPage = async function(page) {
-    return this.distinct('creator', { page }).exec();
-  };
-
-  return mongoose.model('Comment', commentSchema);
-};

+ 0 - 1
apps/app/src/server/models/index.js

@@ -9,7 +9,6 @@ module.exports = {
   Revision: require('./revision'),
   Tag: require('./tag'),
   Bookmark: require('./bookmark'),
-  Comment: require('./comment'),
   Attachment: require('./attachment'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),

+ 1 - 2
apps/app/src/server/models/obsolete-page.js

@@ -2,6 +2,7 @@ import { PageGrant } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 
+import { Comment } from '~/features/comment/server';
 import loggerFactory from '~/utils/logger';
 
 
@@ -274,7 +275,6 @@ export const getPageSchema = (crowi) => {
     validateCrowi();
 
     const self = this;
-    const Comment = crowi.model('Comment');
     return Comment.countCommentByPageId(pageId)
       .then((count) => {
         self.update({ _id: pageId }, { commentCount: count }, {}, (err, data) => {
@@ -702,7 +702,6 @@ export const getPageSchema = (crowi) => {
   };
 
   pageSchema.methods.getNotificationTargetUsers = async function() {
-    const Comment = mongoose.model('Comment');
     const Revision = mongoose.model('Revision');
 
     const [commentCreators, revisionAuthors] = await Promise.all([Comment.findCreatorsByPage(this), Revision.findAuthorsByPage(this)]);

+ 2 - 4
apps/app/src/server/routes/apiv3/pages.js

@@ -240,11 +240,9 @@ module.exports = (crowi) => {
   }
 
   async function generateUniquePath(basePath, index = 1) {
-    const Page = mongoose.model('Page');
     const path = basePath + index;
-    const response = await Page.findByPath(path);
-    const isPathExists = response != null;
-    if (isPathExists) {
+    const existingPageId = await Page.exists({ path, isEmpty: false });
+    if (existingPageId != null) {
       return generateUniquePath(basePath, index + 1);
     }
     return path;

+ 10 - 17
apps/app/src/server/routes/comment.js

@@ -1,4 +1,5 @@
 
+import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
@@ -49,7 +50,6 @@ const { serializeUserSecurely } = require('../models/serializers/user-serializer
 
 module.exports = function(crowi, app) {
   const logger = loggerFactory('growi:routes:comment');
-  const Comment = crowi.model('Comment');
   const User = crowi.model('User');
   const Page = crowi.model('Page');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
@@ -124,21 +124,21 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
 
-    let fetcher = null;
+    let query = null;
 
     try {
       if (revisionId) {
-        fetcher = Comment.getCommentsByRevisionId(revisionId);
+        query = Comment.findCommentsByRevisionId(revisionId);
       }
       else {
-        fetcher = Comment.getCommentsByPageId(pageId);
+        query = Comment.findCommentsByPageId(pageId);
       }
     }
     catch (err) {
       return res.json(ApiResponse.error(err));
     }
 
-    const comments = await fetcher.populate('creator');
+    const comments = await query.populate('creator');
     comments.forEach((comment) => {
       if (comment.creator != null && comment.creator instanceof User) {
         comment.creator = serializeUserSecurely(comment.creator);
@@ -233,9 +233,7 @@ module.exports = function(crowi, app) {
     const revisionId = commentForm.revision_id;
     const comment = commentForm.comment;
     const position = commentForm.comment_position || -1;
-    const isMarkdown = commentForm.is_markdown ?? true; // comment is always markdown (https://github.com/weseek/growi/pull/6096)
     const replyTo = commentForm.replyTo;
-    const commentEvent = crowi.event('comment');
 
     // check whether accessible
     const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
@@ -245,8 +243,8 @@ module.exports = function(crowi, app) {
 
     let createdComment;
     try {
-      createdComment = await Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown, replyTo);
-      commentEvent.emit('create', createdComment);
+      createdComment = await Comment.add(pageId, req.user._id, revisionId, comment, position, replyTo);
+      commentEvent.emit(CommentEvent.CREATE, createdComment);
     }
     catch (err) {
       logger.error(err);
@@ -355,12 +353,9 @@ module.exports = function(crowi, app) {
     const { commentForm } = req.body;
 
     const commentStr = commentForm.comment;
-    const isMarkdown = commentForm.is_markdown ?? true; // comment is always markdown (https://github.com/weseek/growi/pull/6096)
     const commentId = commentForm.comment_id;
     const revision = commentForm.revision_id;
 
-    const commentEvent = crowi.event('comment');
-
     if (commentStr === '') {
       return res.json(ApiResponse.error('Comment text is required'));
     }
@@ -389,9 +384,9 @@ module.exports = function(crowi, app) {
 
       updatedComment = await Comment.findOneAndUpdate(
         { _id: commentId },
-        { $set: { comment: commentStr, isMarkdown, revision } },
+        { $set: { comment: commentStr, revision } },
       );
-      commentEvent.emit('update', updatedComment);
+      commentEvent.emit(CommentEvent.UPDATE, updatedComment);
     }
     catch (err) {
       logger.error(err);
@@ -448,8 +443,6 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment_id Comment Id.
    */
   api.remove = async function(req, res) {
-    const commentEvent = crowi.event('comment');
-
     const commentId = req.body.comment_id;
     if (!commentId) {
       return Promise.resolve(res.json(ApiResponse.error('\'comment_id\' is undefined')));
@@ -474,7 +467,7 @@ module.exports = function(crowi, app) {
 
       await comment.removeWithReplies(comment);
       await Page.updateCommentCount(comment.page);
-      commentEvent.emit('delete', comment);
+      commentEvent.emit(CommentEvent.DELETE, comment);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 12 - 16
apps/app/src/server/service/comment.ts

@@ -1,5 +1,7 @@
 import { Types } from 'mongoose';
 
+import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
+
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import { getModelSafely } from '../util/mongoose-utils';
@@ -17,22 +19,18 @@ class CommentService {
 
   inAppNotificationService!: any;
 
-  commentEvent!: any;
-
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.activityService = crowi.activityService;
     this.inAppNotificationService = crowi.inAppNotificationService;
 
-    this.commentEvent = crowi.event('comment');
-
     // init
     this.initCommentEventListeners();
   }
 
   initCommentEventListeners(): void {
     // create
-    this.commentEvent.on('create', async(savedComment) => {
+    commentEvent.on(CommentEvent.CREATE, async(savedComment) => {
 
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
@@ -45,19 +43,11 @@ class CommentService {
     });
 
     // update
-    this.commentEvent.on('update', async() => {
-      try {
-        this.commentEvent.onUpdate();
-      }
-      catch (err) {
-        logger.error('Error occurred while handling the comment update event:\n', err);
-      }
+    commentEvent.on(CommentEvent.UPDATE, async() => {
     });
 
     // remove
-    this.commentEvent.on('delete', async(removedComment) => {
-      this.commentEvent.onDelete();
-
+    commentEvent.on(CommentEvent.DELETE, async(removedComment) => {
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
         await Page.updateCommentCount(removedComment.page);
@@ -69,11 +59,17 @@ class CommentService {
   }
 
   getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
-    const Comment = getModelSafely('Comment') || require('../models/comment')(this.crowi);
     const User = getModelSafely('User') || require('../models/user')(this.crowi);
 
     // Get comment by comment ID
     const commentData = await Comment.findOne({ _id: commentId });
+
+    // not found
+    if (commentData == null) {
+      logger.warn(`The comment ('${commentId.toString()}') is not found.`);
+      return [];
+    }
+
     const { comment } = commentData;
 
     const usernamesFromComment = comment.match(USERNAME_PATTERN);

+ 0 - 3
apps/app/src/server/service/in-app-notification.ts

@@ -35,9 +35,6 @@ export default class InAppNotificationService {
 
   activityEvent!: any;
 
-  commentEvent!: any;
-
-
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.activityEvent = crowi.event('activity');

+ 1 - 1
apps/app/src/server/service/page.ts

@@ -14,6 +14,7 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, Cursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
+import { Comment } from '~/features/comment/server';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
@@ -1687,7 +1688,6 @@ class PageService {
   private async deleteCompletelyOperation(pageIds, pagePaths) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
-    const Comment = this.crowi.model('Comment');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const ShareLink = this.crowi.model('ShareLink');

+ 1 - 1
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -5,6 +5,7 @@ import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
+import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
   ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
@@ -458,7 +459,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
-    const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;

+ 4 - 4
apps/app/src/server/service/search.ts

@@ -2,6 +2,7 @@ import type { IPageHasId } from '@growi/core';
 import mongoose from 'mongoose';
 import { FilterXSS } from 'xss';
 
+import { CommentEvent, commentEvent } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
@@ -166,10 +167,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const tagEvent = this.crowi.event('tag');
     tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
 
-    const commentEvent = this.crowi.event('comment');
-    commentEvent.on('create', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on('update', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
-    commentEvent.on('delete', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on(CommentEvent.CREATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on(CommentEvent.UPDATE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on(CommentEvent.DELETE, this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
   }
 
   resetErrorStatus() {

+ 49 - 1
apps/app/src/stores/modal.tsx

@@ -700,7 +700,7 @@ export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalSta
   const open = useCallback((attachment: IAttachmentHasId, remove: Remove) => {
     mutate({ isOpened: true, attachment, remove });
   }, [mutate]);
-  const close = useCallback((): void => {
+  const close = useCallback(() => {
     mutate({ isOpened: false });
   }, [mutate]);
 
@@ -773,3 +773,51 @@ export const usePageSelectModal = (
     close: () => swrResponse.mutate({ isOpened: false }),
   };
 };
+
+/*
+* TagEditModal
+*/
+export type TagEditModalStatus = {
+  isOpen: boolean,
+  tags: string[],
+  pageId: string,
+  revisionId: string,
+}
+
+type TagEditModalUtils = {
+  open(tags: string[], pageId: string, revisionId: string): Promise<void>,
+  close(): Promise<void>,
+}
+
+export const useTagEditModal = (): SWRResponse<TagEditModalStatus, Error> & TagEditModalUtils => {
+  const initialStatus: TagEditModalStatus = useMemo(() => {
+    return {
+      isOpen: false,
+      tags: [],
+      pageId: '',
+      revisionId: '',
+    };
+  }, []);
+
+  const swrResponse = useStaticSWR<TagEditModalStatus, Error>('TagEditModal', undefined, { fallbackData: initialStatus });
+  const { mutate } = swrResponse;
+
+  const open = useCallback(async(tags: string[], pageId: string, revisionId: string) => {
+    mutate({
+      isOpen: true,
+      tags,
+      pageId,
+      revisionId,
+    });
+  }, [mutate]);
+
+  const close = useCallback(async() => {
+    mutate(initialStatus);
+  }, [initialStatus, mutate]);
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};

+ 2 - 1
apps/app/src/styles/_editor.scss

@@ -1,4 +1,5 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
+
 @use './variables' as var;
 
 @import './organisms/wiki-custom-sidebar';
@@ -27,7 +28,7 @@
    *****************/
   .dynamic-layout-root {
     width: calc(100vw - var.$grw-sidebar-nav-width);
-    height: 100vh;
+    @extend .flex-expand-vh-100;
   }
 
 

+ 1 - 11
apps/app/src/styles/_layout.scss

@@ -1,19 +1,9 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
-@use '@growi/core/scss/flex-expand';
 
 @use './variables' as var;
 
-.flex-expand-horiz {
-  @extend %flex-expand-horiz;
-}
-
-.flex-expand-vert {
-  @extend %flex-expand-vert;
-}
-
 .dynamic-layout-root {
-  @extend %flex-expand-vert;
-  overflow-y: unset;
+  @extend .flex-expand-vert;
 }
 
 .dynamic-layout-root.growi-layout-fluid .grw-container-convertible {

+ 0 - 2
apps/app/test/integration/service/v5.public-page.test.ts

@@ -739,7 +739,6 @@ describe('PageService page operations with only public pages', () => {
     await Comment.insertMany([
       {
         commentPosition: -1,
-        isMarkdown: true,
         page: pageIdForDuplicate11,
         creator: dummyUser1._id,
         revision: revisionIdForDuplicate10,
@@ -982,7 +981,6 @@ describe('PageService page operations with only public pages', () => {
     await Comment.insertMany([
       {
         commentPosition: -1,
-        isMarkdown: true,
         page: pageIdForDeleteCompletely2,
         creator: dummyUser1._id,
         revision: revisionIdForDeleteCompletely4,

+ 60 - 0
apps/app/turbo.json

@@ -0,0 +1,60 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "pipeline": {
+
+    "styles-prebuilt": {
+      "outputs": ["src/styles/prebuilt/**"],
+      "inputs": [
+        "src/styles/**/*.scss",
+        "../../packages/core/scss/**"
+      ],
+      "outputMode": "new-only"
+    },
+    "build": {
+      "dependsOn": ["^build", "styles-prebuilt"],
+      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
+      "outputMode": "new-only"
+    },
+
+    "dev:migrate": {
+      "dependsOn": ["@growi/core#dev"],
+      "outputs": ["tmp/cache/migration-status.out"],
+      "inputs": ["src/migrations/*.js"],
+      "outputMode": "new-only"
+    },
+    "dev:styles-prebuilt": {
+      "outputs": ["src/styles/prebuilt/**"],
+      "inputs": [
+        "src/styles/**/*.scss",
+        "!src/styles/prebuilt/**",
+        "../../packages/core/scss/**"
+      ],
+      "outputMode": "new-only"
+    },
+    "dev": {
+      "dependsOn": ["^dev", "dev:migrate", "dev:styles-prebuilt"],
+      "cache": false,
+      "persistent": true
+    },
+    "dev:ci": {
+      "dependsOn": ["^dev", "dev:migrate", "dev:styles-prebuilt"],
+      "cache": false
+    },
+
+    "lint": {
+      "dependsOn": ["^dev", "dev:styles-prebuilt"]
+    },
+
+    "test": {
+      "dependsOn": ["^dev"],
+      "outputMode": "new-only"
+    },
+
+    "version": {
+      "cache": false,
+      "dependsOn": ["^version", "//#version"]
+    }
+
+  }
+}

+ 17 - 4
packages/core/scss/_flex-expand.scss

@@ -1,9 +1,22 @@
-@use './placeholders/flex-expand';
-
 .flex-expand-horiz {
-  @extend %flex-expand-horiz;
+  display: flex;
+  flex-direction: row;
+  flex-grow: 1;
+  height: 100%;
 }
 
 .flex-expand-vert {
-  @extend %flex-expand-vert;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  height: 100%;
+}
+
+.flex-expand-vh-100 {
+  height: 100vh;
+
+  .flex-expand-horiz,
+  .flex-expand-vert {
+    overflow-y: auto;
+  }
 }

+ 1 - 0
packages/core/scss/bootstrap/_variables.scss

@@ -113,6 +113,7 @@ $font-family-base: $font-family-sans-serif;
 // $modal-content-border-radius: $border-radius-lg;
 // $modal-header-padding-y: 0.75rem;
 // $modal-header-padding-x: 1rem;
+$modal-footer-border-width: 0;
 
 //== Alerts
 // $alert-bg-level: -2;

+ 0 - 17
packages/core/scss/placeholders/_flex-expand.scss

@@ -1,17 +0,0 @@
-// ref: https://discuss.codemirror.net/t/how-to-fit-the-codemirror-6-widget-into-a-flex-div/4207/4
-%flex-expand-horiz {
-  display: flex;
-  flex-direction: row;
-  flex-grow: 1;
-  height: 100%;
-  overflow-y: auto;
-}
-
-// ref: https://discuss.codemirror.net/t/how-to-fit-the-codemirror-6-widget-into-a-flex-div/4207/4
-%flex-expand-vert {
-  display: flex;
-  flex: 1;
-  flex-direction: column;
-  height: 100%;
-  overflow-y: auto;
-}

+ 1 - 1
packages/editor/src/components/playground/Playground.tsx

@@ -49,7 +49,7 @@ export const Playground = (): JSX.Element => {
   }, [codeMirrorEditor]);
 
   return (
-    <div className="d-flex flex-column vw-100 vh-100">
+    <div className="d-flex flex-column vw-100 flex-expand-vh-100">
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
         <div className="text-white">GrowiSubNavigation</div>
       </div>

+ 1 - 0
packages/editor/turbo.json

@@ -1,4 +1,5 @@
 {
+  "$schema": "https://turbo.build/schema.json",
   "extends": ["//"],
   "pipeline": {
     "build": {

+ 2 - 0
packages/ui/scss/atoms/_btn-muted.scss

@@ -12,6 +12,8 @@
   --bs-btn-active-color: #{$color-active};
   --bs-btn-active-bg: transparent;
 
+  --bs-btn-border-width: 0;
+
   &:hover {
     --bs-btn-active-bg: rgba(#{$color-active-rgb}, 0.2);
   }

+ 1 - 47
turbo.json

@@ -39,19 +39,6 @@
       "outputs": ["dist/**"],
       "outputMode": "new-only"
     },
-    "@growi/app#styles-prebuilt": {
-      "outputs": ["src/styles/prebuilt/**"],
-      "inputs": [
-        "src/styles/**/*.scss",
-        "../../packages/core/scss/**/*.scss"
-      ],
-      "outputMode": "new-only"
-    },
-    "@growi/app#build": {
-      "dependsOn": ["^build", "@growi/app#styles-prebuilt"],
-      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
-      "outputMode": "new-only"
-    },
     "@growi/slackbot-proxy#build": {
       "dependsOn": ["@growi/slack#build"],
       "outputs": ["dist/**"],
@@ -89,29 +76,6 @@
       "outputs": ["dist/**"],
       "outputMode": "new-only"
     },
-    "@growi/app#dev:migrate": {
-      "dependsOn": ["@growi/core#dev"],
-      "outputs": ["tmp/cache/migration-status.out"],
-      "inputs": ["src/migrations/*.js"],
-      "outputMode": "new-only"
-    },
-    "@growi/app#dev:styles-prebuilt": {
-      "outputs": ["src/styles/prebuilt/**"],
-      "inputs": [
-        "src/styles/**/*.scss",
-        "!src/styles/prebuilt/**"
-      ],
-      "outputMode": "new-only"
-    },
-    "@growi/app#dev": {
-      "dependsOn": ["^dev", "@growi/app#dev:migrate", "@growi/app#dev:styles-prebuilt"],
-      "cache": false,
-      "persistent": true
-    },
-    "@growi/app#dev:ci": {
-      "dependsOn": ["^dev", "@growi/app#dev:migrate", "@growi/app#dev:styles-prebuilt"],
-      "cache": false
-    },
     "@growi/slackbot-proxy#dev": {
       "dependsOn": ["@growi/slack#dev"],
       "cache": false,
@@ -157,19 +121,12 @@
     "@growi/ui#lint": {
       "dependsOn": ["@growi/core#dev"]
     },
-    "@growi/app#lint": {
-      "dependsOn": ["^dev", "@growi/app#dev:styles-prebuilt"]
-    },
     "@growi/slackbot-proxy#lint": {
       "dependsOn": ["@growi/slack#dev"]
     },
     "lint": {
     },
 
-    "@growi/app#test": {
-      "dependsOn": ["^dev"],
-      "outputMode": "new-only"
-    },
     "@growi/slackbot-proxy#test": {
       "dependsOn": ["@growi/slack#dev"],
       "outputMode": "new-only"
@@ -189,10 +146,7 @@
     "test": {
       "outputMode": "new-only"
     },
-    "@growi/app#version": {
-      "cache": false,
-      "dependsOn": ["^version", "//#version"]
-    },
+
     "version": {
       "cache": false
     },