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

Merge branch 'master' into support/156162-176216-app-some-client-components-biome-5

Yuki Takei 3 месяцев назад
Родитель
Сommit
5b6ac9ab9a
53 измененных файлов с 1973 добавлено и 1326 удалено
  1. 105 0
      .serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md
  2. 5 0
      apps/app/.eslintrc.js
  3. 60 27
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.tsx
  4. 6 7
      apps/app/src/client/components/Common/CountBadge.tsx
  5. 7 6
      apps/app/src/client/components/Common/CustomCopyToClipBoard.tsx
  6. 4 7
      apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx
  7. 17 11
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  8. 373 283
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  9. 138 87
      apps/app/src/client/components/Common/ImageCropModal.tsx
  10. 6 8
      apps/app/src/client/components/Common/LazyRenderer.tsx
  11. 4 3
      apps/app/src/client/components/Common/RendererErrorMessage.tsx
  12. 19 12
      apps/app/src/client/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  13. 5 8
      apps/app/src/client/components/Common/SubmittableInput/SubmittableInput.tsx
  14. 1 1
      apps/app/src/client/components/Common/SubmittableInput/index.ts
  15. 8 7
      apps/app/src/client/components/Common/SubmittableInput/types.d.ts
  16. 73 55
      apps/app/src/client/components/Common/SubmittableInput/use-submittable.ts
  17. 1 5
      apps/app/src/client/components/Common/UserPictureList.jsx
  18. 3 6
      apps/app/src/client/components/Icons/FolderIcon.tsx
  19. 1 2
      apps/app/src/client/components/Icons/RecentlyCreatedIcon.tsx
  20. 1 5
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss
  21. 79 72
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  22. 106 45
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  23. 30 21
      apps/app/src/client/components/PageAccessoriesModal/PageAttachment.tsx
  24. 7 6
      apps/app/src/client/components/PageAccessoriesModal/PageHistory.tsx
  25. 39 23
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLink.tsx
  26. 115 57
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx
  27. 41 33
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  28. 4 1
      apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx
  29. 32 29
      apps/app/src/client/components/PageAccessoriesModal/hooks.tsx
  30. 66 43
      apps/app/src/client/components/PageComment/Comment.tsx
  31. 8 6
      apps/app/src/client/components/PageComment/CommentControl.tsx
  32. 153 113
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  33. 2 7
      apps/app/src/client/components/PageComment/CommentPreview.tsx
  34. 74 56
      apps/app/src/client/components/PageComment/DeleteCommentModal/DeleteCommentModal.tsx
  35. 7 3
      apps/app/src/client/components/PageComment/DeleteCommentModal/dynamic.tsx
  36. 35 23
      apps/app/src/client/components/PageComment/ReplyComments.tsx
  37. 12 21
      apps/app/src/client/components/PageComment/SwitchingButtonGroup.tsx
  38. 44 31
      apps/app/src/client/components/PageControls/BookmarkButtons.tsx
  39. 38 23
      apps/app/src/client/components/PageControls/LikeButtons.tsx
  40. 147 82
      apps/app/src/client/components/PageControls/PageControls.tsx
  41. 1 4
      apps/app/src/client/components/PageControls/SearchButton.tsx
  42. 28 12
      apps/app/src/client/components/PageControls/SeenUserInfo.tsx
  43. 11 9
      apps/app/src/client/components/PageControls/SubscribeButton.tsx
  44. 0 8
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss
  45. 34 25
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  46. 1 1
      apps/app/src/client/components/TrashPageList.tsx
  47. 0 5
      apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.module.scss
  48. 2 12
      apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx
  49. 1 1
      apps/app/src/components/PageView/PageContentFooter.tsx
  50. 14 6
      apps/app/src/components/PageView/PageView.tsx
  51. 1 1
      apps/app/src/components/PageView/PageViewLayout.tsx
  52. 4 2
      apps/app/src/pages/trash/index.page.tsx
  53. 0 5
      biome.json

+ 105 - 0
.serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md

@@ -0,0 +1,105 @@
+# PagePathNav と SubNavigation の z-index レイヤリング
+
+## 概要
+
+PagePathNav(ページパス表示)と GrowiContextualSubNavigation(PageControls等を含むサブナビゲーション)の
+Sticky 状態における z-index の重なり順を修正した際の知見。
+
+## 修正したバグ
+
+### 症状
+スクロールしていって PagePathNav がウィンドウ上端に近づいたときに、PageControls のボタンが
+PagePathNav の要素の裏側に回ってしまい、クリックできなくなる。
+
+### 原因
+z-index 的に以下のように重なっていたため:
+
+**[Before]** 下層から順に:
+1. PageView の children - z-0
+2. ( GroundGlassBar = PageControls ) ← 同じ層 z-1
+3. PagePathNav
+
+PageControls が PagePathNav より下層にいたため、sticky 境界付近でクリック不能になっていた。
+
+## 修正後の構成
+
+**[After]** 下層から順に:
+1. PageView の children - z-0
+2. GroundGlassBar(磨りガラス背景)- z-1
+3. PagePathNav - z-2(通常時)/ z-3(sticky時)
+4. PageControls(nav要素)- z-3
+
+### ファイル構成
+
+- `GrowiContextualSubNavigation.tsx` - GroundGlassBar を分離してレンダリング
+  - 1つ目: GroundGlassBar のみ(`position-fixed`, `z-1`)
+  - 2つ目: nav 要素(`z-3`)
+- `PagePathNavSticky.tsx` - z-index を動的に切り替え
+  - 通常時: `z-2`
+  - sticky時: `z-3`
+
+## 実装のポイント
+
+### GroundGlassBar を分離した理由
+GroundGlassBar を `position-fixed` で常に固定表示にすることで、
+PageControls と切り離して独立した z-index 層として扱えるようにした。
+
+これにより、GroundGlassBar → PagePathNav → PageControls という
+理想的なレイヤー構造を実現できた。
+
+## CopyDropdown が z-2 で動作しない理由(解決済み)
+
+### 問題
+
+`PagePathNavSticky.tsx` の sticky 時の z-index について:
+
+```tsx
+// これだと CopyDropdown(マウスオーバーで表示されるドロップダウン)が出ない
+innerActiveClass="active z-2 mt-1"
+
+// これだと正常に動作する
+innerActiveClass="active z-3 mt-1"
+```
+
+### 原因
+
+1. `GrowiContextualSubNavigation` の sticky-inner-wrapper は `z-3` かつ横幅いっぱい(Flex アイテム)
+2. この要素が PagePathNavSticky(`z-2`)の上に重なる
+3. CopyDropdown は `.grw-page-path-nav-layout:hover` で `visibility: visible` になる仕組み
+   (参照: `PagePathNavLayout.module.scss`)
+4. **z-3 の要素が上に被さっているため、hover イベントが PagePathNavSticky に届かない**
+5. 結果、CopyDropdown のアイコンが表示されない
+
+### なぜ z-3 で動作するか
+
+- 同じ z-index: 3 になるため、DOM 順序で前後が決まる
+- PagePathNavSticky は GrowiContextualSubNavigation より後にレンダリングされるため前面に来る
+- hover イベントが正常に届き、CopyDropdown が表示される
+
+### 結論
+
+PagePathNavSticky の sticky 時の z-index は `z-3` である必要がある。
+これは GrowiContextualSubNavigation と同じ層に置くことで、DOM 順序による前後関係を利用するため。
+
+## 関連ファイル
+
+- `apps/app/src/client/components/PageView/PageView.tsx`
+- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx`
+- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss`
+- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx`
+- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss`
+- `apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx`(CopyDropdown を含む)
+
+## ライブラリの注意事項
+
+### react-stickynode の deprecation
+`react-stickynode` は **2025-12-31 で deprecated** となる予定。
+https://github.com/yahoo/react-stickynode
+
+将来的には CSS `position: sticky` + `IntersectionObserver` への移行を検討する必要がある。
+
+## 注意事項
+
+- z-index の値を変更する際は、上記のレイヤー構造を壊さないよう注意
+- Sticky コンポーネントの `innerActiveClass` で z-index を指定する際、
+  他のコンポーネントとの相互作用を確認すること

+ 5 - 0
apps/app/.eslintrc.js

@@ -42,12 +42,17 @@ module.exports = {
     'src/client/components/*.ts',
     'src/client/components/*.js',
     'src/client/components/AuthorInfo/**',
+    'src/client/components/Common/**',
     'src/client/components/CreateTemplateModal/**',
     'src/client/components/CustomNavigation/**',
     'src/client/components/DeleteBookmarkFolderModal/**',
     'src/client/components/EmptyTrashModal/**',
     'src/client/components/GrantedGroupsInheritanceSelectModal/**',
+    'src/client/components/Icons/**',
     'src/client/components/Maintenance/**',
+    'src/client/components/PageControls/**',
+    'src/client/components/PageComment/**',
+    'src/client/components/PageAccessoriesModal/**',
     'src/client/components/PageHistory/**',
     'src/client/components/Presentation/**',
     'src/client/components/PutbackPageModal/**',

+ 60 - 27
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.tsx

@@ -1,12 +1,18 @@
 import React, {
-  useState, useMemo, useCallback, type ReactNode, type CSSProperties,
+  type CSSProperties,
+  type ReactNode,
+  useCallback,
+  useMemo,
+  useState,
 } from 'react';
-
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
   Tooltip,
 } from 'reactstrap';
 
@@ -33,20 +39,28 @@ interface CopyDropdownProps {
 
 /* eslint-disable react/prop-types */
 const DropdownItemContents: React.FC<DropdownItemContentsProps> = ({
-  title, contents, className = '', style,
+  title,
+  contents,
+  className = '',
+  style,
 }) => (
   <>
-    <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-    <div className={`card mb-1 p-2 ${className}`} style={style}>{contents}</div>
+    <div className="h6 mt-1 mb-2">
+      <strong>{title}</strong>
+    </div>
+    <div className={`card mb-1 p-2 ${className}`} style={style}>
+      {contents}
+    </div>
   </>
 );
 /* eslint-enable react/prop-types */
 
-
 export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
   const [dropdownOpen, setDropdownOpen] = useState(false);
   const [tooltipOpen, setTooltipOpen] = useState(false);
-  const [isParamsAppended, setParamsAppended] = useState(!props.isShareLinkMode);
+  const [isParamsAppended, setParamsAppended] = useState(
+    !props.isShareLinkMode,
+  );
 
   /*
    * functions to construct labels and URLs
@@ -56,9 +70,7 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
       return '';
     }
 
-    const {
-      search, hash,
-    } = window.location;
+    const { search, hash } = window.location;
 
     return `${search}${hash}`;
   }, [isParamsAppended, dropdownOpen]);
@@ -96,7 +108,6 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
     return `[${label}](${permalink})`;
   }, [props, getUriParams, permalink]);
 
-
   /**
    * control
    */
@@ -115,16 +126,17 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
     }, 1000);
   }, []);
 
-
   /*
    * render
    */
   const { t } = useTranslation('commons');
   const {
-    dropdownToggleId, pageId,
+    dropdownToggleId,
+    pageId,
     dropdownToggleClassName,
     dropdownMenuContainer,
-    children, isShareLinkMode,
+    children,
+    isShareLinkMode,
   } = props;
 
   const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
@@ -151,9 +163,9 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
         >
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">
-              { t('copy_to_clipboard.Copy to clipboard') }
+              {t('copy_to_clipboard.Copy to clipboard')}
             </DropdownItem>
-            { !isShareLinkMode && (
+            {!isShareLinkMode && (
               <div className="px-3 form-check form-switch form-switch-sm">
                 <input
                   type="checkbox"
@@ -162,9 +174,14 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
                   checked={isParamsAppended}
                   onChange={toggleAppendParams}
                 />
-                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>{ t('copy_to_clipboard.Append params') }</label>
+                <label
+                  className="form-label form-check-label small"
+                  htmlFor={customSwitchForParamsId}
+                >
+                  {t('copy_to_clipboard.Append params')}
+                </label>
               </div>
-            ) }
+            )}
           </div>
 
           <DropdownItem divider className="my-0"></DropdownItem>
@@ -195,7 +212,7 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
           <DropdownItem divider className="my-0"></DropdownItem>
 
           {/* Permanent Link */}
-          { pageId && (
+          {pageId && (
             <CopyToClipboard text={permalink} onCopy={showToolTip}>
               <DropdownItem className="px-3">
                 <DropdownItemContents
@@ -210,12 +227,21 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
           <DropdownItem divider className="my-0"></DropdownItem>
 
           {/* Page path + Permanent Link */}
-          { pageId && (
-            <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={showToolTip}>
+          {pageId && (
+            <CopyToClipboard
+              text={`${pagePathWithParams}\n${permalink}`}
+              onCopy={showToolTip}
+            >
               <DropdownItem className="px-3">
                 <DropdownItemContents
                   title={t('copy_to_clipboard.Page path and permanent link')}
-                  contents={<>{pagePathWithParams}<br />{permalink}</>}
+                  contents={
+                    <>
+                      {pagePathWithParams}
+                      <br />
+                      {permalink}
+                    </>
+                  }
                   className="text-truncate d-block"
                 />
               </DropdownItem>
@@ -225,18 +251,25 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
           <DropdownItem divider className="my-0"></DropdownItem>
 
           {/* Markdown Link */}
-          { pageId && (
+          {pageId && (
             <CopyToClipboard text={markdownLink} onCopy={showToolTip}>
               <DropdownItem className="px-3 text-wrap">
-                <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={markdownLink} />
+                <DropdownItemContents
+                  title={t('copy_to_clipboard.Markdown link')}
+                  contents={markdownLink}
+                />
               </DropdownItem>
             </CopyToClipboard>
           )}
         </DropdownMenu>
-
       </Dropdown>
 
-      <Tooltip placement="bottom" isOpen={tooltipOpen} target={dropdownToggleId} fade={false}>
+      <Tooltip
+        placement="bottom"
+        isOpen={tooltipOpen}
+        target={dropdownToggleId}
+        fade={false}
+      >
         copied!
       </Tooltip>
     </>

+ 6 - 7
apps/app/src/client/components/Common/CountBadge.tsx

@@ -2,18 +2,17 @@ import type { FC } from 'react';
 import React from 'react';
 
 type CountProps = {
-  count?: number,
-  offset?: number,
-}
+  count?: number;
+  offset?: number;
+};
 
-const CountBadge: FC<CountProps> = (props:CountProps) => {
+const CountBadge: FC<CountProps> = (props: CountProps) => {
   const { count, offset = 0 } = props;
 
-
   return (
     <span className="grw-count-badge px-2 badge bg-body-tertiary text-body-tertiary">
-      { count == null && <span className="text-muted">―</span> }
-      { count != null && count + offset }
+      {count == null && <span className="text-muted">―</span>}
+      {count != null && count + offset}
     </span>
   );
 };

+ 7 - 6
apps/app/src/client/components/Common/CustomCopyToClipBoard.tsx

@@ -1,14 +1,13 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
 import { Tooltip } from 'reactstrap';
 
 type Props = {
-  message: string
-  textToBeCopied?: string
-}
+  message: string;
+  textToBeCopied?: string;
+};
 
 // To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
 const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
@@ -26,7 +25,9 @@ const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
     <>
       <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
         <div className="btn input-group-text" id="tooltipTarget">
-          <span className="material-symbols-outlined mx-1" aria-hidden="true">content_paste</span>
+          <span className="material-symbols-outlined mx-1" aria-hidden="true">
+            content_paste
+          </span>
         </div>
       </CopyToClipboard>
       <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>

+ 4 - 7
apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx

@@ -1,4 +1,4 @@
-import { type ReactNode, type JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 
 import { useDrawerOpened } from '~/states/ui/sidebar';
 
@@ -6,14 +6,12 @@ import styles from './DrawerToggler.module.scss';
 
 const moduleClass = styles['grw-drawer-toggler'];
 
-
 type Props = {
-  className?: string,
-  children?: ReactNode,
-}
+  className?: string;
+  children?: ReactNode;
+};
 
 export const DrawerToggler = (props: Props): JSX.Element => {
-
   const { className, children } = props;
 
   const [isOpened, setIsOpened] = useDrawerOpened();
@@ -31,5 +29,4 @@ export const DrawerToggler = (props: Props): JSX.Element => {
       </button>
     </div>
   );
-
 };

+ 17 - 11
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,13 +1,13 @@
-import { type IPageInfoForOperation, type IPageInfoForEmpty } from '@growi/core/dist/interfaces';
-import {
-  fireEvent, screen, within,
-} from '@testing-library/dom';
+import type {
+  IPageInfoForEmpty,
+  IPageInfoForOperation,
+} from '@growi/core/dist/interfaces';
+import { fireEvent, screen, within } from '@testing-library/dom';
 import { render } from '@testing-library/react';
 import { mock } from 'vitest-mock-extended';
 
 import { PageItemControl } from './PageItemControl';
 
-
 // mock for isIPageInfoForOperation and isIPageInfoForEmpty
 
 const mocks = vi.hoisted(() => ({
@@ -20,10 +20,9 @@ vi.mock('@growi/core/dist/interfaces', () => ({
   isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
 }));
 
-
 describe('PageItemControl.tsx', () => {
   describe('Should trigger onClickRenameMenuItem() when clicking the rename button', () => {
-    it('without fetching PageInfo by useSWRxPageInfo', async() => {
+    it('without fetching PageInfo by useSWRxPageInfo', async () => {
       // setup
       const pageInfo = mock<IPageInfoForOperation>();
 
@@ -47,7 +46,9 @@ describe('PageItemControl.tsx', () => {
       render(<PageItemControl {...props} />);
 
       // when
-      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      const button = within(
+        screen.getByTestId('open-page-item-control-btn'),
+      ).getByText(/more_vert/);
       fireEvent.click(button);
       const renameMenuItem = await screen.findByTestId('rename-page-btn');
       fireEvent.click(renameMenuItem);
@@ -56,7 +57,7 @@ describe('PageItemControl.tsx', () => {
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
     });
 
-    it('with empty page (IPageInfoForEmpty)', async() => {
+    it('with empty page (IPageInfoForEmpty)', async () => {
       // setup - Create an empty page mock with required properties
       const pageInfo: IPageInfoForEmpty = {
         emptyPageId: 'empty-page-id',
@@ -94,14 +95,19 @@ describe('PageItemControl.tsx', () => {
       render(<PageItemControl {...props} />);
 
       // when
-      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      const button = within(
+        screen.getByTestId('open-page-item-control-btn'),
+      ).getByText(/more_vert/);
       fireEvent.click(button);
       const renameMenuItem = await screen.findByTestId('rename-page-btn');
       fireEvent.click(renameMenuItem);
 
       // then
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
-      expect(onClickRenameMenuItemMock).toHaveBeenCalledWith('dummy-page-id', pageInfo);
+      expect(onClickRenameMenuItemMock).toHaveBeenCalledWith(
+        'dummy-page-id',
+        pageInfo,
+      );
     });
   });
 });

+ 373 - 283
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -1,14 +1,16 @@
-import React, {
-  useState, useCallback, useEffect, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import {
-  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
+  type IPageInfoExt,
+  isIPageInfoForEmpty,
+  isIPageInfoForOperation,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import {
-  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
@@ -19,7 +21,6 @@ import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
-
 export const MenuItemType = {
   BOOKMARK: 'bookmark',
   RENAME: 'rename',
@@ -29,276 +30,355 @@ export const MenuItemType = {
   PATH_RECOVERY: 'pathRecovery',
   SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
-export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
+export type MenuItemType = (typeof MenuItemType)[keyof typeof MenuItemType];
 
 export type ForceHideMenuItems = MenuItemType[];
 
 export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
 
 type CommonProps = {
-  pageInfo?: IPageInfoExt,
-  isEnableActions?: boolean,
-  isReadOnlyUser?: boolean,
-  forceHideMenuItems?: ForceHideMenuItems,
-
-  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
-  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
-  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
-
-  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  isInstantRename?: boolean,
-  alignEnd?: boolean,
-}
-
+  pageInfo?: IPageInfoExt;
+  isEnableActions?: boolean;
+  isReadOnlyUser?: boolean;
+  forceHideMenuItems?: ForceHideMenuItems;
+
+  onClickBookmarkMenuItem?: (
+    pageId: string,
+    newValue?: boolean,
+  ) => Promise<void>;
+  onClickRenameMenuItem?: (
+    pageId: string,
+    pageInfo: IPageInfoExt | undefined,
+  ) => Promise<void> | void;
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void;
+  onClickDeleteMenuItem?: (
+    pageId: string,
+    pageInfo: IPageInfoExt | undefined,
+  ) => Promise<void> | void;
+  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void;
+  onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void;
+
+  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
+  isInstantRename?: boolean;
+  alignEnd?: boolean;
+};
 
 type DropdownMenuProps = CommonProps & {
-  pageId: string,
-  isLoading?: boolean,
-  isDataUnavailable?: boolean,
-  operationProcessData?: IPageOperationProcessData,
-}
-
-const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
-  const { t } = useTranslation('');
-
-  const {
-    pageId, isLoading, isDataUnavailable, pageInfo, isEnableActions, isReadOnlyUser, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
-    onClickRevertMenuItem, onClickPathRecoveryMenuItem,
-    additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
-    additionalMenuItemRenderer: AdditionalMenuItems,
-    isInstantRename, alignEnd,
-  } = props;
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const bookmarkItemClickedHandler = useCallback(async() => {
-    if (onClickBookmarkMenuItem == null) return;
-
-    if (!isIPageInfoForEmpty(pageInfo) && !isIPageInfoForOperation(pageInfo)) {
-      return;
-    }
-
-    await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
-  }, [onClickBookmarkMenuItem, pageId, pageInfo]);
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const renameItemClickedHandler = useCallback(async() => {
-    if (onClickRenameMenuItem == null) return;
-
-    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isMovable) {
-      logger.warn('This page could not be renamed.');
-      return;
-    }
-
-    await onClickRenameMenuItem(pageId, pageInfo);
-  }, [onClickRenameMenuItem, pageId, pageInfo]);
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const duplicateItemClickedHandler = useCallback(async() => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-    await onClickDuplicateMenuItem(pageId);
-  }, [onClickDuplicateMenuItem, pageId]);
-
-  const revertItemClickedHandler = useCallback(async() => {
-    if (onClickRevertMenuItem == null) {
-      return;
-    }
-    await onClickRevertMenuItem(pageId);
-  }, [onClickRevertMenuItem, pageId]);
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const deleteItemClickedHandler = useCallback(async() => {
-    if (onClickDeleteMenuItem == null) return;
-
-    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isDeletable) {
-      logger.warn('This page could not be deleted.');
-      return;
-    }
-    await onClickDeleteMenuItem(pageId, pageInfo);
-  }, [onClickDeleteMenuItem, pageId, pageInfo]);
+  pageId: string;
+  isLoading?: boolean;
+  isDataUnavailable?: boolean;
+  operationProcessData?: IPageOperationProcessData;
+};
 
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const pathRecoveryItemClickedHandler = useCallback(async() => {
-    if (onClickPathRecoveryMenuItem == null) {
-      return;
+const PageItemControlDropdownMenu = React.memo(
+  (props: DropdownMenuProps): JSX.Element => {
+    const { t } = useTranslation('');
+
+    const {
+      pageId,
+      isLoading,
+      isDataUnavailable,
+      pageInfo,
+      isEnableActions,
+      isReadOnlyUser,
+      forceHideMenuItems,
+      operationProcessData,
+      onClickBookmarkMenuItem,
+      onClickRenameMenuItem,
+      onClickDuplicateMenuItem,
+      onClickDeleteMenuItem,
+      onClickRevertMenuItem,
+      onClickPathRecoveryMenuItem,
+      additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
+      additionalMenuItemRenderer: AdditionalMenuItems,
+      isInstantRename,
+      alignEnd,
+    } = props;
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const bookmarkItemClickedHandler = useCallback(async () => {
+      if (onClickBookmarkMenuItem == null) return;
+
+      if (
+        !isIPageInfoForEmpty(pageInfo) &&
+        !isIPageInfoForOperation(pageInfo)
+      ) {
+        return;
+      }
+
+      await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
+    }, [onClickBookmarkMenuItem, pageId, pageInfo]);
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const renameItemClickedHandler = useCallback(async () => {
+      if (onClickRenameMenuItem == null) return;
+
+      if (
+        !(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) ||
+        !pageInfo?.isMovable
+      ) {
+        logger.warn('This page could not be renamed.');
+        return;
+      }
+
+      await onClickRenameMenuItem(pageId, pageInfo);
+    }, [onClickRenameMenuItem, pageId, pageInfo]);
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const duplicateItemClickedHandler = useCallback(async () => {
+      if (onClickDuplicateMenuItem == null) {
+        return;
+      }
+      await onClickDuplicateMenuItem(pageId);
+    }, [onClickDuplicateMenuItem, pageId]);
+
+    const revertItemClickedHandler = useCallback(async () => {
+      if (onClickRevertMenuItem == null) {
+        return;
+      }
+      await onClickRevertMenuItem(pageId);
+    }, [onClickRevertMenuItem, pageId]);
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const deleteItemClickedHandler = useCallback(async () => {
+      if (onClickDeleteMenuItem == null) return;
+
+      if (
+        !(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) ||
+        !pageInfo?.isDeletable
+      ) {
+        logger.warn('This page could not be deleted.');
+        return;
+      }
+      await onClickDeleteMenuItem(pageId, pageInfo);
+    }, [onClickDeleteMenuItem, pageId, pageInfo]);
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const pathRecoveryItemClickedHandler = useCallback(async () => {
+      if (onClickPathRecoveryMenuItem == null) {
+        return;
+      }
+      await onClickPathRecoveryMenuItem(pageId);
+    }, [onClickPathRecoveryMenuItem, pageId]);
+
+    let contents = <></>;
+
+    if (isDataUnavailable) {
+      // Show message when data is not available (e.g., fetch error)
+      contents = (
+        <div className="text-warning text-center px-3">
+          <span className="material-symbols-outlined">error_outline</span> No
+          data available
+        </div>
+      );
+    } else if (isLoading) {
+      contents = (
+        <div className="text-muted text-center my-2">
+          <LoadingSpinner />
+        </div>
+      );
+    } else if (pageId != null && pageInfo != null) {
+      const showDeviderBeforeAdditionalMenuItems =
+        (forceHideMenuItems?.length ?? 0) < 3;
+      const showDeviderBeforeDelete =
+        AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
+
+      // PathRecovery
+      // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+      const shouldShowPathRecoveryButton =
+        operationProcessData != null
+          ? shouldRecoverPagePaths(operationProcessData)
+          : false;
+
+      contents = (
+        <>
+          {!isEnableActions && (
+            <DropdownItem>
+              <p>{t('search_result.currently_not_implemented')}</p>
+            </DropdownItem>
+          )}
+
+          {AdditionalMenuItemsOnTop && (
+            <>
+              <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
+              <DropdownItem divider />
+            </>
+          )}
+
+          {/* Bookmark */}
+          {!forceHideMenuItems?.includes(MenuItemType.BOOKMARK) &&
+            isEnableActions &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) && (
+              <DropdownItem
+                onClick={bookmarkItemClickedHandler}
+                className="grw-page-control-dropdown-item"
+                data-testid={
+                  pageInfo.isBookmarked
+                    ? 'remove-bookmark-btn'
+                    : 'add-bookmark-btn'
+                }
+              >
+                <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+                  bookmark
+                </span>
+                {pageInfo.isBookmarked
+                  ? t('remove_bookmark')
+                  : t('add_bookmark')}
+              </DropdownItem>
+            )}
+
+          {/* Move/Rename */}
+          {!forceHideMenuItems?.includes(MenuItemType.RENAME) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) &&
+            pageInfo.isMovable && (
+              <DropdownItem
+                onClick={renameItemClickedHandler}
+                data-testid="rename-page-btn"
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  redo
+                </span>
+                {t(isInstantRename ? 'Rename' : 'Move/Rename')}
+              </DropdownItem>
+            )}
+
+          {/* Duplicate */}
+          {!forceHideMenuItems?.includes(MenuItemType.DUPLICATE) &&
+            isEnableActions &&
+            !isReadOnlyUser && (
+              <DropdownItem
+                onClick={duplicateItemClickedHandler}
+                data-testid="open-page-duplicate-modal-btn"
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  file_copy
+                </span>
+                {t('Duplicate')}
+              </DropdownItem>
+            )}
+
+          {/* Revert */}
+          {!forceHideMenuItems?.includes(MenuItemType.REVERT) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) &&
+            pageInfo.isRevertible && (
+              <DropdownItem
+                onClick={revertItemClickedHandler}
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  undo
+                </span>
+                {t('modal_putback.label.Put Back Page')}
+              </DropdownItem>
+            )}
+
+          {AdditionalMenuItems && (
+            <>
+              {showDeviderBeforeAdditionalMenuItems && <DropdownItem divider />}
+              <AdditionalMenuItems pageInfo={pageInfo} />
+            </>
+          )}
+
+          {/* PathRecovery */}
+          {!forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            shouldShowPathRecoveryButton && (
+              <DropdownItem
+                onClick={pathRecoveryItemClickedHandler}
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  build
+                </span>
+                {t('PathRecovery')}
+              </DropdownItem>
+            )}
+
+          {/* divider */}
+          {/* Delete */}
+          {!forceHideMenuItems?.includes(MenuItemType.DELETE) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) &&
+            pageInfo.isDeletable && (
+              <>
+                {showDeviderBeforeDelete && <DropdownItem divider />}
+                <DropdownItem
+                  className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+                  disabled={!pageInfo.isDeletable}
+                  onClick={deleteItemClickedHandler}
+                  data-testid="open-page-delete-modal-btn"
+                >
+                  <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                    delete
+                  </span>
+                  {t('Delete')}
+                </DropdownItem>
+              </>
+            )}
+        </>
+      );
     }
-    await onClickPathRecoveryMenuItem(pageId);
-  }, [onClickPathRecoveryMenuItem, pageId]);
 
-  let contents = <></>;
-
-  if (isDataUnavailable) {
-    // Show message when data is not available (e.g., fetch error)
-    contents = (
-      <div className="text-warning text-center px-3">
-        <span className="material-symbols-outlined">error_outline</span> No data available
-      </div>
-    );
-  }
-  else if (isLoading) {
-    contents = (
-      <div className="text-muted text-center my-2">
-        <LoadingSpinner />
-      </div>
-    );
-  }
-  else if (pageId != null && pageInfo != null) {
-
-    const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
-    const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
-
-    // PathRecovery
-    // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-    const shouldShowPathRecoveryButton = operationProcessData != null ? shouldRecoverPagePaths(operationProcessData) : false;
-
-    contents = (
-      <>
-        { !isEnableActions && (
-          <DropdownItem>
-            <p>
-              {t('search_result.currently_not_implemented')}
-            </p>
-          </DropdownItem>
-        ) }
-
-        { AdditionalMenuItemsOnTop && (
-          <>
-            <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
-            <DropdownItem divider />
-          </>
-        ) }
-
-        {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) && (
-          <DropdownItem
-            onClick={bookmarkItemClickedHandler}
-            className="grw-page-control-dropdown-item"
-            data-testid={pageInfo.isBookmarked ? 'remove-bookmark-btn' : 'add-bookmark-btn'}
-          >
-            <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
-            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
-          </DropdownItem>
-        ) }
-
-        {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
-          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
-          && pageInfo.isMovable && (
-          <DropdownItem
-            onClick={renameItemClickedHandler}
-            data-testid="rename-page-btn"
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
-            {t(isInstantRename ? 'Rename' : 'Move/Rename')}
-          </DropdownItem>
-        ) }
-
-        {/* Duplicate */}
-        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && !isReadOnlyUser && (
-          <DropdownItem
-            onClick={duplicateItemClickedHandler}
-            data-testid="open-page-duplicate-modal-btn"
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">file_copy</span>
-            {t('Duplicate')}
-          </DropdownItem>
-        ) }
-
-        {/* Revert */}
-        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
-          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
-          && pageInfo.isRevertible && (
-          <DropdownItem
-            onClick={revertItemClickedHandler}
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">undo</span>
-            {t('modal_putback.label.Put Back Page')}
-          </DropdownItem>
-        ) }
-
-        { AdditionalMenuItems && (
-          <>
-            { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }
-            <AdditionalMenuItems pageInfo={pageInfo} />
-          </>
-        ) }
-
-        {/* PathRecovery */}
-        { !forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) && isEnableActions && !isReadOnlyUser && shouldShowPathRecoveryButton && (
-          <DropdownItem
-            onClick={pathRecoveryItemClickedHandler}
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">build</span>
-            {t('PathRecovery')}
-          </DropdownItem>
-        ) }
-
-        {/* divider */}
-        {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
-          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
-          && pageInfo.isDeletable && (
-          <>
-            { showDeviderBeforeDelete && <DropdownItem divider /> }
-            <DropdownItem
-              className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
-              disabled={!pageInfo.isDeletable}
-              onClick={deleteItemClickedHandler}
-              data-testid="open-page-delete-modal-btn"
-            >
-              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
-              {t('Delete')}
-            </DropdownItem>
-          </>
-        )}
-      </>
+    return (
+      <DropdownMenu
+        className="d-print-none"
+        data-testid="page-item-control-menu"
+        end={alignEnd}
+        container="body"
+        persist={!!alignEnd}
+        style={{
+          zIndex: 1055,
+        }} /* make it larger than $zindex-modal of bootstrap */
+      >
+        {contents}
+      </DropdownMenu>
     );
-  }
-
-  return (
-    <DropdownMenu
-      className="d-print-none"
-      data-testid="page-item-control-menu"
-      end={alignEnd}
-      container="body"
-      persist={!!alignEnd}
-      style={{ zIndex: 1055 }} /* make it larger than $zindex-modal of bootstrap */
-    >
-      {contents}
-    </DropdownMenu>
-  );
-});
+  },
+);
 
 PageItemControlDropdownMenu.displayName = 'PageItemControl';
 
-
 type PageItemControlSubstanceProps = CommonProps & {
-  pageId: string,
-  children?: React.ReactNode,
-  operationProcessData?: IPageOperationProcessData,
-}
-
-export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
+  pageId: string;
+  children?: React.ReactNode;
+  operationProcessData?: IPageOperationProcessData;
+};
 
+export const PageItemControlSubstance = (
+  props: PageItemControlSubstanceProps,
+): JSX.Element => {
   const {
-    pageId, pageInfo: presetPageInfo, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
-    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId,
+    pageInfo: presetPageInfo,
+    children,
+    onClickBookmarkMenuItem,
+    onClickRenameMenuItem,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+    onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
   const [shouldFetch, setShouldFetch] = useState(false);
 
-  const { data: fetchedPageInfo, error: fetchError, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const {
+    data: fetchedPageInfo,
+    error: fetchError,
+    mutate: mutatePageInfo,
+  } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
   // update shouldFetch (and will never be false)
   useEffect(() => {
@@ -311,42 +391,47 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   }, [isOpen, presetPageInfo, shouldFetch]);
 
   // mutate after handle event
-  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
-    if (onClickBookmarkMenuItem != null) {
-      await onClickBookmarkMenuItem(_pageId, _newValue);
-    }
-
-    if (shouldFetch) {
-      mutatePageInfo();
-    }
-  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
+  const bookmarkMenuItemClickHandler = useCallback(
+    async (_pageId: string, _newValue: boolean) => {
+      if (onClickBookmarkMenuItem != null) {
+        await onClickBookmarkMenuItem(_pageId, _newValue);
+      }
+
+      if (shouldFetch) {
+        mutatePageInfo();
+      }
+    },
+    [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch],
+  );
 
   // isLoading should be true only when fetching is in progress (data and error are both undefined)
-  const isLoading = shouldFetch && fetchedPageInfo == null && fetchError == null;
-  const isDataUnavailable = !isLoading && fetchedPageInfo == null && presetPageInfo == null;
+  const isLoading =
+    shouldFetch && fetchedPageInfo == null && fetchError == null;
+  const isDataUnavailable =
+    !isLoading && fetchedPageInfo == null && presetPageInfo == null;
 
-  const renameMenuItemClickHandler = useCallback(async() => {
+  const renameMenuItemClickHandler = useCallback(async () => {
     if (onClickRenameMenuItem == null) {
       return;
     }
     await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
-  const duplicateMenuItemClickHandler = useCallback(async() => {
+  const duplicateMenuItemClickHandler = useCallback(async () => {
     if (onClickDuplicateMenuItem == null) {
       return;
     }
     await onClickDuplicateMenuItem(pageId);
   }, [onClickDuplicateMenuItem, pageId]);
 
-  const deleteMenuItemClickHandler = useCallback(async() => {
+  const deleteMenuItemClickHandler = useCallback(async () => {
     if (onClickDeleteMenuItem == null) {
       return;
     }
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
-  const pathRecoveryMenuItemClickHandler = useCallback(async() => {
+  const pathRecoveryMenuItemClickHandler = useCallback(async () => {
     if (onClickPathRecoveryMenuItem == null) {
       return;
     }
@@ -355,14 +440,23 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 
   return (
     <NotAvailableForGuest>
-      <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} className="grw-page-item-control" data-testid="open-page-item-control-btn">
-        { children ?? (
-          <DropdownToggle role="button" color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+      <Dropdown
+        isOpen={isOpen}
+        toggle={() => setIsOpen(!isOpen)}
+        className="grw-page-item-control"
+        data-testid="open-page-item-control-btn"
+      >
+        {children ?? (
+          <DropdownToggle
+            role="button"
+            color="transparent"
+            className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center"
+          >
             <span className="material-symbols-outlined">more_vert</span>
           </DropdownToggle>
-        ) }
+        )}
 
-        { isOpen && (
+        {isOpen && (
           <PageItemControlDropdownMenu
             {...props}
             isLoading={isLoading}
@@ -374,21 +468,17 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
           />
-        ) }
+        )}
       </Dropdown>
-
     </NotAvailableForGuest>
-
   );
-
 };
 
-
 export type PageItemControlProps = CommonProps & {
-  pageId?: string,
-  children?: React.ReactNode,
-  operationProcessData?: IPageOperationProcessData,
-}
+  pageId?: string;
+  children?: React.ReactNode;
+  operationProcessData?: IPageOperationProcessData;
+};
 
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
   const { pageId } = props;

+ 138 - 87
apps/app/src/client/components/Common/ImageCropModal.tsx

@@ -1,16 +1,9 @@
 import type { FC } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
-
 import canvasToBlob from 'async-canvas-to-blob';
 import { useTranslation } from 'react-i18next';
 import ReactCrop from 'react-image-crop';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { toastError } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
@@ -19,28 +12,32 @@ import 'react-image-crop/dist/ReactCrop.css';
 const logger = loggerFactory('growi:ImageCropModal');
 
 interface ICropOptions {
-  aspect: number
-  unit: string,
-  x: number
-  y: number
-  width: number,
-  height: number,
+  aspect: number;
+  unit: string;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
 }
 
-type CropOptions = ICropOptions | null
+type CropOptions = ICropOptions | null;
 
 type Props = {
-  isShow: boolean,
-  src: string | ArrayBuffer | null,
-  onModalClose: () => void,
-  onImageProcessCompleted: (res: any) => void,
-  isCircular: boolean,
-  showCropOption: boolean
-}
+  isShow: boolean;
+  src: string | ArrayBuffer | null;
+  onModalClose: () => void;
+  onImageProcessCompleted: (res: any) => void;
+  isCircular: boolean;
+  showCropOption: boolean;
+};
 const ImageCropModal: FC<Props> = (props: Props) => {
-
   const {
-    isShow, src, onModalClose, onImageProcessCompleted, isCircular, showCropOption,
+    isShow,
+    src,
+    onModalClose,
+    onImageProcessCompleted,
+    isCircular,
+    showCropOption,
   } = props;
 
   const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
@@ -77,91 +74,133 @@ const ImageCropModal: FC<Props> = (props: Props) => {
   }, [reset]);
 
   // Memoize image processing functions
-  const onImageLoaded = useCallback((image) => {
-    setImageRef(image);
-    reset();
-    return false;
-  }, [reset]);
+  const onImageLoaded = useCallback(
+    (image) => {
+      setImageRef(image);
+      reset();
+      return false;
+    },
+    [reset],
+  );
 
-  const getCroppedImg = useCallback(async(image: HTMLImageElement, crop: ICropOptions) => {
-    const {
-      naturalWidth: imageNaturalWidth, naturalHeight: imageNaturalHeight, width: imageWidth, height: imageHeight,
-    } = image;
-
-    const {
-      width: cropWidth, height: cropHeight, x, y,
-    } = crop;
-
-    const canvas = document.createElement('canvas');
-    const scaleX = imageNaturalWidth / imageWidth;
-    const scaleY = imageNaturalHeight / imageHeight;
-    canvas.width = cropWidth;
-    canvas.height = cropHeight;
-    const ctx = canvas.getContext('2d');
-    ctx?.drawImage(image, x * scaleX, y * scaleY, cropWidth * scaleX, cropHeight * scaleY, 0, 0, cropWidth, cropHeight);
-    try {
-      const blob = await canvasToBlob(canvas);
-      return blob;
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to draw image'));
-    }
-  }, []);
+  const getCroppedImg = useCallback(
+    async (image: HTMLImageElement, crop: ICropOptions) => {
+      const {
+        naturalWidth: imageNaturalWidth,
+        naturalHeight: imageNaturalHeight,
+        width: imageWidth,
+        height: imageHeight,
+      } = image;
+
+      const { width: cropWidth, height: cropHeight, x, y } = crop;
+
+      const canvas = document.createElement('canvas');
+      const scaleX = imageNaturalWidth / imageWidth;
+      const scaleY = imageNaturalHeight / imageHeight;
+      canvas.width = cropWidth;
+      canvas.height = cropHeight;
+      const ctx = canvas.getContext('2d');
+      ctx?.drawImage(
+        image,
+        x * scaleX,
+        y * scaleY,
+        cropWidth * scaleX,
+        cropHeight * scaleY,
+        0,
+        0,
+        cropWidth,
+        cropHeight,
+      );
+      try {
+        const blob = await canvasToBlob(canvas);
+        return blob;
+      } catch (err) {
+        logger.error(err);
+        toastError(new Error('Failed to draw image'));
+      }
+    },
+    [],
+  );
 
   // Convert base64 Image to blob
-  const convertBase64ToBlob = useCallback(async(base64Image: string) => {
+  const convertBase64ToBlob = useCallback(async (base64Image: string) => {
     const base64Response = await fetch(base64Image);
     return base64Response.blob();
   }, []);
 
-
   // Memoize event handlers
-  const onModalCloseHandler = useCallback(async() => {
+  const onModalCloseHandler = useCallback(async () => {
     setImageRef(null);
     onModalClose();
   }, [onModalClose]);
 
-  const processAndSaveImage = useCallback(async() => {
+  const processAndSaveImage = useCallback(async () => {
     if (imageRef && cropOptions?.width && cropOptions.height) {
-      const processedImage = isCropImage ? await getCroppedImg(imageRef, cropOptions) : await convertBase64ToBlob(imageRef.src);
+      const processedImage = isCropImage
+        ? await getCroppedImg(imageRef, cropOptions)
+        : await convertBase64ToBlob(imageRef.src);
       // Save image to database
       onImageProcessCompleted(processedImage);
     }
     onModalCloseHandler();
-  }, [imageRef, cropOptions, isCropImage, getCroppedImg, convertBase64ToBlob, onImageProcessCompleted, onModalCloseHandler]);
-
-  const toggleCropMode = useCallback(() => setIsCropImage(!isCropImage), [isCropImage]);
-  const handleCropChange = useCallback((crop: CropOptions) => setCropOtions(crop), []);
+  }, [
+    imageRef,
+    cropOptions,
+    isCropImage,
+    getCroppedImg,
+    convertBase64ToBlob,
+    onImageProcessCompleted,
+    onModalCloseHandler,
+  ]);
+
+  const toggleCropMode = useCallback(
+    () => setIsCropImage(!isCropImage),
+    [isCropImage],
+  );
+  const handleCropChange = useCallback(
+    (crop: CropOptions) => setCropOtions(crop),
+    [],
+  );
 
   return (
     <Modal isOpen={isShow} toggle={onModalCloseHandler}>
       {isShow && (
         <>
-          <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
+          <ModalHeader
+            tag="h4"
+            toggle={onModalCloseHandler}
+            className="text-info"
+          >
             {t('crop_image_modal.image_crop')}
           </ModalHeader>
           <ModalBody className="my-4">
-            {
-              isCropImage
-                ? (
-                  <ReactCrop
-                    style={{ backgroundColor: 'transparent' }}
-                    src={src}
-                    crop={cropOptions}
-                    onImageLoaded={onImageLoaded}
-                    onChange={handleCropChange}
-                    circularCrop={isCircular}
-                  />
-                )
-                : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
-            }
+            {isCropImage ? (
+              <ReactCrop
+                style={{ backgroundColor: 'transparent' }}
+                src={src}
+                crop={cropOptions}
+                onImageLoaded={onImageLoaded}
+                onChange={handleCropChange}
+                circularCrop={isCircular}
+              />
+            ) : (
+              <img
+                style={{ maxWidth: imageRef?.width }}
+                src={imageRef?.src}
+                alt="Cropped preview"
+              />
+            )}
           </ModalBody>
           <ModalFooter>
-            <button type="button" className="btn btn-outline-danger rounded-pill me-auto" disabled={!isCropImage} onClick={reset}>
+            <button
+              type="button"
+              className="btn btn-outline-danger rounded-pill me-auto"
+              disabled={!isCropImage}
+              onClick={reset}
+            >
               {t('commons:Reset')}
             </button>
-            { !showCropOption && (
+            {!showCropOption && (
               <div className="me-auto">
                 <div className="form-check form-switch">
                   <input
@@ -171,18 +210,30 @@ const ImageCropModal: FC<Props> = (props: Props) => {
                     checked={isCropImage}
                     onChange={toggleCropMode}
                   />
-                  <label className="form-label form-check-label" htmlFor="cropImageOption">
-                    { t('crop_image_modal.image_crop') }
+                  <label
+                    className="form-label form-check-label"
+                    htmlFor="cropImageOption"
+                  >
+                    {t('crop_image_modal.image_crop')}
                   </label>
                 </div>
               </div>
-            )
-            }
-            <button type="button" className="btn btn-outline-secondary rounded-pill me-2" onClick={onModalCloseHandler}>
+            )}
+            <button
+              type="button"
+              className="btn btn-outline-secondary rounded-pill me-2"
+              onClick={onModalCloseHandler}
+            >
               {t('crop_image_modal.cancel')}
             </button>
-            <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
-              { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
+            <button
+              type="button"
+              className="btn btn-outline-primary rounded-pill"
+              onClick={processAndSaveImage}
+            >
+              {isCropImage
+                ? t('crop_image_modal.crop')
+                : t('crop_image_modal.save')}
             </button>
           </ModalFooter>
         </>

+ 6 - 8
apps/app/src/client/components/Common/LazyRenderer.tsx

@@ -1,18 +1,17 @@
-import React, { useEffect, useState, type JSX } from 'react';
+import React, { type JSX, useEffect, useState } from 'react';
 
 type Props = {
-  shouldRender: boolean | (() => boolean),
-  children: JSX.Element,
-}
+  shouldRender: boolean | (() => boolean);
+  children: JSX.Element;
+};
 
 export const LazyRenderer = (props: Props): JSX.Element => {
   const { shouldRender: _shouldRender, children } = props;
 
   const [isActivated, setActivated] = useState(false);
 
-  const shouldRender = typeof _shouldRender === 'function'
-    ? _shouldRender()
-    : _shouldRender;
+  const shouldRender =
+    typeof _shouldRender === 'function' ? _shouldRender() : _shouldRender;
 
   useEffect(() => {
     if (isActivated) {
@@ -28,5 +27,4 @@ export const LazyRenderer = (props: Props): JSX.Element => {
   const child = React.Children.only(children);
 
   return React.cloneElement(child, { visibility: shouldRender });
-
 };

+ 4 - 3
apps/app/src/client/components/Common/RendererErrorMessage.tsx

@@ -1,10 +1,11 @@
-import React from 'react';
+import type React from 'react';
 
 export const RendererErrorMessage: React.FC = () => {
   return (
     <p className="alert alert-warning">
-      ⚠️ <strong>Developer Warning:</strong>{' '}
-      Required renderer configuration is missing. Ensure <code>useRendererConfig()</code> is properly called in the component.
+      ⚠️ <strong>Developer Warning:</strong> Required renderer configuration is
+      missing. Ensure <code>useRendererConfig()</code> is properly called in the
+      component.
     </p>
   );
 };

+ 19 - 12
apps/app/src/client/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx

@@ -1,30 +1,37 @@
-import type {
-  ReactElement,
-} from 'react';
-
+import type { ReactElement } from 'react';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import AutosizeInput from 'react-input-autosize';
 
 import type { SubmittableInputProps } from './types';
 import { useSubmittable } from './use-submittable';
 
-
-export const getAdjustedMaxWidthForAutosizeInput = (parentMaxWidth: number, size: 'sm' | 'md' | 'lg' = 'md', isValid?: boolean): number => {
+export const getAdjustedMaxWidthForAutosizeInput = (
+  parentMaxWidth: number,
+  size: 'sm' | 'md' | 'lg' = 'md',
+  isValid?: boolean,
+): number => {
   // eslint-disable-next-line no-nested-ternary
   const bsFormPaddingSize = size === 'sm' ? 8 : size === 'md' ? 12 : 16; // by bootstrap form
   // eslint-disable-next-line no-nested-ternary
   const bsValidationIconSize = size === 'sm' ? 25 : size === 'md' ? 24 : 26; // by bootstrap form validation
 
-  return parentMaxWidth
-      - bsFormPaddingSize * 2 // minus the padding (12px * 2) because AutosizeInput has "box-sizing: content-box;"
-      - (isValid === false ? bsValidationIconSize : 0); // minus the width for the exclamation icon
+  return (
+    parentMaxWidth -
+    bsFormPaddingSize * 2 - // minus the padding (12px * 2) because AutosizeInput has "box-sizing: content-box;"
+    (isValid === false ? bsValidationIconSize : 0)
+  ); // minus the width for the exclamation icon
 };
 
-export const AutosizeSubmittableInput = (props: SubmittableInputProps<AutosizeInputProps>): ReactElement<AutosizeInput> => {
-
+export const AutosizeSubmittableInput = (
+  props: SubmittableInputProps<AutosizeInputProps>,
+): ReactElement<AutosizeInput> => {
   const submittableProps = useSubmittable(props);
 
   return (
-    <AutosizeInput {...submittableProps} type="text" data-testid="autosize-submittable-input" />
+    <AutosizeInput
+      {...submittableProps}
+      type="text"
+      data-testid="autosize-submittable-input"
+    />
   );
 };

+ 5 - 8
apps/app/src/client/components/Common/SubmittableInput/SubmittableInput.tsx

@@ -1,12 +1,11 @@
-import type {
-  ReactElement,
-} from 'react';
+import type { ReactElement } from 'react';
 
 import type { SubmittableInputProps } from './types';
 import { useSubmittable } from './use-submittable';
 
-
-export const SubmittableInput = (props: SubmittableInputProps): ReactElement<HTMLInputElement> => {
+export const SubmittableInput = (
+  props: SubmittableInputProps,
+): ReactElement<HTMLInputElement> => {
   // // autoFocus
   // useEffect(() => {
   //   if (inputRef?.current == null) {
@@ -17,7 +16,5 @@ export const SubmittableInput = (props: SubmittableInputProps): ReactElement<HTM
 
   const submittableProps = useSubmittable(props);
 
-  return (
-    <input {...submittableProps} />
-  );
+  return <input {...submittableProps} />;
 };

+ 1 - 1
apps/app/src/client/components/Common/SubmittableInput/index.ts

@@ -1,2 +1,2 @@
-export * from './SubmittableInput';
 export * from './AutosizeSubmittableInput';
+export * from './SubmittableInput';

+ 8 - 7
apps/app/src/client/components/Common/SubmittableInput/types.d.ts

@@ -1,7 +1,8 @@
-export type SubmittableInputProps<T extends InputHTMLAttributes<HTMLInputElement> = InputHTMLAttributes<HTMLInputElement>> =
-  Omit<InputHTMLAttributes<T>, 'value' | 'onKeyDown' | 'onSubmit'>
-  & {
-    value?: string,
-    onSubmit?: (inputText: string) => void,
-    onCancel?: () => void,
-  }
+export type SubmittableInputProps<
+  T extends
+    InputHTMLAttributes<HTMLInputElement> = InputHTMLAttributes<HTMLInputElement>,
+> = Omit<InputHTMLAttributes<T>, 'value' | 'onKeyDown' | 'onSubmit'> & {
+  value?: string;
+  onSubmit?: (inputText: string) => void;
+  onCancel?: () => void;
+};

+ 73 - 55
apps/app/src/client/components/Common/SubmittableInput/use-submittable.ts

@@ -1,77 +1,96 @@
-import type {
-  CompositionEvent,
-} from 'react';
 import type React from 'react';
-import {
-  useCallback, useState,
-} from 'react';
+import type { CompositionEvent } from 'react';
+import { useCallback, useState } from 'react';
 
 import type { SubmittableInputProps } from './types';
 
-export const useSubmittable = (props: SubmittableInputProps): Partial<React.InputHTMLAttributes<HTMLInputElement>> => {
-
+export const useSubmittable = (
+  props: SubmittableInputProps,
+): Partial<React.InputHTMLAttributes<HTMLInputElement>> => {
   const {
     value,
-    onChange, onBlur,
-    onCompositionStart, onCompositionEnd,
-    onSubmit, onCancel,
+    onChange,
+    onBlur,
+    onCompositionStart,
+    onCompositionEnd,
+    onSubmit,
+    onCancel,
   } = props;
 
   const [inputText, setInputText] = useState(value ?? '');
-  const [lastSubmittedInputText, setLastSubmittedInputText] = useState<string|undefined>(value ?? '');
+  const [lastSubmittedInputText, setLastSubmittedInputText] = useState<
+    string | undefined
+  >(value ?? '');
   const [isComposing, setComposing] = useState(false);
 
-  const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
-    const inputText = e.target.value;
-    setInputText(inputText);
+  const changeHandler = useCallback(
+    async (e: React.ChangeEvent<HTMLInputElement>) => {
+      const inputText = e.target.value;
+      setInputText(inputText);
 
-    onChange?.(e);
-  }, [onChange]);
+      onChange?.(e);
+    },
+    [onChange],
+  );
 
-  const keyDownHandler = useCallback((e) => {
-    switch (e.key) {
-      case 'Enter':
-        // Do nothing when composing
-        if (isComposing) {
-          return;
-        }
-        setLastSubmittedInputText(inputText);
-        onSubmit?.(inputText.trim());
-        break;
-      case 'Escape':
-        if (isComposing) {
-          return;
-        }
-        onCancel?.();
-        break;
-    }
-  }, [inputText, isComposing, onCancel, onSubmit]);
+  const keyDownHandler = useCallback(
+    (e) => {
+      switch (e.key) {
+        case 'Enter':
+          // Do nothing when composing
+          if (isComposing) {
+            return;
+          }
+          setLastSubmittedInputText(inputText);
+          onSubmit?.(inputText.trim());
+          break;
+        case 'Escape':
+          if (isComposing) {
+            return;
+          }
+          onCancel?.();
+          break;
+      }
+    },
+    [inputText, isComposing, onCancel, onSubmit],
+  );
 
-  const blurHandler = useCallback((e) => {
-    // suppress continuous calls to submit by blur event
-    if (lastSubmittedInputText === inputText) {
-      return;
-    }
+  const blurHandler = useCallback(
+    (e) => {
+      // suppress continuous calls to submit by blur event
+      if (lastSubmittedInputText === inputText) {
+        return;
+      }
 
-    // submit on blur
-    setLastSubmittedInputText(inputText);
-    onSubmit?.(inputText.trim());
-    onBlur?.(e);
-  }, [inputText, lastSubmittedInputText, onSubmit, onBlur]);
+      // submit on blur
+      setLastSubmittedInputText(inputText);
+      onSubmit?.(inputText.trim());
+      onBlur?.(e);
+    },
+    [inputText, lastSubmittedInputText, onSubmit, onBlur],
+  );
 
-  const compositionStartHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
-    setComposing(true);
-    onCompositionStart?.(e);
-  }, [onCompositionStart]);
+  const compositionStartHandler = useCallback(
+    (e: CompositionEvent<HTMLInputElement>) => {
+      setComposing(true);
+      onCompositionStart?.(e);
+    },
+    [onCompositionStart],
+  );
 
-  const compositionEndHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
-    setComposing(false);
-    onCompositionEnd?.(e);
-  }, [onCompositionEnd]);
+  const compositionEndHandler = useCallback(
+    (e: CompositionEvent<HTMLInputElement>) => {
+      setComposing(false);
+      onCompositionEnd?.(e);
+    },
+    [onCompositionEnd],
+  );
 
   const {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    value: _value, onSubmit: _onSubmit, onCancel: _onCancel,
+    value: _value,
+    onSubmit: _onSubmit,
+    onCancel: _onCancel,
     ...cleanedProps
   } = props;
 
@@ -84,5 +103,4 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
     onCompositionStart: compositionStartHandler,
     onCompositionEnd: compositionEndHandler,
   };
-
 };

+ 1 - 5
apps/app/src/client/components/Common/UserPictureList.jsx

@@ -1,19 +1,15 @@
 import React from 'react';
-
 import { UserPicture } from '@growi/ui/dist/components';
 import PropTypes from 'prop-types';
 
-
 export default class UserPictureList extends React.Component {
-
   render() {
-    return this.props.users.map(user => (
+    return this.props.users.map((user) => (
       <span key={user._id}>
         <UserPicture user={user} size="xs" />
       </span>
     ));
   }
-
 }
 
 UserPictureList.propTypes = {

+ 3 - 6
apps/app/src/client/components/Icons/FolderIcon.tsx

@@ -1,8 +1,8 @@
 import React, { type JSX } from 'react';
 
 type Props = {
-  isOpen: boolean
-}
+  isOpen: boolean;
+};
 export const FolderIcon = (props: Props): JSX.Element => {
   const { isOpen } = props;
 
@@ -10,12 +10,9 @@ export const FolderIcon = (props: Props): JSX.Element => {
     <>
       {!isOpen ? (
         <span className="material-symbols-outlined">folder</span>
-
       ) : (
         <span className="material-symbols-outlined">folder_open</span>
-      )
-      }
+      )}
     </>
   );
-
 };

+ 1 - 2
apps/app/src/client/components/Icons/RecentlyCreatedIcon.tsx

@@ -7,9 +7,8 @@ export const RecentlyCreatedIcon = (): JSX.Element => (
     height="20"
     viewBox="0 0 20 20"
   >
-
+    <title>Recently created</title>
     <g transform="translate(-921.906 192.966)">
-
       <rect
         width="20"
         height="20"

+ 1 - 5
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss

@@ -1,12 +1,8 @@
 @use '~/styles/mixins';
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
-.grw-contextual-sub-navigation {
+.grw-min-height-sub-navigation {
   min-height: 46px;
-
-  @include bs.media-breakpoint-up(lg) {
-    min-height: 46px;
-  }
 }
 
 @include mixins.at-editing() {

+ 79 - 72
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -73,6 +73,9 @@ import { Skeleton } from '../Skeleton';
 import styles from './GrowiContextualSubNavigation.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
+const moduleClass = styles['grw-contextual-sub-navigation'];
+const minHeightSubNavigation = styles['grw-min-height-sub-navigation'];
+
 const PageEditorModeManager = dynamic(
   () =>
     import('./PageEditorModeManager').then((mod) => mod.PageEditorModeManager),
@@ -456,90 +459,94 @@ const GrowiContextualSubNavigation = (
 
   return (
     <>
+      {/* for App Title for mobile */}
       <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
 
+      {/* for Sub Navigation */}
+      <GroundGlassBar
+        className={`position-fixed z-1 d-edit-none d-print-none w-100 end-0 ${minHeightSubNavigation}`}
+      />
+
       <Sticky
-        className="z-1"
+        className="z-3"
         enabled={!isPrinting}
         onStateChange={(status) =>
           setStickyActive(status.status === Sticky.STATUS_FIXED)
         }
         innerActiveClass="w-100 end-0"
       >
-        <GroundGlassBar>
-          <nav
-            className={`${styles['grw-contextual-sub-navigation']}
-              d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
-            `}
-            data-testid="grw-contextual-sub-nav"
-            id="grw-contextual-sub-nav"
-          >
-            <PageControls
-              pageId={pageId}
-              revisionId={revisionId}
-              shareLinkId={shareLinkId}
-              path={path ?? currentPathname} // If the page is empty, "path" is undefined
-              expandContentWidth={shouldExpandContent}
-              disableSeenUserInfoPopover={isSharedUser}
-              hideSubControls={hideSubControls}
-              showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={additionalMenuItemsRenderer}
-              onClickDuplicateMenuItem={duplicateItemClickedHandler}
-              onClickRenameMenuItem={renameItemClickedHandler}
-              onClickDeleteMenuItem={deleteItemClickedHandler}
-              onClickSwitchContentWidth={switchContentWidthHandler}
+        <nav
+          className={`${moduleClass} ${minHeightSubNavigation}
+            d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
+          `}
+          data-testid="grw-contextual-sub-nav"
+          id="grw-contextual-sub-nav"
+        >
+          <PageControls
+            pageId={pageId}
+            revisionId={revisionId}
+            shareLinkId={shareLinkId}
+            path={path ?? currentPathname} // If the page is empty, "path" is undefined
+            expandContentWidth={shouldExpandContent}
+            disableSeenUserInfoPopover={isSharedUser}
+            hideSubControls={hideSubControls}
+            showPageControlDropdown={isAbleToShowPageManagement}
+            additionalMenuItemRenderer={additionalMenuItemsRenderer}
+            onClickDuplicateMenuItem={duplicateItemClickedHandler}
+            onClickRenameMenuItem={renameItemClickedHandler}
+            onClickDeleteMenuItem={deleteItemClickedHandler}
+            onClickSwitchContentWidth={switchContentWidthHandler}
+          />
+
+          {isAbleToChangeEditorMode && (
+            <PageEditorModeManager
+              editorMode={editorMode}
+              isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
+              path={path}
             />
+          )}
 
-            {isAbleToChangeEditorMode && (
-              <PageEditorModeManager
-                editorMode={editorMode}
-                isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
-                path={path}
-              />
-            )}
-
-            {isGuestUser && (
-              <div className="mt-2">
-                <span>
-                  <span className="d-inline-block" id="sign-up-link">
-                    <Link
-                      href={
-                        !isLocalAccountRegistrationEnabled
-                          ? '#'
-                          : '/login#register'
-                      }
-                      className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
-                      style={{
-                        pointerEvents: !isLocalAccountRegistrationEnabled
-                          ? 'none'
-                          : undefined,
-                      }}
-                      prefetch={false}
-                    >
-                      <span className="material-symbols-outlined me-1">
-                        person_add
-                      </span>
-                      {t('Sign up')}
-                    </Link>
-                  </span>
-                  {!isLocalAccountRegistrationEnabled && (
-                    <UncontrolledTooltip target="sign-up-link" fade={false}>
-                      {t('tooltip.login_required')}
-                    </UncontrolledTooltip>
-                  )}
+          {isGuestUser && (
+            <div>
+              <span>
+                <span className="d-inline-block" id="sign-up-link">
+                  <Link
+                    href={
+                      !isLocalAccountRegistrationEnabled
+                        ? '#'
+                        : '/login#register'
+                    }
+                    className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
+                    style={{
+                      pointerEvents: !isLocalAccountRegistrationEnabled
+                        ? 'none'
+                        : undefined,
+                    }}
+                    prefetch={false}
+                  >
+                    <span className="material-symbols-outlined me-1">
+                      person_add
+                    </span>
+                    {t('Sign up')}
+                  </Link>
                 </span>
-                <Link
-                  href="/login#login"
-                  className="btn btn-primary"
-                  prefetch={false}
-                >
-                  <span className="material-symbols-outlined me-1">login</span>
-                  {t('Sign in')}
-                </Link>
-              </div>
-            )}
-          </nav>
-        </GroundGlassBar>
+                {!isLocalAccountRegistrationEnabled && (
+                  <UncontrolledTooltip target="sign-up-link" fade={false}>
+                    {t('tooltip.login_required')}
+                  </UncontrolledTooltip>
+                )}
+              </span>
+              <Link
+                href="/login#login"
+                className="btn btn-primary"
+                prefetch={false}
+              >
+                <span className="material-symbols-outlined me-1">login</span>
+                {t('Sign in')}
+              </Link>
+            </div>
+          )}
+        </nav>
       </Sticky>
 
       {path != null && currentUser != null && !isReadOnlyUser && (

+ 106 - 45
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -1,18 +1,21 @@
-import React, {
-  useMemo, useCallback, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
+import dynamic from 'next/dynamic';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import {
-  Modal, ModalBody, ModalHeader,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import { disableLinkSharingAtom } from '~/states/server-configurations';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
-import { usePageAccessoriesModalStatus, usePageAccessoriesModalActions, PageAccessoriesModalContents } from '~/states/ui/modal/page-accessories';
+import {
+  PageAccessoriesModalContents,
+  usePageAccessoriesModalActions,
+  usePageAccessoriesModalStatus,
+} from '~/states/ui/modal/page-accessories';
 
 import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
@@ -20,18 +23,51 @@ import ExpandOrContractButton from '../ExpandOrContractButton';
 
 import styles from './PageAccessoriesModal.module.scss';
 
+const PageAttachment = dynamic(() => import('./PageAttachment'), {
+  ssr: false,
+});
+const PageHistory = dynamic(
+  () => import('./PageHistory').then((mod) => mod.PageHistory),
+  { ssr: false },
+);
+const ShareLink = dynamic(
+  () => import('./ShareLink').then((mod) => mod.ShareLink),
+  { ssr: false },
+);
+
+const PageHistoryIcon = (): JSX.Element => (
+  <span className="material-symbols-outlined">history</span>
+);
+const PageAttachmentIcon = (): JSX.Element => (
+  <span className="material-symbols-outlined">attachment</span>
+);
+const ShareLinkIcon = (): JSX.Element => (
+  <span className="material-symbols-outlined">share</span>
+);
+
+const PageHistoryContent = (): JSX.Element => {
+  const { close } = usePageAccessoriesModalActions();
 
-const PageAttachment = dynamic(() => import('./PageAttachment'), { ssr: false });
-const PageHistory = dynamic(() => import('./PageHistory').then(mod => mod.PageHistory), { ssr: false });
-const ShareLink = dynamic(() => import('./ShareLink').then(mod => mod.ShareLink), { ssr: false });
+  return <PageHistory onClose={close} />;
+};
+
+const PageAttachmentContent = (): JSX.Element => {
+  return <PageAttachment />;
+};
+
+const ShareLinkContent = (): JSX.Element => {
+  return <ShareLink />;
+};
 
 interface PageAccessoriesModalSubstanceProps {
   isWindowExpanded: boolean;
   setIsWindowExpanded: (expanded: boolean) => void;
 }
 
-const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }: PageAccessoriesModalSubstanceProps): JSX.Element => {
-
+const PageAccessoriesModalSubstance = ({
+  isWindowExpanded,
+  setIsWindowExpanded,
+}: PageAccessoriesModalSubstanceProps): JSX.Element => {
   const { t } = useTranslation();
 
   const isSharedUser = useIsSharedUser();
@@ -47,45 +83,57 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
   const navTabMapping = useMemo(() => {
     return {
       [PageAccessoriesModalContents.PageHistory]: {
-        Icon: () => <span className="material-symbols-outlined">history</span>,
-        Content: () => {
-          return <PageHistory onClose={close} />;
-        },
+        Icon: PageHistoryIcon,
+        Content: PageHistoryContent,
         i18n: t('History'),
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
       },
       [PageAccessoriesModalContents.Attachment]: {
-        Icon: () => <span className="material-symbols-outlined">attachment</span>,
-        Content: () => {
-          return <PageAttachment />;
-        },
+        Icon: PageAttachmentIcon,
+        Content: PageAttachmentContent,
         i18n: t('attachment_data'),
       },
       [PageAccessoriesModalContents.ShareLink]: {
-        Icon: () => <span className="material-symbols-outlined">share</span>,
-        Content: () => {
-          return <ShareLink />;
-        },
+        Icon: ShareLinkIcon,
+        Content: ShareLinkContent,
         i18n: t('share_links.share_link_management'),
-        isLinkEnabled: () => !isGuestUser && !isReadOnlyUser && !isSharedUser && !isLinkSharingDisabled,
+        isLinkEnabled: () =>
+          !isGuestUser &&
+          !isReadOnlyUser &&
+          !isSharedUser &&
+          !isLinkSharingDisabled,
       },
     };
-  }, [t, close, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
 
   // Memoize expand/contract handlers
-  const expandWindow = useCallback(() => setIsWindowExpanded(true), [setIsWindowExpanded]);
-  const contractWindow = useCallback(() => setIsWindowExpanded(false), [setIsWindowExpanded]);
-
-  const buttons = useMemo(() => (
-    <span className="me-3">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="btn btn-close ms-2" onClick={close} aria-label="Close"></button>
-    </span>
-  ), [close, isWindowExpanded, expandWindow, contractWindow]);
+  const expandWindow = useCallback(
+    () => setIsWindowExpanded(true),
+    [setIsWindowExpanded],
+  );
+  const contractWindow = useCallback(
+    () => setIsWindowExpanded(false),
+    [setIsWindowExpanded],
+  );
+
+  const buttons = useMemo(
+    () => (
+      <span className="me-3">
+        <ExpandOrContractButton
+          isWindowExpanded={isWindowExpanded}
+          expandWindow={expandWindow}
+          contractWindow={contractWindow}
+        />
+        <button
+          type="button"
+          className="btn btn-close ms-2"
+          onClick={close}
+          aria-label="Close"
+        ></button>
+      </span>
+    ),
+    [close, isWindowExpanded, expandWindow, contractWindow],
+  );
 
   if (status == null || status.activatedContents == null) {
     return <></>;
@@ -93,7 +141,11 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
 
   return (
     <>
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
+      <ModalHeader
+        className={isDeviceLargerThanLg ? 'p-0' : ''}
+        toggle={close}
+        close={buttons}
+      >
         {isDeviceLargerThanLg && (
           <CustomNavTab
             activeTab={status.activatedContents}
@@ -115,7 +167,11 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
         <CustomTabContent
           activeTab={status.activatedContents}
           navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+          additionalClassNames={
+            !isDeviceLargerThanLg
+              ? ['grw-tab-content-style-md-down']
+              : undefined
+          }
         />
       </ModalBody>
     </>
@@ -139,7 +195,12 @@ export const PageAccessoriesModal = (): JSX.Element => {
       data-testid="page-accessories-modal"
       className={`grw-page-accessories-modal ${styles['grw-page-accessories-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
-      {status.isOpened && <PageAccessoriesModalSubstance isWindowExpanded={isWindowExpanded} setIsWindowExpanded={setIsWindowExpanded} />}
+      {status.isOpened && (
+        <PageAccessoriesModalSubstance
+          isWindowExpanded={isWindowExpanded}
+          setIsWindowExpanded={setIsWindowExpanded}
+        />
+      )}
     </Modal>
   );
 };

+ 30 - 21
apps/app/src/client/components/PageAccessoriesModal/PageAttachment.tsx

@@ -1,7 +1,4 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
 import type { IAttachmentHasId } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
@@ -19,7 +16,6 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 };
 
 const PageAttachment = (): JSX.Element => {
-
   const pageId = useCurrentPageId();
   const currentPage = useCurrentPageData();
 
@@ -32,32 +28,40 @@ const PageAttachment = (): JSX.Element => {
   const [pageNumber, setPageNumber] = useState(1);
 
   // SWRs
-  const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const { data: dataAttachments, remove } = useSWRxAttachments(
+    pageId,
+    pageNumber,
+  );
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModalActions();
   const markdown = currentPage?.revision?.body;
 
   // Custom hooks
-  const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
-    if (markdown == null || dataAttachments == null) {
-      return undefined;
-    }
-
-    const attachmentEntries = dataAttachments.attachments
-      .map((attachment) => {
-        return [attachment._id, checkIfFileInUse(markdown, attachment)];
-      });
+  const inUseAttachmentsMap: { [id: string]: boolean } | undefined =
+    useMemo(() => {
+      if (markdown == null || dataAttachments == null) {
+        return undefined;
+      }
+
+      const attachmentEntries = dataAttachments.attachments.map(
+        (attachment) => {
+          return [attachment._id, checkIfFileInUse(markdown, attachment)];
+        },
+      );
 
-    return Object.fromEntries(attachmentEntries);
-  }, [dataAttachments, markdown]);
+      return Object.fromEntries(attachmentEntries);
+    }, [dataAttachments, markdown]);
 
   // Methods
   const onChangePageHandler = useCallback((newPageNumber: number) => {
     setPageNumber(newPageNumber);
   }, []);
 
-  const onAttachmentDeleteClicked = useCallback((attachment: IAttachmentHasId) => {
-    openDeleteAttachmentModal(attachment, remove);
-  }, [openDeleteAttachmentModal, remove]);
+  const onAttachmentDeleteClicked = useCallback(
+    (attachment: IAttachmentHasId) => {
+      openDeleteAttachmentModal(attachment, remove);
+    },
+    [openDeleteAttachmentModal, remove],
+  );
 
   // Renderers
   const renderPageAttachmentList = useCallback(() => {
@@ -77,7 +81,12 @@ const PageAttachment = (): JSX.Element => {
         isUserLoggedIn={!isPageAttachmentDisabled}
       />
     );
-  }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
+  }, [
+    dataAttachments,
+    inUseAttachmentsMap,
+    isPageAttachmentDisabled,
+    onAttachmentDeleteClicked,
+  ]);
 
   const renderPaginationWrapper = useCallback(() => {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {

+ 7 - 6
apps/app/src/client/components/PageAccessoriesModal/PageHistory.tsx

@@ -1,19 +1,20 @@
-import React from 'react';
+import type React from 'react';
 
-import { useCurrentPagePath, useCurrentPageId } from '~/states/page';
+import { useCurrentPageId, useCurrentPagePath } from '~/states/page';
 import loggerFactory from '~/utils/logger';
 
 import { PageRevisionTable } from '../PageHistory/PageRevisionTable';
-
 import { useAutoComparingRevisionsByQueryParam } from './hooks';
 
 const logger = loggerFactory('growi:PageHistory');
 
 type PageHistoryProps = {
-  onClose: () => void
-}
+  onClose: () => void;
+};
 
-export const PageHistory: React.FC<PageHistoryProps> = (props: PageHistoryProps) => {
+export const PageHistory: React.FC<PageHistoryProps> = (
+  props: PageHistoryProps,
+) => {
   const { onClose } = props;
 
   const currentPageId = useCurrentPageId();

+ 39 - 23
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLink.tsx

@@ -1,9 +1,8 @@
-import React, { useState, useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Delete } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentPageId } from '~/states/page';
 import { useSWRxSharelink } from '~/stores/share-link';
 
@@ -12,46 +11,61 @@ import ShareLinkList from './ShareLinkList';
 
 export const ShareLink = (): JSX.Element => {
   const { t } = useTranslation();
-  const [isOpenShareLinkForm, setIsOpenShareLinkForm] = useState<boolean>(false);
+  const [isOpenShareLinkForm, setIsOpenShareLinkForm] =
+    useState<boolean>(false);
 
   const currentPageId = useCurrentPageId();
 
   const { data: currentShareLinks, mutate } = useSWRxSharelink(currentPageId);
 
   const toggleShareLinkFormHandler = useCallback(() => {
-    setIsOpenShareLinkForm(prev => !prev);
+    setIsOpenShareLinkForm((prev) => !prev);
     mutate();
   }, [mutate]);
 
-  const deleteAllLinksButtonHandler = useCallback(async() => {
+  const deleteAllLinksButtonHandler = useCallback(async () => {
     try {
-      const res = await apiv3Delete('/share-links/', { relatedPage: currentPageId });
+      const res = await apiv3Delete('/share-links/', {
+        relatedPage: currentPageId,
+      });
       const count = res.data.n;
       toastSuccess(t('toaster.remove_share_link', { count, ns: 'commons' }));
       mutate();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [mutate, currentPageId, t]);
 
-  const deleteLinkById = useCallback(async(shareLinkId) => {
-    try {
-      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
-      const { deletedShareLink } = res.data;
-      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id, ns: 'commons' }));
-      mutate();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [mutate, t]);
+  const deleteLinkById = useCallback(
+    async (shareLinkId) => {
+      try {
+        const res = await apiv3Delete(`/share-links/${shareLinkId}`);
+        const { deletedShareLink } = res.data;
+        toastSuccess(
+          t('toaster.remove_share_link_success', {
+            shareLinkId: deletedShareLink._id,
+            ns: 'commons',
+          }),
+        );
+        mutate();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [mutate, t],
+  );
 
   return (
     <div className="container p-0" data-testid="share-link-management">
       <h3 className="d-flex pb-2">
-        { t('share_links.share_link_list') }
-        <button className="btn btn-danger ms-auto " type="button" onClick={deleteAllLinksButtonHandler}>{t('delete_all')}</button>
+        {t('share_links.share_link_list')}
+        <button
+          className="btn btn-danger ms-auto "
+          type="button"
+          onClick={deleteAllLinksButtonHandler}
+        >
+          {t('delete_all')}
+        </button>
       </h3>
       <div>
         <ShareLinkList
@@ -66,7 +80,9 @@ export const ShareLink = (): JSX.Element => {
         >
           {isOpenShareLinkForm ? t('Close') : t('New')}
         </button>
-        {isOpenShareLinkForm && <ShareLinkForm onCloseForm={toggleShareLinkFormHandler} />}
+        {isOpenShareLinkForm && (
+          <ShareLinkForm onCloseForm={toggleShareLinkFormHandler} />
+        )}
       </div>
     </div>
   );

+ 115 - 57
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx

@@ -1,43 +1,48 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
-import {
-  format, parse, addDays, set,
-} from 'date-fns';
+import React, { useCallback, useState } from 'react';
+import { addDays, format, parse, set } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentPageId } from '~/states/page';
 
-
 const ExpirationType = {
   UNLIMITED: 'unlimited',
   CUSTOM: 'custom',
   NUMBER_OF_DAYS: 'numberOfDays',
 } as const;
 
-type ExpirationType = typeof ExpirationType[keyof typeof ExpirationType];
+type ExpirationType = (typeof ExpirationType)[keyof typeof ExpirationType];
 
 type Props = {
-  onCloseForm: () => void,
-}
+  onCloseForm: () => void;
+};
 
 export const ShareLinkForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { onCloseForm } = props;
 
-  const [expirationType, setExpirationType] = useState<ExpirationType>(ExpirationType.UNLIMITED);
+  const [expirationType, setExpirationType] = useState<ExpirationType>(
+    ExpirationType.UNLIMITED,
+  );
   const [numberOfDays, setNumberOfDays] = useState<number>(7);
   const [description, setDescription] = useState<string>('');
-  const [customExpirationDate, setCustomExpirationDate] = useState<Date>(new Date());
-  const [customExpirationTime, setCustomExpirationTime] = useState<Date>(new Date());
+  const [customExpirationDate, setCustomExpirationDate] = useState<Date>(
+    new Date(),
+  );
+  const [customExpirationTime, setCustomExpirationTime] = useState<Date>(
+    new Date(),
+  );
 
   const currentPageId = useCurrentPageId();
 
-  const handleChangeExpirationType = useCallback((expirationType: ExpirationType) => {
-    setExpirationType(expirationType);
-  }, []);
+  const handleChangeExpirationType = useCallback(
+    (expirationType: ExpirationType) => {
+      setExpirationType(expirationType);
+    },
+    [],
+  );
 
   const handleChangeNumberOfDays = useCallback((numberOfDays: number) => {
     setNumberOfDays(numberOfDays);
@@ -47,21 +52,27 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
     setDescription(description);
   }, []);
 
-  const handleChangeCustomExpirationDate = useCallback((customExpirationDate: string) => {
-    // set customExpirationDate to today if the input is empty
-    if (customExpirationDate.length === 0) {
-      setCustomExpirationDate(new Date());
-      return;
-    }
+  const handleChangeCustomExpirationDate = useCallback(
+    (customExpirationDate: string) => {
+      // set customExpirationDate to today if the input is empty
+      if (customExpirationDate.length === 0) {
+        setCustomExpirationDate(new Date());
+        return;
+      }
 
-    const parsedDate = parse(customExpirationDate, 'yyyy-MM-dd', new Date());
-    setCustomExpirationDate(parsedDate);
-  }, []);
+      const parsedDate = parse(customExpirationDate, 'yyyy-MM-dd', new Date());
+      setCustomExpirationDate(parsedDate);
+    },
+    [],
+  );
 
-  const handleChangeCustomExpirationTime = useCallback((customExpirationTime: string) => {
-    const parsedTime = parse(customExpirationTime, 'HH:mm', new Date());
-    setCustomExpirationTime(parsedTime);
-  }, []);
+  const handleChangeCustomExpirationTime = useCallback(
+    (customExpirationTime: string) => {
+      const parsedTime = parse(customExpirationTime, 'HH:mm', new Date());
+      setCustomExpirationTime(parsedTime);
+    },
+    [],
+  );
 
   const generateExpired = useCallback(() => {
     if (expirationType === ExpirationType.UNLIMITED) {
@@ -76,9 +87,18 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
     }
 
     if (expirationType === ExpirationType.CUSTOM) {
-      return set(customExpirationDate, { hours: customExpirationTime.getHours(), minutes: customExpirationTime.getMinutes() });
+      return set(customExpirationDate, {
+        hours: customExpirationTime.getHours(),
+        minutes: customExpirationTime.getMinutes(),
+      });
     }
-  }, [t, customExpirationTime, customExpirationDate, expirationType, numberOfDays]);
+  }, [
+    t,
+    customExpirationTime,
+    customExpirationDate,
+    expirationType,
+    numberOfDays,
+  ]);
 
   const closeForm = useCallback(() => {
     if (onCloseForm == null) {
@@ -87,36 +107,38 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
     onCloseForm();
   }, [onCloseForm]);
 
-  const handleIssueShareLink = useCallback(async() => {
-    let expiredAt;
+  const handleIssueShareLink = useCallback(async () => {
+    let expiredAt: Date | null | undefined;
 
     try {
       expiredAt = generateExpired();
-    }
-    catch (err) {
+    } catch (err) {
       return toastError(err);
     }
 
     try {
-      await apiv3Post('/share-links/', { relatedPage: currentPageId, expiredAt, description });
+      await apiv3Post('/share-links/', {
+        relatedPage: currentPageId,
+        expiredAt,
+        description,
+      });
       closeForm();
       toastSuccess(t('toaster.issue_share_link'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [t, currentPageId, description, closeForm, generateExpired]);
 
   return (
     <div className="share-link-form p-3">
-      <h3 className="pb-2"> { t('share_links.share_settings') }</h3>
+      <h3 className="pb-2"> {t('share_links.share_settings')}</h3>
       <div className=" p-3">
-
         {/* ExpirationTypeOptions */}
         <div className="row">
-          <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.expire')}</label>
+          <label htmlFor="inputDesc" className="col-md-5 col-form-label">
+            {t('share_links.expire')}
+          </label>
           <div className="col-md-7">
-
             <div className="form-check">
               <input
                 type="radio"
@@ -125,9 +147,16 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                 name="expirationType"
                 value="customRadio1"
                 checked={expirationType === ExpirationType.UNLIMITED}
-                onChange={() => { handleChangeExpirationType(ExpirationType.UNLIMITED) }}
+                onChange={() => {
+                  handleChangeExpirationType(ExpirationType.UNLIMITED);
+                }}
               />
-              <label className="form-label form-check-label" htmlFor="customRadio1">{t('share_links.Unlimited')}</label>
+              <label
+                className="form-label form-check-label"
+                htmlFor="customRadio1"
+              >
+                {t('share_links.Unlimited')}
+              </label>
             </div>
 
             <div className="form-check">
@@ -137,10 +166,15 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                 id="customRadio2"
                 value="customRadio2"
                 checked={expirationType === ExpirationType.NUMBER_OF_DAYS}
-                onChange={() => { handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS) }}
+                onChange={() => {
+                  handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS);
+                }}
                 name="expirationType"
               />
-              <label className="form-label form-check-label" htmlFor="customRadio2">
+              <label
+                className="form-label form-check-label"
+                htmlFor="customRadio2"
+              >
                 <div className="row align-items-center m-0">
                   <input
                     type="number"
@@ -148,8 +182,12 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                     className="col-4"
                     name="expirationType"
                     value={numberOfDays}
-                    onFocus={() => { handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS) }}
-                    onChange={e => handleChangeNumberOfDays(Number(e.target.value))}
+                    onFocus={() => {
+                      handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS);
+                    }}
+                    onChange={(e) =>
+                      handleChangeNumberOfDays(Number(e.target.value))
+                    }
                   />
                   <span className="col-auto">{t('share_links.Days')}</span>
                 </div>
@@ -164,9 +202,14 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                 name="expirationType"
                 value="customRadio3"
                 checked={expirationType === ExpirationType.CUSTOM}
-                onChange={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
+                onChange={() => {
+                  handleChangeExpirationType(ExpirationType.CUSTOM);
+                }}
               />
-              <label className="form-label form-check-label" htmlFor="customRadio3">
+              <label
+                className="form-label form-check-label"
+                htmlFor="customRadio3"
+              >
                 {t('share_links.Custom')}
               </label>
               <div className="d-inline-flex flex-wrap">
@@ -175,16 +218,24 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                   className="ms-3 mb-2"
                   name="customExpirationDate"
                   value={format(customExpirationDate, 'yyyy-MM-dd')}
-                  onFocus={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
-                  onChange={e => handleChangeCustomExpirationDate(e.target.value)}
+                  onFocus={() => {
+                    handleChangeExpirationType(ExpirationType.CUSTOM);
+                  }}
+                  onChange={(e) =>
+                    handleChangeCustomExpirationDate(e.target.value)
+                  }
                 />
                 <input
                   type="time"
                   className="ms-3 mb-2"
                   name="customExpiration"
                   value={format(customExpirationTime, 'HH:mm')}
-                  onFocus={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
-                  onChange={e => handleChangeCustomExpirationTime(e.target.value)}
+                  onFocus={() => {
+                    handleChangeExpirationType(ExpirationType.CUSTOM);
+                  }}
+                  onChange={(e) =>
+                    handleChangeCustomExpirationTime(e.target.value)
+                  }
                 />
               </div>
             </div>
@@ -193,7 +244,9 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
 
         {/* DescriptionForm */}
         <div className="row">
-          <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.description')}</label>
+          <label htmlFor="inputDesc" className="col-md-5 col-form-label">
+            {t('share_links.description')}
+          </label>
           <div className="col-md-4">
             <input
               type="text"
@@ -201,14 +254,19 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
               id="inputDesc"
               placeholder={t('share_links.enter_desc')}
               value={description}
-              onChange={e => handleChangeDescription(e.target.value)}
+              onChange={(e) => handleChangeDescription(e.target.value)}
             />
           </div>
         </div>
 
         <div className="row mt-4">
           <div className="col">
-            <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={handleIssueShareLink} data-testid="btn-sharelink-issue">
+            <button
+              type="button"
+              className="btn btn-primary d-block mx-auto px-5"
+              onClick={handleIssueShareLink}
+              data-testid="btn-sharelink-issue"
+            >
               {t('share_links.Issue')}
             </button>
           </div>

+ 41 - 33
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -1,17 +1,15 @@
 import React, { type JSX } from 'react';
-
 import { format as dateFnsFormat } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 
 import { CopyDropdown } from '../../Common/CopyDropdown';
 
-
 type ShareLinkTrProps = {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  shareLink: any,
-  isAdmin?: boolean,
-  onDelete?: () => void,
-}
+  shareLink: any;
+  isAdmin?: boolean;
+  onDelete?: () => void;
+};
 
 const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
   const { t } = useTranslation();
@@ -27,7 +25,7 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
       <td className="d-flex justify-content-between align-items-center">
         <span data-testid="share-link">{shareLinkId}</span>
 
-        { isRelatedPageExists && (
+        {isRelatedPageExists && (
           <CopyDropdown
             pagePath={relatedPage.path}
             dropdownToggleId={`copydropdown-for-share-link-list-${shareLinkId}`}
@@ -36,47 +34,53 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
           >
             Copy Link
           </CopyDropdown>
-        ) }
+        )}
       </td>
-      { isAdmin && (
+      {isAdmin && (
         <td>
-          { isRelatedPageExists
-            ? <a href={relatedPage.path}>{relatedPage.path}</a>
-            : '(Page is not found)'
-          }
+          {isRelatedPageExists ? (
+            <a href={relatedPage.path}>{relatedPage.path}</a>
+          ) : (
+            '(Page is not found)'
+          )}
         </td>
-      ) }
-      <td style={{ verticalAlign: 'middle' }}>
-        {shareLink.description}
-      </td>
+      )}
+      <td style={{ verticalAlign: 'middle' }}>{shareLink.description}</td>
       <td style={{ verticalAlign: 'middle' }}>
-        {shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}
+        {shareLink.expiredAt && (
+          <span>
+            {dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}
+          </span>
+        )}
       </td>
       <td style={{ maxWidth: '50', textAlign: 'center' }}>
-        <button className="btn btn-outline-danger" type="button" onClick={onDelete}>
-          <span className="material-symbols-outlined">delete</span>{t('Delete')}
+        <button
+          className="btn btn-outline-danger"
+          type="button"
+          onClick={onDelete}
+        >
+          <span className="material-symbols-outlined">delete</span>
+          {t('Delete')}
         </button>
       </td>
     </tr>
   );
 };
 
-
 type Props = {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  shareLinks: any[],
-  onClickDeleteButton?: (shareLinkId: string) => void,
-  isAdmin?: boolean,
-}
+  shareLinks: any[];
+  onClickDeleteButton?: (shareLinkId: string) => void;
+  isAdmin?: boolean;
+};
 
 const ShareLinkList = (props: Props): JSX.Element => {
-
   const { t } = useTranslation('commons');
 
   function renderShareLinks() {
     return (
       <>
-        {props.shareLinks.map(shareLink => (
+        {props.shareLinks.map((shareLink) => (
           <ShareLinkTr
             key={shareLink._id}
             isAdmin={props.isAdmin}
@@ -98,16 +102,20 @@ const ShareLinkList = (props: Props): JSX.Element => {
       <table className="table table-bordered">
         <thead>
           <tr>
-            <th style={{ width: '350px' }}>{t('share_links.Share Link', { ns: 'commons' })}</th>
-            {props.isAdmin && <th>{t('share_links.Page Path', { ns: 'commons' })}</th>}
+            <th style={{ width: '350px' }}>
+              {t('share_links.Share Link', { ns: 'commons' })}
+            </th>
+            {props.isAdmin && (
+              <th>{t('share_links.Page Path', { ns: 'commons' })}</th>
+            )}
             <th>{t('share_links.description', { ns: 'commons' })}</th>
-            <th style={{ width: '150px' }}>{t('share_links.expire', { ns: 'commons' })}</th>
+            <th style={{ width: '150px' }}>
+              {t('share_links.expire', { ns: 'commons' })}
+            </th>
             <th></th>
           </tr>
         </thead>
-        <tbody>
-          {renderShareLinks()}
-        </tbody>
+        <tbody>{renderShareLinks()}</tbody>
       </table>
     </div>
   );

+ 4 - 1
apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx

@@ -14,7 +14,10 @@ export const PageAccessoriesModalLazyLoaded = (): JSX.Element => {
 
   const PageAccessoriesModal = useLazyLoader<PageAccessoriesModalProps>(
     'page-accessories-modal',
-    () => import('./PageAccessoriesModal').then(mod => ({ default: mod.PageAccessoriesModal })),
+    () =>
+      import('./PageAccessoriesModal').then((mod) => ({
+        default: mod.PageAccessoriesModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 32 - 29
apps/app/src/client/components/PageAccessoriesModal/hooks.tsx

@@ -1,9 +1,13 @@
 import { useEffect, useState } from 'react';
 
-import { usePageAccessoriesModalStatus, usePageAccessoriesModalActions, PageAccessoriesModalContents } from '~/states/ui/modal/page-accessories';
+import {
+  PageAccessoriesModalContents,
+  usePageAccessoriesModalActions,
+  usePageAccessoriesModalStatus,
+} from '~/states/ui/modal/page-accessories';
 
 function getURLQueryParamValue(key: string) {
-// window.location.href is page URL;
+  // window.location.href is page URL;
   const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
   return queryStr.get(key);
 }
@@ -11,7 +15,6 @@ function getURLQueryParamValue(key: string) {
 // https://regex101.com/r/YHTDsr/1
 const queryCompareFormat = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
 
-
 export const useAutoOpenModalByQueryParam = (): void => {
   const [isArleadyMounted, setIsArleadyMounted] = useState(false);
 
@@ -41,40 +44,40 @@ export const useAutoOpenModalByQueryParam = (): void => {
 
     setIsArleadyMounted(true);
   }, [openPageAccessories, status, isArleadyMounted]);
-
 };
 
 type ComparingRevisionIds = {
-  sourceRevisionId: string,
-  targetRevisionId: string,
-}
+  sourceRevisionId: string;
+  targetRevisionId: string;
+};
 
-export const useAutoComparingRevisionsByQueryParam = (): ComparingRevisionIds | null => {
-  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+export const useAutoComparingRevisionsByQueryParam =
+  (): ComparingRevisionIds | null => {
+    const [isArleadyMounted, setIsArleadyMounted] = useState(false);
 
-  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
-  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+    const [sourceRevisionId, setSourceRevisionId] = useState<string>();
+    const [targetRevisionId, setTargetRevisionId] = useState<string>();
 
-  useEffect(() => {
-    if (isArleadyMounted) {
-      return;
-    }
+    useEffect(() => {
+      if (isArleadyMounted) {
+        return;
+      }
 
-    const pageIdParams = getURLQueryParamValue('compare');
-    if (pageIdParams != null) {
-      const matches = pageIdParams.match(queryCompareFormat);
+      const pageIdParams = getURLQueryParamValue('compare');
+      if (pageIdParams != null) {
+        const matches = pageIdParams.match(queryCompareFormat);
 
-      if (matches != null) {
-        const [, source, target] = matches;
-        setSourceRevisionId(source);
-        setTargetRevisionId(target);
+        if (matches != null) {
+          const [, source, target] = matches;
+          setSourceRevisionId(source);
+          setTargetRevisionId(target);
+        }
       }
-    }
 
-    setIsArleadyMounted(true);
-  }, [isArleadyMounted]);
+      setIsArleadyMounted(true);
+    }, [isArleadyMounted]);
 
-  return sourceRevisionId != null && targetRevisionId != null
-    ? { sourceRevisionId, targetRevisionId }
-    : null;
-};
+    return sourceRevisionId != null && targetRevisionId != null
+      ? { sourceRevisionId, targetRevisionId }
+      : null;
+  };

+ 66 - 43
apps/app/src/client/components/PageComment/Comment.tsx

@@ -1,47 +1,49 @@
-import React, {
-  useEffect, useMemo, useState, type JSX,
-} from 'react';
-
-import { isPopulated, type IUser } from '@growi/core';
+import React, { type JSX, useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { type IUser, isPopulated } 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';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
 import urljoin from 'url-join';
 
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
-
 import RevisionRenderer from '../../../components/PageView/RevisionRenderer';
 import { Username } from '../../../components/User/Username';
 import type { ICommentHasId } from '../../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
-
 import { CommentControl } from './CommentControl';
 import { CommentEditor } from './CommentEditor';
 
 import styles from './Comment.module.scss';
 
 type CommentProps = {
-  comment: ICommentHasId,
-  rendererOptions: RendererOptions,
-  revisionId: string,
-  revisionCreatedAt: Date,
-  currentUser: IUser,
-  isReadOnly: boolean,
-  pageId: string,
-  pagePath: string,
-  deleteBtnClicked: (comment: ICommentHasId) => void,
-  onComment: () => void,
-}
+  comment: ICommentHasId;
+  rendererOptions: RendererOptions;
+  revisionId: string;
+  revisionCreatedAt: Date;
+  currentUser: IUser;
+  isReadOnly: boolean;
+  pageId: string;
+  pagePath: string;
+  deleteBtnClicked: (comment: ICommentHasId) => void;
+  onComment: () => void;
+};
 
 export const Comment = (props: CommentProps): JSX.Element => {
-
   const {
-    comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
-    pageId, pagePath, deleteBtnClicked, onComment,
+    comment,
+    rendererOptions,
+    revisionId,
+    revisionCreatedAt,
+    currentUser,
+    isReadOnly,
+    pageId,
+    pagePath,
+    deleteBtnClicked,
+    onComment,
   } = props;
 
   const { returnPathForURL } = pathUtils;
@@ -82,22 +84,24 @@ export const Comment = (props: CommentProps): JSX.Element => {
     let className = 'page-comment flex-column';
 
     // TODO: fix so that `comment.createdAt` to be type Date https://redmine.weseek.co.jp/issues/113876
-    const commentCreatedAtFixed = typeof comment.createdAt === 'string'
-      ? parseISO(comment.createdAt)
-      : comment.createdAt;
-    const revisionCreatedAtFixed = typeof revisionCreatedAt === 'string'
-      ? parseISO(revisionCreatedAt)
-      : revisionCreatedAt;
+    const commentCreatedAtFixed =
+      typeof comment.createdAt === 'string'
+        ? parseISO(comment.createdAt)
+        : comment.createdAt;
+    const revisionCreatedAtFixed =
+      typeof revisionCreatedAt === 'string'
+        ? parseISO(revisionCreatedAt)
+        : revisionCreatedAt;
 
     // Conditional for called from SearchResultContext
     if (revisionId != null && revisionCreatedAt != null) {
       if (comment.revision === revisionId) {
         className += ' page-comment-current';
-      }
-      else if (commentCreatedAtFixed.getTime() > revisionCreatedAtFixed.getTime()) {
+      } else if (
+        commentCreatedAtFixed.getTime() > revisionCreatedAtFixed.getTime()
+      ) {
         className += ' page-comment-newer';
-      }
-      else {
+      } else {
         className += ' page-comment-older';
       }
     }
@@ -130,11 +134,13 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const rootClassName = getRootClassName(comment);
   const revHref = `?revisionId=${comment.revision}`;
   const editedDateId = `editedDate-${comment._id}`;
-  const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
+  const editedDateFormatted = isEdited
+    ? format(updatedAt, 'yyyy/MM/dd HH:mm')
+    : null;
 
   return (
     <div className={`${styles['comment-styles']}`}>
-      { (isReEdit && !isReadOnly) ? (
+      {isReEdit && !isReadOnly ? (
         <CommentEditor
           pageId={comment._id}
           replyTo={undefined}
@@ -155,8 +161,15 @@ export const Comment = (props: CommentProps): JSX.Element => {
               <div className="small fw-bold me-3">
                 <Username user={creator} />
               </div>
-              <Link href={`#${commentId}`} prefetch={false} className="small page-comment-revision">
-                <FormattedDistanceDate id={commentId} date={comment.createdAt} />
+              <Link
+                href={`#${commentId}`}
+                prefetch={false}
+                className="small page-comment-revision"
+              >
+                <FormattedDistanceDate
+                  id={commentId}
+                  date={comment.createdAt}
+                />
               </Link>
               <span className="ms-2">
                 <Link
@@ -167,29 +180,39 @@ export const Comment = (props: CommentProps): JSX.Element => {
                 >
                   <span className="material-symbols-outlined">history</span>
                 </Link>
-                <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
+                <UncontrolledTooltip
+                  placement="bottom"
+                  fade={false}
+                  target={`page-comment-revision-${commentId}`}
+                >
                   {t('page_comment.display_the_page_when_posting_this_comment')}
                 </UncontrolledTooltip>
               </span>
             </div>
             <div className="page-comment-body">{commentBody}</div>
             <div className="page-comment-meta">
-              { isEdited && (
+              {isEdited && (
                 <>
                   <span id={editedDateId}>&nbsp;(edited)</span>
-                  <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
+                  <UncontrolledTooltip
+                    placement="bottom"
+                    fade={false}
+                    target={editedDateId}
+                  >
+                    {editedDateFormatted}
+                  </UncontrolledTooltip>
                 </>
-              ) }
+              )}
             </div>
-            { (isCurrentUserEqualsToAuthor() && !isReadOnly) && (
+            {isCurrentUserEqualsToAuthor() && !isReadOnly && (
               <CommentControl
                 onClickDeleteBtn={deleteBtnClickedHandler}
                 onClickEditBtn={() => setIsReEdit(true)}
               />
-            ) }
+            )}
           </div>
         </div>
-      ) }
+      )}
     </div>
   );
 };

+ 8 - 6
apps/app/src/client/components/PageComment/CommentControl.tsx

@@ -3,12 +3,11 @@ import React, { type JSX } from 'react';
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableForReadOnlyUser';
 
 type CommentControlProps = {
-  onClickEditBtn: () => void,
-  onClickDeleteBtn: () => void,
-}
+  onClickEditBtn: () => void;
+  onClickDeleteBtn: () => void;
+};
 
 export const CommentControl = (props: CommentControlProps): JSX.Element => {
-
   const { onClickEditBtn, onClickDeleteBtn } = props;
 
   return (
@@ -16,7 +15,11 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
     <div className="page-comment-control">
       <NotAvailableIfReadOnlyUserNotAllowedToComment>
         <>
-          <button type="button" className="btn btn-link p-2 opacity-50" onClick={onClickEditBtn}>
+          <button
+            type="button"
+            className="btn btn-link p-2 opacity-50"
+            onClick={onClickEditBtn}
+          >
             <span className="material-symbols-outlined">edit</span>
           </button>
           <button
@@ -31,5 +34,4 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
       </NotAvailableIfReadOnlyUserNotAllowedToComment>
     </div>
   );
-
 };

+ 153 - 113
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -1,83 +1,90 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React, {
-  useCallback, useState, useEffect, useLayoutEffect,
+  useCallback,
+  useEffect,
+  useLayoutEffect,
   useMemo,
+  useState,
 } from 'react';
-
+import dynamic from 'next/dynamic';
 import { GlobalCodeMirrorEditorKey, useSetResolvedTheme } from '@growi/editor';
 import { CodeMirrorEditorComment } from '@growi/editor/dist/client/components/CodeMirrorEditorComment';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import {
-  TabContent, TabPane,
-} from 'reactstrap';
-
+import { TabContent, TabPane } from 'reactstrap';
 
 import { uploadAttachments } from '~/client/services/upload-attachments';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentPagePath } from '~/states/page';
-import { isSlackConfiguredAtom, useAcceptedUploadFileType } from '~/states/server-configurations';
+import {
+  isSlackConfiguredAtom,
+  useAcceptedUploadFileType,
+} from '~/states/server-configurations';
 import { useIsSlackEnabled } from '~/states/ui/editor';
 import { useCommentEditorsDirtyMap } from '~/states/ui/unsaved-warning';
-import { useNextThemes } from '~/stores-universal/use-next-themes';
 import { useSWRxPageComment } from '~/stores/comment';
-import { useSWRxSlackChannels, useEditorSettings } from '~/stores/editor';
+import { useEditorSettings, useSWRxSlackChannels } from '~/stores/editor';
+import { useNextThemes } from '~/stores-universal/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableForReadOnlyUser';
-
 import { CommentPreview } from './CommentPreview';
 import { SwitchingButtonGroup } from './SwitchingButtonGroup';
 
-
 import '@growi/editor/dist/style.css';
-import styles from './CommentEditor.module.scss';
 
+import styles from './CommentEditor.module.scss';
 
 const logger = loggerFactory('growi:components:CommentEditor');
 
+const SlackNotification = dynamic(
+  () => import('../SlackNotification').then((mod) => mod.SlackNotification),
+  { ssr: false },
+);
 
-const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
-
-
-const CommentEditorLayout = ({ children }: { children: ReactNode }): JSX.Element => {
+const CommentEditorLayout = ({
+  children,
+}: {
+  children: ReactNode;
+}): JSX.Element => {
   return (
     <div className={`${styles['comment-editor-styles']} form`}>
       <div className="comment-form">
-        <div className="bg-comment rounded">
-          {children}
-        </div>
+        <div className="bg-comment rounded">{children}</div>
       </div>
     </div>
   );
 };
 
-
 type CommentEditorProps = {
-  pageId: string,
-  replyTo?: string,
-  revisionId: string,
-  currentCommentId?: string,
-  commentBody?: string,
-  onCanceled?: () => void,
-  onCommented?: () => void,
-}
+  pageId: string;
+  replyTo?: string;
+  revisionId: string;
+  currentCommentId?: string;
+  commentBody?: string;
+  onCanceled?: () => void;
+  onCommented?: () => void;
+};
 
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
-
   const {
-    pageId, replyTo, revisionId,
-    currentCommentId, commentBody, onCanceled, onCommented,
+    pageId,
+    replyTo,
+    revisionId,
+    currentCommentId,
+    commentBody,
+    onCanceled,
+    onCommented,
   } = props;
 
   const currentUser = useCurrentUser();
   const currentPagePath = useCurrentPagePath();
-  const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
+  const { update: updateComment, post: postComment } =
+    useSWRxPageComment(pageId);
   const [isSlackEnabled, setIsSlackEnabled] = useIsSlackEnabled();
   const acceptedUploadFileType = useAcceptedUploadFileType();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
@@ -139,7 +146,6 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     setError(undefined);
 
     initializeSlackEnabled();
-
   }, [editorKey, markClean, initializeSlackEnabled]);
 
   const cancelButtonClickedHandler = useCallback(() => {
@@ -154,8 +160,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       if (currentCommentId != null) {
         // update current comment
         await updateComment(commentBodyToPost, revisionId, currentCommentId);
-      }
-      else {
+      } else {
         // post new comment
         const postCommentArgs = {
           commentForm: {
@@ -177,39 +182,55 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
       // Insert empty string as new comment editor is opened after comment
       codeMirrorEditor?.initDoc('');
-    }
-    catch (err) {
-      const errorMessage = err.message || 'An unknown error occured when posting comment';
+    } catch (err) {
+      const errorMessage =
+        err.message || 'An unknown error occured when posting comment';
       setError(errorMessage);
     }
     // eslint-disable-next-line max-len
-  }, [currentCommentId, initializeEditor, onCommented, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
+  }, [
+    currentCommentId,
+    initializeEditor,
+    onCommented,
+    codeMirrorEditor,
+    updateComment,
+    revisionId,
+    replyTo,
+    isSlackEnabled,
+    slackChannels,
+    postComment,
+  ]);
 
   // the upload event handler
-  const uploadHandler = useCallback((files: File[]) => {
-    uploadAttachments(pageId, files, {
-      onUploaded: (attachment) => {
-        const fileName = attachment.originalName;
-
-        const prefix = attachment.fileFormat.startsWith('image/')
-          ? '!' // use "![fileName](url)" syntax when image
-          : '';
-        const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
-
-        codeMirrorEditor?.insertText(insertText);
-      },
-      onError: (error) => {
-        toastError(error);
-      },
-    });
-  }, [codeMirrorEditor, pageId]);
-
-  const cmProps = useMemo(() => ({
-    onChange: (value: string) => {
-      markDirty(editorKey, value);
+  const uploadHandler = useCallback(
+    (files: File[]) => {
+      uploadAttachments(pageId, files, {
+        onUploaded: (attachment) => {
+          const fileName = attachment.originalName;
+
+          const prefix = attachment.fileFormat.startsWith('image/')
+            ? '!' // use "![fileName](url)" syntax when image
+            : '';
+          const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
+
+          codeMirrorEditor?.insertText(insertText);
+        },
+        onError: (error) => {
+          toastError(error);
+        },
+      });
     },
-  }), [editorKey, markDirty]);
+    [codeMirrorEditor, pageId],
+  );
 
+  const cmProps = useMemo(
+    () => ({
+      onChange: (value: string) => {
+        markDirty(editorKey, value);
+      },
+    }),
+    [editorKey, markDirty],
+  );
 
   // initialize CodeMirrorEditor
   useEffect(() => {
@@ -225,16 +246,22 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     codeMirrorEditor?.focus();
   }, [codeMirrorEditor, showPreview]);
 
-  const errorMessage = useMemo(() => <span className="text-danger text-end me-2">{error}</span>, [error]);
-  const cancelButton = useMemo(() => (
-    <button
-      type="button"
-      className="btn btn-outline-neutral-secondary"
-      onClick={cancelButtonClickedHandler}
-    >
-      {t('Cancel')}
-    </button>
-  ), [cancelButtonClickedHandler, t]);
+  const errorMessage = useMemo(
+    () => <span className="text-danger text-end me-2">{error}</span>,
+    [error],
+  );
+  const cancelButton = useMemo(
+    () => (
+      <button
+        type="button"
+        className="btn btn-outline-neutral-secondary"
+        onClick={cancelButtonClickedHandler}
+      >
+        {t('Cancel')}
+      </button>
+    ),
+    [cancelButtonClickedHandler, t],
+  );
   const submitButton = useMemo(() => {
     return (
       <button
@@ -256,9 +283,14 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <UserPicture user={currentUser} noLink noTooltip />
             <p className="ms-2 mb-0">{t('page_comment.add_a_comment')}</p>
           </div>
-          <SwitchingButtonGroup showPreview={showPreview} onSelected={handleSelect} />
+          <SwitchingButtonGroup
+            showPreview={showPreview}
+            onSelected={handleSelect}
+          />
         </div>
-        <TabContent activeTab={showPreview ? 'comment_preview' : 'comment_editor'}>
+        <TabContent
+          activeTab={showPreview ? 'comment_preview' : 'comment_editor'}
+        >
           <TabPane tabId="comment_editor">
             <CodeMirrorEditorComment
               editorKey={editorKey}
@@ -271,7 +303,9 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
           </TabPane>
           <TabPane tabId="comment_preview">
             <div className="comment-preview-container">
-              <CommentPreview markdown={codeMirrorEditor?.getDocString() ?? ''} />
+              <CommentPreview
+                markdown={codeMirrorEditor?.getDocString() ?? ''}
+              />
             </div>
           </TabPane>
         </TabContent>
@@ -280,40 +314,39 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       <div className="comment-submit px-4 pb-3 mb-2">
         <div className="d-flex">
           <span className="flex-grow-1" />
-          <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
-
-          {isSlackConfigured && isSlackEnabled != null
-            && (
-              <div className="align-self-center me-md-3">
-                <SlackNotification
-                  isSlackEnabled={isSlackEnabled}
-                  slackChannels={slackChannels}
-                  onEnabledFlagChange={isSlackEnabledToggleHandler}
-                  onChannelChange={slackChannelsChangedHandler}
-                  id="idForComment"
-                />
-              </div>
-            )
-          }
+          <span className="d-none d-sm-inline">
+            {errorMessage && errorMessage}
+          </span>
+
+          {isSlackConfigured && isSlackEnabled != null && (
+            <div className="align-self-center me-md-3">
+              <SlackNotification
+                isSlackEnabled={isSlackEnabled}
+                slackChannels={slackChannels}
+                onEnabledFlagChange={isSlackEnabledToggleHandler}
+                onChannelChange={slackChannelsChangedHandler}
+                id="idForComment"
+              />
+            </div>
+          )}
           <div className="d-none d-sm-block">
-            <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
+            <span className="me-2">{cancelButton}</span>
+            <span>{submitButton}</span>
           </div>
         </div>
         <div className="d-block d-sm-none mt-2">
           <div className="d-flex justify-content-end">
             {error && errorMessage}
-            <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
+            <span className="me-2">{cancelButton}</span>
+            <span>{submitButton}</span>
           </div>
         </div>
       </div>
     </CommentEditorLayout>
   );
-
 };
 
-
 export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
-
   const { onCommented, onCanceled, ...rest } = props;
 
   const currentUser = useCurrentUser();
@@ -338,8 +371,15 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
               onClick={() => setIsReadyToUse(true)}
               data-testid="open-comment-editor-button"
             >
-              <UserPicture user={currentUser} noLink noTooltip className="me-3" />
-              <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
+              <UserPicture
+                user={currentUser}
+                noLink
+                noTooltip
+                className="me-3"
+              />
+              <span className="material-symbols-outlined me-1 fs-5">
+                add_comment
+              </span>
               <small>{t('page_comment.add_a_comment')}...</small>
             </button>
           </NotAvailableIfReadOnlyUserNotAllowedToComment>
@@ -348,19 +388,19 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
     );
   }, [currentUser, t]);
 
-  return isReadyToUse
-    ? (
-      <CommentEditor
-        onCommented={() => {
-          onCommented?.();
-          setIsReadyToUse(false);
-        }}
-        onCanceled={() => {
-          onCanceled?.();
-          setIsReadyToUse(false);
-        }}
-        {...rest}
-      />
-    )
-    : render();
+  return isReadyToUse ? (
+    <CommentEditor
+      onCommented={() => {
+        onCommented?.();
+        setIsReadyToUse(false);
+      }}
+      onCanceled={() => {
+        onCanceled?.();
+        setIsReadyToUse(false);
+      }}
+      {...rest}
+    />
+  ) : (
+    render()
+  );
 };

+ 2 - 7
apps/app/src/client/components/PageComment/CommentPreview.tsx

@@ -4,19 +4,15 @@ import { useCommentPreviewOptions } from '~/stores/renderer';
 
 import RevisionRenderer from '../../../components/PageView/RevisionRenderer';
 
-
 import styles from './CommentPreview.module.scss';
 
-
 const moduleClass = styles['grw-comment-preview'] ?? '';
 
-
 type CommentPreviewPorps = {
-  markdown: string,
-}
+  markdown: string;
+};
 
 export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
-
   const { markdown } = props;
 
   const { data: rendererOptions } = useCommentPreviewOptions();
@@ -34,5 +30,4 @@ export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
       />
     </div>
   );
-
 };

+ 74 - 56
apps/app/src/client/components/PageComment/DeleteCommentModal/DeleteCommentModal.tsx

@@ -1,41 +1,38 @@
-import React, { useMemo } from 'react';
-
+import type React from 'react';
+import { useMemo } from 'react';
 import { isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { Username } from '~/components/User/Username';
 import type { ICommentHasId } from '~/interfaces/comment';
 
 import styles from './DeleteCommentModal.module.scss';
 
-
 export type DeleteCommentModalProps = {
-  isShown: boolean,
-  comment: ICommentHasId | null,
-  errorMessage: string,
-  cancelToDelete: () => void,
-  confirmToDelete: () => void,
-}
+  isShown: boolean;
+  comment: ICommentHasId | null;
+  errorMessage: string;
+  cancelToDelete: () => void;
+  confirmToDelete: () => void;
+};
 
 /**
  * DeleteCommentModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
 type DeleteCommentModalSubstanceProps = {
-  comment: ICommentHasId,
-  errorMessage: string,
-  cancelToDelete: () => void,
-  confirmToDelete: () => void,
-}
+  comment: ICommentHasId;
+  errorMessage: string;
+  cancelToDelete: () => void;
+  confirmToDelete: () => void;
+};
 
-const DeleteCommentModalSubstance = (props: DeleteCommentModalSubstanceProps): React.JSX.Element => {
-  const {
-    comment, errorMessage, cancelToDelete, confirmToDelete,
-  } = props;
+const DeleteCommentModalSubstance = (
+  props: DeleteCommentModalSubstanceProps,
+): React.JSX.Element => {
+  const { comment, errorMessage, cancelToDelete, confirmToDelete } = props;
 
   const { t } = useTranslation();
 
@@ -63,46 +60,61 @@ const DeleteCommentModalSubstance = (props: DeleteCommentModalSubstanceProps): R
   }, [comment]);
 
   // Memoize header content
-  const headerContent = useMemo(() => (
-    <span>
-      <span className="material-symbols-outlined">delete_forever</span>
-      {t('page_comment.delete_comment')}
-    </span>
-  ), [t]);
+  const headerContent = useMemo(
+    () => (
+      <span>
+        <span className="material-symbols-outlined">delete_forever</span>
+        {t('page_comment.delete_comment')}
+      </span>
+    ),
+    [t],
+  );
 
   // Memoize body content
-  const bodyContent = useMemo(() => (
-    <>
-      <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
-      <div className="card mt-2">
-        <div className="card-body comment-body px-3 py-2">{commentBodyElement}</div>
-      </div>
-    </>
-  ), [creator, commentDate, commentBodyElement]);
+  const bodyContent = useMemo(
+    () => (
+      <>
+        <UserPicture user={creator} size="xs" />{' '}
+        <strong className="me-2">
+          <Username user={creator}></Username>
+        </strong>
+        {commentDate}:
+        <div className="card mt-2">
+          <div className="card-body comment-body px-3 py-2">
+            {commentBodyElement}
+          </div>
+        </div>
+      </>
+    ),
+    [creator, commentDate, commentBodyElement],
+  );
 
   // Memoize footer content
-  const footerContent = useMemo(() => (
-    <>
-      <span className="text-danger">{errorMessage}</span>&nbsp;
-      <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
-      <Button data-testid="delete-comment-button" color="danger" onClick={confirmToDelete}>
-        <span className="material-symbols-outlined">delete_forever</span>
-        {t('Delete')}
-      </Button>
-    </>
-  ), [errorMessage, cancelToDelete, confirmToDelete, t]);
+  const footerContent = useMemo(
+    () => (
+      <>
+        <span className="text-danger">{errorMessage}</span>&nbsp;
+        <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
+        <Button
+          data-testid="delete-comment-button"
+          color="danger"
+          onClick={confirmToDelete}
+        >
+          <span className="material-symbols-outlined">delete_forever</span>
+          {t('Delete')}
+        </Button>
+      </>
+    ),
+    [errorMessage, cancelToDelete, confirmToDelete, t],
+  );
 
   return (
     <>
       <ModalHeader tag="h4" toggle={cancelToDelete} className="text-danger">
         {headerContent}
       </ModalHeader>
-      <ModalBody>
-        {bodyContent}
-      </ModalBody>
-      <ModalFooter>
-        {footerContent}
-      </ModalFooter>
+      <ModalBody>{bodyContent}</ModalBody>
+      <ModalFooter>{footerContent}</ModalFooter>
     </>
   );
 };
@@ -110,13 +122,19 @@ const DeleteCommentModalSubstance = (props: DeleteCommentModalSubstanceProps): R
 /**
  * DeleteCommentModal - Container component (lightweight, always rendered)
  */
-export const DeleteCommentModal = (props: DeleteCommentModalProps): React.JSX.Element => {
-  const {
-    isShown, comment, errorMessage, cancelToDelete, confirmToDelete,
-  } = props;
+export const DeleteCommentModal = (
+  props: DeleteCommentModalProps,
+): React.JSX.Element => {
+  const { isShown, comment, errorMessage, cancelToDelete, confirmToDelete } =
+    props;
 
   return (
-    <Modal data-testid="page-comment-delete-modal" isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
+    <Modal
+      data-testid="page-comment-delete-modal"
+      isOpen={isShown}
+      toggle={cancelToDelete}
+      className={`${styles['page-comment-delete-modal']}`}
+    >
       {isShown && comment != null && (
         <DeleteCommentModalSubstance
           comment={comment}

+ 7 - 3
apps/app/src/client/components/PageComment/DeleteCommentModal/dynamic.tsx

@@ -1,13 +1,17 @@
 import type { JSX } from 'react';
 
 import { useLazyLoader } from '../../../../components/utils/use-lazy-loader';
-
 import type { DeleteCommentModalProps } from './DeleteCommentModal';
 
-export const DeleteCommentModalLazyLoaded = (props: DeleteCommentModalProps): JSX.Element => {
+export const DeleteCommentModalLazyLoaded = (
+  props: DeleteCommentModalProps,
+): JSX.Element => {
   const DeleteCommentModal = useLazyLoader<DeleteCommentModalProps>(
     'delete-comment-modal',
-    () => import('./DeleteCommentModal').then(mod => ({ default: mod.DeleteCommentModal })),
+    () =>
+      import('./DeleteCommentModal').then((mod) => ({
+        default: mod.DeleteCommentModal,
+      })),
     props.isShown,
   );
 

+ 35 - 23
apps/app/src/client/components/PageComment/ReplyComments.tsx

@@ -1,6 +1,4 @@
-
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import type { IUser } from '@growi/core';
 import { useAtomValue } from 'jotai';
 import { Collapse } from 'reactstrap';
@@ -9,30 +7,35 @@ import type { ICommentHasId, ICommentHasIdList } from '~/interfaces/comment';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import { isAllReplyShownAtom } from '~/states/server-configurations';
 
-
 import { Comment } from './Comment';
 
 import styles from './ReplyComments.module.scss';
 
-
 type ReplycommentsProps = {
-  rendererOptions: RendererOptions,
-  isReadOnly: boolean,
-  revisionId: string,
-  revisionCreatedAt: Date,
-  currentUser: IUser,
-  replyList: ICommentHasIdList,
-  pageId: string,
-  pagePath: string,
-  deleteBtnClicked: (comment: ICommentHasId) => void,
-  onComment: () => void,
-}
+  rendererOptions: RendererOptions;
+  isReadOnly: boolean;
+  revisionId: string;
+  revisionCreatedAt: Date;
+  currentUser: IUser;
+  replyList: ICommentHasIdList;
+  pageId: string;
+  pagePath: string;
+  deleteBtnClicked: (comment: ICommentHasId) => void;
+  onComment: () => void;
+};
 
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
-
   const {
-    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList,
-    pageId, pagePath, deleteBtnClicked, onComment,
+    rendererOptions,
+    isReadOnly,
+    revisionId,
+    revisionCreatedAt,
+    currentUser,
+    replyList,
+    pageId,
+    pagePath,
+    deleteBtnClicked,
+    onComment,
   } = props;
 
   const isAllReplyShown = useAtomValue(isAllReplyShownAtom);
@@ -41,7 +44,10 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const renderReply = (reply: ICommentHasId) => {
     return (
-      <div key={reply._id} className={`${styles['page-comment-reply']} mt-2 ms-4 ms-sm-5`}>
+      <div
+        key={reply._id}
+        className={`${styles['page-comment-reply']} mt-2 ms-4 ms-sm-5`}
+      >
         <Comment
           rendererOptions={rendererOptions}
           comment={reply}
@@ -68,9 +74,15 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
     );
   }
 
-  const areThereHiddenReplies = (replyList.length > 2);
-  const toggleButtonIconName = isOlderRepliesShown ? 'expand_less' : 'more_vert';
-  const toggleButtonIcon = <span className="material-symbols-outlined me-1">{toggleButtonIconName}</span>;
+  const areThereHiddenReplies = replyList.length > 2;
+  const toggleButtonIconName = isOlderRepliesShown
+    ? 'expand_less'
+    : 'more_vert';
+  const toggleButtonIcon = (
+    <span className="material-symbols-outlined me-1">
+      {toggleButtonIconName}
+    </span>
+  );
   const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
   const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
   const hiddenReplies = replyList.slice(0, replyList.length - 2);

+ 12 - 21
apps/app/src/client/components/PageComment/SwitchingButtonGroup.tsx

@@ -1,21 +1,20 @@
 import type { ButtonHTMLAttributes, DetailedHTMLProps, JSX } from 'react';
 import { memo } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import styles from './SwitchingButtonGroup.module.scss';
 
 const moduleClass = styles['btn-group-switching'] ?? '';
 
-
-type SwitchingButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
-    active?: boolean,
-}
+type SwitchingButtonProps = DetailedHTMLProps<
+  ButtonHTMLAttributes<HTMLButtonElement>,
+  HTMLButtonElement
+> & {
+  active?: boolean;
+};
 
 const SwitchingButton = memo((props: SwitchingButtonProps) => {
-  const {
-    active, className, children, onClick, ...rest
-  } = props;
+  const { active, className, children, onClick, ...rest } = props;
 
   return (
     <button
@@ -32,25 +31,18 @@ const SwitchingButton = memo((props: SwitchingButtonProps) => {
   );
 });
 
-
 type Props = {
-  showPreview: boolean,
-  onSelected?: (showPreview: boolean) => void,
+  showPreview: boolean;
+  onSelected?: (showPreview: boolean) => void;
 };
 
 export const SwitchingButtonGroup = (props: Props): JSX.Element => {
-
   const { t } = useTranslation();
 
-  const {
-    showPreview, onSelected,
-  } = props;
+  const { showPreview, onSelected } = props;
 
   return (
-    <div
-      className={`btn-group ${moduleClass}`}
-      role="group"
-    >
+    <fieldset className={`btn-group ${moduleClass}`} aria-label="Comment view">
       <SwitchingButton
         active={showPreview}
         className="ps-2 pe-3"
@@ -67,7 +59,6 @@ export const SwitchingButtonGroup = (props: Props): JSX.Element => {
         <span className="material-symbols-outlined me-1">edit_square</span>
         <span className="d-none d-sm-inline">{t('page_comment.write')}</span>
       </SwitchingButton>
-    </div>
+    </fieldset>
   );
-
 };

+ 44 - 31
apps/app/src/client/components/PageControls/BookmarkButtons.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import DropdownToggle from 'reactstrap/esm/DropdownToggle';
@@ -18,38 +17,37 @@ import styles from './BookmarkButtons.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
 
 interface Props {
-  pageId: string,
-  isBookmarked?: boolean,
-  bookmarkCount: number,
+  pageId: string;
+  isBookmarked?: boolean;
+  bookmarkCount: number;
 }
 
 export const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-  const {
-    pageId, isBookmarked, bookmarkCount,
-  } = props;
+  const { pageId, isBookmarked, bookmarkCount } = props;
 
   const [isBookmarkFolderMenuOpen, setBookmarkFolderMenuOpen] = useState(false);
-  const [isBookmarkUsersPopoverOpen, setBookmarkUsersPopoverOpen] = useState(false);
+  const [isBookmarkUsersPopoverOpen, setBookmarkUsersPopoverOpen] =
+    useState(false);
 
   const isGuestUser = useIsGuestUser();
 
-  const { data: bookmarkedUsers, isLoading: isLoadingBookmarkedUsers } = useSWRxBookmarkedUsers(isBookmarkUsersPopoverOpen ? pageId : null);
+  const { data: bookmarkedUsers, isLoading: isLoadingBookmarkedUsers } =
+    useSWRxBookmarkedUsers(isBookmarkUsersPopoverOpen ? pageId : null);
 
   const unbookmarkHandler = () => {
     setBookmarkFolderMenuOpen(false);
   };
 
   const toggleBookmarkFolderMenuHandler = () => {
-    setBookmarkFolderMenuOpen(v => !v);
+    setBookmarkFolderMenuOpen((v) => !v);
   };
 
   const toggleBookmarkUsersPopover = () => {
-    setBookmarkUsersPopoverOpen(v => !v);
+    setBookmarkUsersPopoverOpen((v) => !v);
   };
 
   const getTooltipMessage = useCallback(() => {
-
     if (isGuestUser) {
       return 'Not available for guest';
     }
@@ -61,8 +59,10 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
   }
 
   return (
-    <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-
+    <fieldset
+      className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`}
+      aria-label="Bookmark buttons"
+    >
       <BookmarkFolderMenu
         isOpen={isBookmarkFolderMenuOpen}
         pageId={pageId}
@@ -76,12 +76,18 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
           className={`btn btn-bookmark rounded-end-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
-          <span className={`material-symbols-outlined ${isBookmarked ? 'fill' : ''}`}>
+          <span
+            className={`material-symbols-outlined ${isBookmarked ? 'fill' : ''}`}
+          >
             bookmark
           </span>
         </DropdownToggle>
       </BookmarkFolderMenu>
-      <UncontrolledTooltip data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
+      <UncontrolledTooltip
+        data-testid="bookmark-button-tooltip"
+        target="bookmark-dropdown-btn"
+        fade={false}
+      >
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
 
@@ -93,23 +99,30 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
       >
         {bookmarkCount}
       </button>
-      <Popover placement="bottom" isOpen={isBookmarkUsersPopoverOpen} target="po-total-bookmarks" toggle={toggleBookmarkUsersPopover} trigger="legacy">
-        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
-          { isLoadingBookmarkedUsers && <LoadingSpinner /> }
-          { !isLoadingBookmarkedUsers && bookmarkedUsers != null && (
+      <Popover
+        placement="bottom"
+        isOpen={isBookmarkUsersPopoverOpen}
+        target="po-total-bookmarks"
+        toggle={toggleBookmarkUsersPopover}
+        trigger="legacy"
+      >
+        <PopoverBody
+          className={`user-list-popover ${popoverStyles['user-list-popover']}`}
+        >
+          {isLoadingBookmarkedUsers && <LoadingSpinner />}
+          {!isLoadingBookmarkedUsers && bookmarkedUsers != null && (
             <>
-              { bookmarkedUsers.length > 0
-                ? (
-                  <div className="px-2 text-end user-list-content text-truncate text-muted">
-                    <UserPictureList users={bookmarkedUsers} />
-                  </div>
-                )
-                : t('No users have bookmarked yet')
-              }
+              {bookmarkedUsers.length > 0 ? (
+                <div className="px-2 text-end user-list-content text-truncate text-muted">
+                  <UserPictureList users={bookmarkedUsers} />
+                </div>
+              ) : (
+                t('No users have bookmarked yet')
+              )}
             </>
-          ) }
+          )}
         </PopoverBody>
       </Popover>
-    </div>
+    </fieldset>
   );
 };

+ 38 - 23
apps/app/src/client/components/PageControls/LikeButtons.tsx

@@ -1,10 +1,8 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import type { IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
-
+import { Popover, PopoverBody, UncontrolledTooltip } from 'reactstrap';
 
 import UserPictureList from '../Common/UserPictureList';
 
@@ -12,14 +10,13 @@ import styles from './LikeButtons.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
 
 type LikeButtonsProps = {
+  sumOfLikers: number;
+  likers: IUser[];
 
-  sumOfLikers: number,
-  likers: IUser[],
-
-  isGuestUser?: boolean,
-  isLiked?: boolean,
-  onLikeClicked?: ()=>void,
-}
+  isGuestUser?: boolean;
+  isLiked?: boolean;
+  onLikeClicked?: () => void;
+};
 
 const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   const { t } = useTranslation();
@@ -30,12 +27,9 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
-  const {
-    isGuestUser, isLiked, sumOfLikers, onLikeClicked,
-  } = props;
+  const { isGuestUser, isLiked, sumOfLikers, onLikeClicked } = props;
 
   const getTooltipMessage = useCallback(() => {
-
     if (isLiked) {
       return 'tooltip.cancel_like';
     }
@@ -43,7 +37,10 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   }, [isLiked]);
 
   return (
-    <div className={`btn-group btn-group-like ${styles['btn-group-like']}`} role="group" aria-label="Like buttons">
+    <fieldset
+      className={`btn-group btn-group-like ${styles['btn-group-like']}`}
+      aria-label="Like buttons"
+    >
       <button
         type="button"
         id="like-button"
@@ -51,10 +48,17 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         className={`btn btn-like
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <span className={`material-symbols-outlined ${isLiked ? 'fill' : ''}`}>favorite</span>
+        <span className={`material-symbols-outlined ${isLiked ? 'fill' : ''}`}>
+          favorite
+        </span>
       </button>
 
-      <UncontrolledTooltip data-testid="like-button-tooltip" target="like-button" autohide={false} fade={false}>
+      <UncontrolledTooltip
+        data-testid="like-button-tooltip"
+        target="like-button"
+        autohide={false}
+        fade={false}
+      >
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
 
@@ -66,16 +70,27 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
       >
         {sumOfLikers}
       </button>
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
+      <Popover
+        placement="bottom"
+        isOpen={isPopoverOpen}
+        target="po-total-likes"
+        toggle={togglePopover}
+        trigger="legacy"
+      >
+        <PopoverBody
+          className={`user-list-popover ${popoverStyles['user-list-popover']}`}
+        >
           <div className="px-2 text-end user-list-content text-truncate text-muted">
-            {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
+            {props.likers?.length ? (
+              <UserPictureList users={props.likers} />
+            ) : (
+              t('No users have liked this yet.')
+            )}
           </div>
         </PopoverBody>
       </Popover>
-    </div>
+    </fieldset>
   );
-
 };
 
 export default LikeButtons;

+ 147 - 82
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -1,58 +1,65 @@
 import React, {
-  memo, useCallback, useEffect, useMemo, useRef, type JSX,
+  type JSX,
+  memo,
+  useCallback,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
 } from 'react';
-
 import type {
-  IPageInfo, IPageToDeleteWithMeta, IPageToRenameWithMeta,
+  IPageInfo,
+  IPageToDeleteWithMeta,
+  IPageToRenameWithMeta,
 } from '@growi/core';
 import {
   isIPageInfoForEmpty,
-
-  isIPageInfoForEntity, isIPageInfoForOperation,
+  isIPageInfoForEntity,
+  isIPageInfoForOperation,
 } from '@growi/core';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
-import {
-  toggleLike, toggleSubscribe,
-} from '~/client/services/page-operation';
+import { toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSearchPage,
+} from '~/states/context';
 import { useCurrentPagePath } from '~/states/page';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
-import {
-  EditorMode, useEditorMode,
-} from '~/states/ui/editor';
-import { type IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
+import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
 import { useTagEditModalActions } from '~/states/ui/modal/tag-edit';
 import { useSetPageControlsX } from '~/states/ui/page';
 import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../../stores/page';
 import { useSWRxUsersList } from '../../../stores/user';
-import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import type {
+  AdditionalMenuItemsRendererProps,
+  ForceHideMenuItems,
+} from '../Common/Dropdown/PageItemControl';
 import {
   MenuItemType,
   PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
-
 import { BookmarkButtons } from './BookmarkButtons';
 import LikeButtons from './LikeButtons';
 import SearchButton from './SearchButton';
 import SeenUserInfo from './SeenUserInfo';
 import SubscribeButton from './SubscribeButton';
 
-
 import styles from './PageControls.module.scss';
 
 const logger = loggerFactory('growi:components/PageControls');
 
-
 type TagsProps = {
-  onClickEditTagsButton: () => void,
-}
+  onClickEditTagsButton: () => void;
+};
 
 const Tags = (props: TagsProps): JSX.Element => {
   const { onClickEditTagsButton } = props;
@@ -73,27 +80,31 @@ const Tags = (props: TagsProps): JSX.Element => {
 };
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
-  onClick: () => void,
-  expandContentWidth?: boolean,
-}
+  onClick: () => void;
+  expandContentWidth?: boolean;
+};
 
 const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    onClick, expandContentWidth,
-  } = props;
+  const { onClick, expandContentWidth } = props;
+  const wideViewId = useId();
 
   return (
-    <DropdownItem className="grw-page-control-dropdown-item dropdown-item" onClick={onClick} toggle={false}>
+    <DropdownItem
+      className="grw-page-control-dropdown-item dropdown-item"
+      onClick={onClick}
+      toggle={false}
+    >
       <div className="form-check form-switch ms-1">
         <input
           className="form-check-input pe-none"
           type="checkbox"
+          id={wideViewId}
           checked={expandContentWidth}
-          onChange={() => { }}
+          onChange={() => {}}
         />
-        <label className="form-check-label pe-none">
+        <label className="form-check-label pe-none" htmlFor={wideViewId}>
           {t('wide_view')}
         </label>
       </div>
@@ -101,35 +112,50 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   );
 };
 
-
 type CommonProps = {
-  pageId?: string,
-  shareLinkId?: string | null,
-  revisionId?: string | null,
-  path?: string | null,
-  expandContentWidth?: boolean,
-  disableSeenUserInfoPopover?: boolean,
-  hideSubControls?: boolean,
-  showPageControlDropdown?: boolean,
-  forceHideMenuItems?: ForceHideMenuItems,
-  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-  onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void,
-  onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
-  onClickSwitchContentWidth?: (pageId: string, value: boolean) => void,
-}
+  pageId?: string;
+  shareLinkId?: string | null;
+  revisionId?: string | null;
+  path?: string | null;
+  expandContentWidth?: boolean;
+  disableSeenUserInfoPopover?: boolean;
+  hideSubControls?: boolean;
+  showPageControlDropdown?: boolean;
+  forceHideMenuItems?: ForceHideMenuItems;
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
+  onClickDuplicateMenuItem?: (
+    pageToDuplicate: IPageForPageDuplicateModal,
+  ) => void;
+  onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void;
+  onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void;
+  onClickSwitchContentWidth?: (pageId: string, value: boolean) => void;
+};
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfo | undefined,
-  onClickEditTagsButton: () => void,
-}
+  pageInfo: IPageInfo | undefined;
+  onClickEditTagsButton: () => void;
+};
 
-const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element => {
+const PageControlsSubstance = (
+  props: PageControlsSubstanceProps,
+): JSX.Element => {
   const {
     pageInfo,
-    pageId, revisionId, path, shareLinkId, expandContentWidth,
-    disableSeenUserInfoPopover, hideSubControls, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
-    onClickEditTagsButton, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
+    pageId,
+    revisionId,
+    path,
+    shareLinkId,
+    expandContentWidth,
+    disableSeenUserInfoPopover,
+    hideSubControls,
+    showPageControlDropdown,
+    forceHideMenuItems,
+    additionalMenuItemRenderer,
+    onClickEditTagsButton,
+    onClickDuplicateMenuItem,
+    onClickRenameMenuItem,
+    onClickDeleteMenuItem,
+    onClickSwitchContentWidth,
   } = props;
 
   const isGuestUser = useIsGuestUser();
@@ -141,8 +167,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
-  const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
-  const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
+  const likerIds = isIPageInfoForEntity(pageInfo)
+    ? (pageInfo.likerIds ?? []).slice(0, 15)
+    : [];
+  const seenUserIds = isIPageInfoForEntity(pageInfo)
+    ? (pageInfo.seenUserIds ?? []).slice(0, 15)
+    : [];
 
   const setPageControlsX = useSetPageControlsX();
 
@@ -156,11 +186,16 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     setPageControlsX(pageControlsRect.x);
   }, [pageControlsRect?.x, setPageControlsX]);
 
-
   // Put in a mixture of seenUserIds and likerIds data to make the cache work
   const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
-  const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
-  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+  const likers =
+    usersList != null
+      ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15)
+      : [];
+  const seenUsers =
+    usersList != null
+      ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15)
+      : [];
 
   const subscribeClickhandler = useCallback(async () => {
     if (isGuestUser) {
@@ -192,7 +227,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
-      logger.warn('Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null');
+      logger.warn(
+        'Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null',
+      );
       return;
     }
     const page: IPageForPageDuplicateModal = { pageId, path };
@@ -202,7 +239,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickRenameMenuItem == null || pageId == null || path == null) {
-      logger.warn('Cannot rename the page because onClickRenameMenuItem, pageId or path is null');
+      logger.warn(
+        'Cannot rename the page because onClickRenameMenuItem, pageId or path is null',
+      );
       return;
     }
 
@@ -220,7 +259,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickDeleteMenuItem == null || pageId == null || path == null) {
-      logger.warn('Cannot delete the page because onClickDeleteMenuItem, pageId or path is null');
+      logger.warn(
+        'Cannot delete the page because onClickDeleteMenuItem, pageId or path is null',
+      );
       return;
     }
 
@@ -243,7 +284,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
 
     if (onClickSwitchContentWidth == null || pageId == null) {
-      logger.warn('Cannot switch content width because onClickSwitchContentWidth or pageId is null');
+      logger.warn(
+        'Cannot switch content width because onClickSwitchContentWidth or pageId is null',
+      );
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
@@ -254,11 +297,17 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     try {
       const newValue = !expandContentWidth;
       onClickSwitchContentWidth(pageId, newValue);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
-  }, [expandContentWidth, isGuestUser, isReadOnlyUser, onClickSwitchContentWidth, pageId, pageInfo]);
+  }, [
+    expandContentWidth,
+    isGuestUser,
+    isReadOnlyUser,
+    onClickSwitchContentWidth,
+    pageId,
+    pageInfo,
+  ]);
 
   const isEnableActions = useMemo(() => {
     if (isGuestUser) {
@@ -281,10 +330,21 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
 
     const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
-      return <WideViewMenuItem {...props} onClick={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
+      return (
+        <WideViewMenuItem
+          {...props}
+          onClick={switchContentWidthClickHandler}
+          expandContentWidth={expandContentWidth}
+        />
+      );
     };
     return wideviewMenuItemRenderer;
-  }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
+  }, [
+    pageInfo,
+    expandContentWidth,
+    onClickSwitchContentWidth,
+    switchContentWidthClickHandler,
+  ]);
 
   const forceHideMenuItemsWithAdditions = [
     ...(forceHideMenuItems ?? []),
@@ -295,7 +355,10 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const isViewMode = editorMode === EditorMode.View;
 
   return (
-    <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
+    <div
+      className={`${styles['grw-page-controls']} hstack gap-2`}
+      ref={pageControlsRef}
+    >
       {isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <>
           <SearchButton />
@@ -304,9 +367,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       )}
 
       {revisionId != null && !isViewMode && (
-        <Tags
-          onClickEditTagsButton={onClickEditTagsButton}
-        />
+        <Tags onClickEditTagsButton={onClickEditTagsButton} />
       )}
 
       {!hideSubControls && (
@@ -325,13 +386,15 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
               likers={likers}
             />
           )}
-          {(isIPageInfoForOperation(pageInfo) || isIPageInfoForEmpty(pageInfo)) && pageId != null && (
-            <BookmarkButtons
-              pageId={pageId}
-              isBookmarked={pageInfo.isBookmarked}
-              bookmarkCount={pageInfo.bookmarkCount}
-            />
-          )}
+          {(isIPageInfoForOperation(pageInfo) ||
+            isIPageInfoForEmpty(pageInfo)) &&
+            pageId != null && (
+              <BookmarkButtons
+                pageId={pageId}
+                isBookmarked={pageInfo.isBookmarked}
+                bookmarkCount={pageInfo.bookmarkCount}
+              />
+            )}
           {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
@@ -349,7 +412,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
           isEnableActions={isEnableActions}
           isReadOnlyUser={!!isReadOnlyUser}
           forceHideMenuItems={forceHideMenuItemsWithAdditions}
-          additionalMenuItemOnTopRenderer={!isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined}
+          additionalMenuItemOnTopRenderer={
+            !isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined
+          }
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
@@ -363,12 +428,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 type PageControlsProps = CommonProps;
 
 export const PageControls = memo((props: PageControlsProps): JSX.Element => {
-  const {
-    pageId, revisionId, shareLinkId,
-    ...rest
-  } = props;
+  const { pageId, revisionId, shareLinkId, ...rest } = props;
 
-  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: pageInfo, error } = useSWRxPageInfo(
+    pageId ?? null,
+    shareLinkId,
+  );
   const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
   const { open: openTagEditModal } = useTagEditModalActions();
 

+ 1 - 4
apps/app/src/client/components/PageControls/SearchButton.tsx

@@ -1,19 +1,16 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { type JSX, useCallback } from 'react';
 
 import { useSearchModalActions } from '~/features/search/client/states/modal/search';
 
 import styles from './SearchButton.module.scss';
 
-
 const SearchButton = (): JSX.Element => {
-
   const { open: openSearchModal } = useSearchModalActions();
 
   const searchButtonClickHandler = useCallback(() => {
     openSearchModal();
   }, [openSearchModal]);
 
-
   return (
     <button
       type="button"

+ 28 - 12
apps/app/src/client/components/PageControls/SeenUserInfo.tsx

@@ -1,21 +1,18 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
 import type { IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { Popover, PopoverBody, UncontrolledTooltip } from 'reactstrap';
 
 import UserPictureList from '../Common/UserPictureList';
 
-
 import styles from './SeenUserInfo.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
 
-
 interface Props {
-  seenUsers: IUser[],
-  sumOfSeenUsers?: number,
-  disabled?: boolean,
+  seenUsers: IUser[];
+  sumOfSeenUsers?: number;
+  disabled?: boolean;
 }
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
@@ -28,18 +25,37 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
 
   return (
     <div className={`grw-seen-user-info ${styles['grw-seen-user-info']}`}>
-      <button type="button" id="btn-seen-user" className="shadow-none btn btn-seen-user border-0 d-flex align-items-center">
+      <button
+        type="button"
+        id="btn-seen-user"
+        className="shadow-none btn btn-seen-user border-0 d-flex align-items-center"
+      >
         <span className="material-symbols-outlined me-1">footprint</span>
-        <span className="total-counts">{sumOfSeenUsers || seenUsers.length}</span>
+        <span className="total-counts">
+          {sumOfSeenUsers || seenUsers.length}
+        </span>
       </button>
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
-        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
+      <Popover
+        placement="bottom"
+        isOpen={isPopoverOpen}
+        target="btn-seen-user"
+        toggle={togglePopover}
+        trigger="legacy"
+        disabled={disabled}
+      >
+        <PopoverBody
+          className={`user-list-popover ${popoverStyles['user-list-popover']}`}
+        >
           <div className="px-2 text-end user-list-content text-truncate text-muted">
             <UserPictureList users={seenUsers} />
           </div>
         </PopoverBody>
       </Popover>
-      <UncontrolledTooltip data-testid="seen-user-info-tooltip" target="btn-seen-user" fade={false}>
+      <UncontrolledTooltip
+        data-testid="seen-user-info-tooltip"
+        target="btn-seen-user"
+        fade={false}
+      >
         {t('tooltip.footprints')}
       </UncontrolledTooltip>
     </div>

+ 11 - 9
apps/app/src/client/components/PageControls/SubscribeButton.tsx

@@ -1,17 +1,15 @@
 import type { FC } from 'react';
 import React, { useCallback } from 'react';
-
 import { SubscriptionStatusType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import styles from './SubscribeButton.module.scss';
 
-
 type Props = {
-  isGuestUser?: boolean,
-  status?: SubscriptionStatusType,
-  onClick?: () => Promise<void>,
+  isGuestUser?: boolean;
+  status?: SubscriptionStatusType;
+  onClick?: () => Promise<void>;
 };
 
 const SubscribeButton: FC<Props> = (props: Props) => {
@@ -21,7 +19,6 @@ const SubscribeButton: FC<Props> = (props: Props) => {
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
   const getTooltipMessage = useCallback(() => {
-
     if (isSubscribing) {
       return 'tooltip.stop_notification';
     }
@@ -37,17 +34,22 @@ const SubscribeButton: FC<Props> = (props: Props) => {
         className={`shadow-none btn btn-subscribe ${styles['btn-subscribe']} border-0
           ${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <span className={`material-symbols-outlined ${isSubscribing ? 'fill' : ''}`}>
+        <span
+          className={`material-symbols-outlined ${isSubscribing ? 'fill' : ''}`}
+        >
           {isSubscribing ? 'notifications' : 'notifications_off'}
         </span>
       </button>
 
-      <UncontrolledTooltip data-testid="subscribe-button-tooltip" target="subscribe-button" fade={false}>
+      <UncontrolledTooltip
+        data-testid="subscribe-button-tooltip"
+        target="subscribe-button"
+        fade={false}
+      >
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
     </>
   );
-
 };
 
 export default SubscribeButton;

+ 0 - 8
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss

@@ -1,12 +1,4 @@
-@use '@growi/core-styles/scss/bootstrap/init' as bs;
-
 .grw-page-path-nav-sticky :global {
-  .sticky-inner-wrapper {
-    z-index: bs.$zindex-sticky;
-  }
-
-  // TODO:Responsive font size
-  // set smaller font-size when sticky
   .sticky-inner-wrapper.active {
     h1 {
       font-size: 1.75rem !important;

+ 34 - 25
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -25,7 +25,7 @@ const { isTrashPage } = pagePathUtils;
 
 
 export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element => {
-  const { pagePath } = props;
+  const { pagePath, latterLinkClassName, ...rest } = props;
 
   const isPrinting = usePrintMode();
 
@@ -84,33 +84,42 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     // Controlling pointer-events
     //  1. disable pointer-events with 'pe-none'
     <div ref={pagePathNavRef}>
-      <Sticky className={moduleClass} enabled={!isPrinting} innerClass="pe-none" innerActiveClass="active mt-1">
+      <Sticky className={moduleClass} enabled={!isPrinting} innerClass="z-2 pe-none" innerActiveClass="active z-3 mt-1">
         {({ status }) => {
-          const isParentsCollapsed = status === Sticky.STATUS_FIXED;
-
-          // Controlling pointer-events
-          //  2. enable pointer-events with 'pe-auto' only against the children
-          //      which width is minimized by 'd-inline-block'
-          //
-          if (isParentsCollapsed) {
-            return (
-              <div className="d-inline-block pe-auto">
-                <PagePathNavLayout
-                  {...props}
-                  latterLink={latterLink}
-                  latterLinkClassName="fs-3 text-truncate"
-                  maxWidth={navMaxWidth}
-                />
-              </div>
-            );
-          }
+          const isStatusFixed = status === Sticky.STATUS_FIXED;
 
           return (
-            // Use 'd-block' to make the children take the full width
-            // This is to improve UX when opening/closing CopyDropdown
-            <div className="d-block pe-auto">
-              <PagePathNav {...props} inline />
-            </div>
+            <>
+              {/*
+                * Controlling pointer-events
+                * 2. enable pointer-events with 'pe-auto' only against the children
+                *      which width is minimized by 'd-inline-block'
+                */}
+              { isStatusFixed && (
+                <div className="d-inline-block pe-auto position-absolute">
+                  <PagePathNavLayout
+                    pagePath={pagePath}
+                    latterLink={latterLink}
+                    latterLinkClassName={`${latterLinkClassName} text-truncate`}
+                    maxWidth={navMaxWidth}
+                    {...rest}
+                  />
+                </div>
+              )}
+
+              {/*
+                * Use 'd-block' to make the children take the full width
+                * This is to improve UX when opening/closing CopyDropdown
+                */}
+              <div className={`d-block pe-auto ${isStatusFixed ? 'invisible' : ''}`}>
+                <PagePathNav
+                  pagePath={pagePath}
+                  latterLinkClassName={latterLinkClassName}
+                  inline
+                  {...rest}
+                />
+              </div>
+            </>
           );
         }}
       </Sticky>

+ 1 - 1
apps/app/src/client/components/TrashPageList.tsx

@@ -123,7 +123,7 @@ export const TrashPageList = (): JSX.Element => {
   }, [t]);
 
   return (
-    <div data-testid="trash-page-list" className="mt-5 d-edit-none">
+    <div data-testid="trash-page-list" className="d-edit-none">
       <CustomNavAndContents
         navTabMapping={navTabMapping}
         navRightElement={emptyTrashButton}

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

@@ -1,5 +0,0 @@
-@use '@growi/core-styles/scss/bootstrap/init' as bs;
-
-.grw-page-path-nav-title :global {
-  min-height: 75px;
-}

+ 2 - 12
apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx

@@ -6,10 +6,6 @@ import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 import type { PagePathNavLayoutProps } from '../PagePathNav';
 import { PagePathNav } from '../PagePathNav';
 
-import styles from './PagePathNavTitle.module.scss';
-
-const moduleClass = styles['grw-page-path-nav-title'] ?? '';
-
 const PagePathNavSticky = withLoadingProps<PagePathNavLayoutProps>(
   (useLoadingProps) =>
     dynamic(
@@ -42,15 +38,9 @@ export const PagePathNavTitle = (
     setClient(true);
   }, []);
 
-  const className = `${moduleClass} mb-4`;
-
   return isClient ? (
-    <PagePathNavSticky
-      {...props}
-      className={className}
-      latterLinkClassName="fs-2"
-    />
+    <PagePathNavSticky {...props} latterLinkClassName="fs-2" />
   ) : (
-    <PagePathNav {...props} className={className} latterLinkClassName="fs-2" />
+    <PagePathNav {...props} latterLinkClassName="fs-2" />
   );
 };

+ 1 - 1
apps/app/src/components/PageView/PageContentFooter.tsx

@@ -27,7 +27,7 @@ export const PageContentFooter = (
   }
 
   return (
-    <div className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none`}>
+    <div className={`${styles['page-content-footer']} py-4 d-edit-none`}>
       <div className="page-meta">
         <AuthorInfo
           user={creator}

+ 14 - 6
apps/app/src/components/PageView/PageView.tsx

@@ -1,4 +1,12 @@
-import { type JSX, memo, useCallback, useEffect, useMemo, useRef } from 'react';
+import {
+  type JSX,
+  memo,
+  useCallback,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
+} from 'react';
 import dynamic from 'next/dynamic';
 import { isDeepEquals } from '@growi/core/dist/utils/is-deep-equals';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
@@ -103,6 +111,8 @@ const PageViewComponent = (props: Props): JSX.Element => {
   const isNotCreatable = useIsNotCreatable();
   const isNotFoundMeta = usePageNotFound();
 
+  const contentContainerId = useId();
+
   const page = useCurrentPageData();
   const { data: viewOptions } = useViewOptions();
 
@@ -129,9 +139,7 @@ const PageViewComponent = (props: Props): JSX.Element => {
       return;
     }
 
-    const contentContainer = document.getElementById(
-      'page-view-content-container',
-    );
+    const contentContainer = document.getElementById(contentContainerId);
     if (contentContainer == null) return;
 
     const targetId = decodeURIComponent(hash.slice(1));
@@ -152,7 +160,7 @@ const PageViewComponent = (props: Props): JSX.Element => {
     observer.observe(contentContainer, { childList: true, subtree: true });
 
     return () => observer.disconnect();
-  }, [currentPageId]);
+  }, [currentPageId, contentContainerId]);
 
   // *******************************  end  *******************************
 
@@ -252,7 +260,7 @@ const PageViewComponent = (props: Props): JSX.Element => {
           {isUsersHomepagePath && page?.creator != null && (
             <UserInfo author={page.creator} />
           )}
-          <div id="page-view-content-container" className="flex-expand-vert">
+          <div id={contentContainerId} className="flex-expand-vert">
             <Contents />
           </div>
         </>

+ 1 - 1
apps/app/src/components/PageView/PageViewLayout.tsx

@@ -36,7 +36,7 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       <div
         className={`main ${className} ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert ps-sidebar`}
       >
-        <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert">
+        <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert gap-4">
           {headerContents != null && headerContents}
           {!isPrinting && sideContents != null ? (
             <div className="flex-expand-horiz gap-3 z-0">

+ 4 - 2
apps/app/src/pages/trash/index.page.tsx

@@ -67,8 +67,10 @@ const TrashPage: NextPageWithLayout<Props> = (props: Props) => {
 
         <div className="main ps-sidebar">
           <div className="container-lg wide-gutter-x-lg">
-            <PagePathNavTitle pagePath="/trash" />
-            <TrashPageList />
+            <div className="d-flex flex-column gap-4">
+              <PagePathNavTitle pagePath="/trash" />
+              <TrashPageList />
+            </div>
           </div>
         </div>
       </div>

+ 0 - 5
biome.json

@@ -30,18 +30,13 @@
       "!packages/pdf-converter-client/specs",
       "!apps/app/src/client/components/Admin",
       "!apps/app/src/client/components/Bookmarks",
-      "!apps/app/src/client/components/Common",
       "!apps/app/src/client/components/DescendantsPageListModal",
-      "!apps/app/src/client/components/Icons",
       "!apps/app/src/client/components/InAppNotification",
       "!apps/app/src/client/components/ItemsTree",
       "!apps/app/src/client/components/LoginForm",
       "!apps/app/src/client/components/Me",
       "!apps/app/src/client/components/Page",
-      "!apps/app/src/client/components/PageAccessoriesModal",
       "!apps/app/src/client/components/PageAttachment",
-      "!apps/app/src/client/components/PageComment",
-      "!apps/app/src/client/components/PageControls",
       "!apps/app/src/client/components/PageDeleteModal",
       "!apps/app/src/client/components/PageDuplicateModal",
       "!apps/app/src/client/components/PageList",