Browse Source

Merge pull request #10649 from growilabs/master

Release v7.4.1
mergify[bot] 3 months ago
parent
commit
e6f759bff3
100 changed files with 3116 additions and 2200 deletions
  1. 1 1
      .github/workflows/reusable-app-build-image.yml
  2. 1 0
      .gitignore
  3. 5 0
      apps/app/.eslintrc.js
  4. 3 0
      apps/app/.gitignore
  5. BIN
      apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c
  6. 3 0
      apps/app/docker/codebuild/.terraform.lock.hcl
  7. 1 1
      apps/app/docker/codebuild/main.tf
  8. 8 0
      apps/app/docker/codebuild/oidc.tf
  9. 1 1
      apps/app/package.json
  10. 7 6
      apps/app/src/client/components/AlertSiteUrlUndefined.tsx
  11. 27 21
      apps/app/src/client/components/Comments.tsx
  12. 2 2
      apps/app/src/client/components/CompleteUserRegistration.tsx
  13. 74 58
      apps/app/src/client/components/CompleteUserRegistrationForm.tsx
  14. 31 7
      apps/app/src/client/components/ContentLinkButtons.tsx
  15. 20 7
      apps/app/src/client/components/DataTransferForm.tsx
  16. 77 51
      apps/app/src/client/components/DescendantsPageList.tsx
  17. 16 19
      apps/app/src/client/components/DuplicatedPathsTable.tsx
  18. 3 5
      apps/app/src/client/components/EmptyTrashButton.tsx
  19. 4 5
      apps/app/src/client/components/ErrorBoudary.jsx
  20. 6 6
      apps/app/src/client/components/ExpandOrContractButton.tsx
  21. 11 6
      apps/app/src/client/components/ForbiddenPage.tsx
  22. 10 6
      apps/app/src/client/components/FormattedDistanceDate.jsx
  23. 25 16
      apps/app/src/client/components/IdenticalPathPage.tsx
  24. 20 19
      apps/app/src/client/components/InfiniteScroll.tsx
  25. 143 112
      apps/app/src/client/components/InstallerForm.tsx
  26. 50 38
      apps/app/src/client/components/InvitedForm.tsx
  27. 15 13
      apps/app/src/client/components/NotAvailable.tsx
  28. 19 18
      apps/app/src/client/components/NotAvailableForGuest.tsx
  29. 17 17
      apps/app/src/client/components/NotAvailableForNow.tsx
  30. 19 16
      apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx
  31. 20 7
      apps/app/src/client/components/NotAvailableForReadOnlyUser.tsx
  32. 4 3
      apps/app/src/client/components/NotCreatablePage.tsx
  33. 22 9
      apps/app/src/client/components/NotFoundPage.tsx
  34. 243 203
      apps/app/src/client/components/PageComment.tsx
  35. 139 76
      apps/app/src/client/components/PageCreateModal.tsx
  36. 8 13
      apps/app/src/client/components/PagePathAutoComplete.jsx
  37. 36 13
      apps/app/src/client/components/PageStatusAlert.tsx
  38. 15 16
      apps/app/src/client/components/PageTimeline.tsx
  39. 46 27
      apps/app/src/client/components/PaginationWrapper.tsx
  40. 24 17
      apps/app/src/client/components/PasswordResetExecutionForm.tsx
  41. 28 22
      apps/app/src/client/components/PasswordResetRequestForm.tsx
  42. 45 24
      apps/app/src/client/components/PrivateLegacyPagesMigrationModal.tsx
  43. 82 13
      apps/app/src/client/components/RecentActivity/ActivityListItem.tsx
  44. 8 6
      apps/app/src/client/components/RecentActivity/RecentActivity.tsx
  45. 169 117
      apps/app/src/client/components/SearchTypeahead.tsx
  46. 55 49
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx
  47. 2 6
      apps/app/src/client/components/Sidebar/Bookmarks.tsx
  48. 20 22
      apps/app/src/client/components/Sidebar/Bookmarks/BookmarkContents.tsx
  49. 14 6
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  50. 15 7
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  51. 11 12
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.tsx
  52. 18 10
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  53. 38 28
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  54. 51 39
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  55. 7 3
      apps/app/src/client/components/Sidebar/PageCreateButton/CreateButton.tsx
  56. 63 64
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendMenu.tsx
  57. 3 4
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.tsx
  58. 20 14
      apps/app/src/client/components/Sidebar/PageCreateButton/Hexagon.tsx
  59. 22 12
      apps/app/src/client/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  60. 4 6
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  61. 12 15
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  62. 5 6
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  63. 133 115
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  64. 3 4
      apps/app/src/client/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  65. 4 9
      apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx
  66. 5 3
      apps/app/src/client/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx
  67. 92 62
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  68. 61 41
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  69. 15 7
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx
  70. 12 6
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesContentSkeleton.tsx
  71. 169 110
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  72. 4 0
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.module.scss
  73. 63 54
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.tsx
  74. 7 15
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableAreaFallback.tsx
  75. 9 9
      apps/app/src/client/components/Sidebar/ResizableArea/props.d.ts
  76. 196 147
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  77. 15 5
      apps/app/src/client/components/Sidebar/SidebarBrandLogo.tsx
  78. 9 4
      apps/app/src/client/components/Sidebar/SidebarContents.tsx
  79. 4 6
      apps/app/src/client/components/Sidebar/SidebarHead/SidebarHead.tsx
  80. 8 8
      apps/app/src/client/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx
  81. 6 3
      apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx
  82. 38 20
      apps/app/src/client/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  83. 52 34
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx
  84. 49 12
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx
  85. 25 15
      apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx
  86. 6 5
      apps/app/src/client/components/Sidebar/SidebarNav/SidebarNav.tsx
  87. 0 1
      apps/app/src/client/components/Sidebar/SidebarNav/SkeletonItem.tsx
  88. 12 5
      apps/app/src/client/components/Sidebar/Skeleton/DefaultContentSkeleton.tsx
  89. 3 2
      apps/app/src/client/components/Sidebar/Skeleton/TagContentSkeleton.tsx
  90. 29 27
      apps/app/src/client/components/Sidebar/Tag.tsx
  91. 7 9
      apps/app/src/client/components/Skeleton.tsx
  92. 21 10
      apps/app/src/client/components/SlackNotification.tsx
  93. 27 14
      apps/app/src/client/components/StickyStretchableScroller.tsx
  94. 15 10
      apps/app/src/client/components/SystemVersion.tsx
  95. 15 9
      apps/app/src/client/components/TableOfContents.tsx
  96. 16 18
      apps/app/src/client/components/TagCloudBox.tsx
  97. 37 32
      apps/app/src/client/components/TagList.tsx
  98. 3 3
      apps/app/src/client/components/TemplateTab.tsx
  99. 57 22
      apps/app/src/client/components/TrashPageList.tsx
  100. 21 24
      apps/app/src/client/components/UnsavedAlertDialog.tsx

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -40,7 +40,7 @@ jobs:
       with:
         aws-region: ap-northeast-1
         role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
-        role-session-name: SessionForReleaseGROWI-RC
+        role-session-name: GitHubActions-SessionForReleaseGROWI-${{ github.run_id }}
 
     - name: Run CodeBuild
       uses: dark-mechanicum/aws-codebuild@v1

+ 1 - 0
.gitignore

@@ -33,6 +33,7 @@ yarn-error.log*
 # Terraform
 **/.terraform/*
 *.tfstate.*
+/aws/
 
 # IDE, dev #
 .idea

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

@@ -37,6 +37,11 @@ module.exports = {
     'src/interfaces/**',
     'src/utils/**',
     'src/components/**',
+    'src/client/components/*.tsx',
+    'src/client/components/*.jsx',
+    'src/client/components/*.ts',
+    'src/client/components/*.js',
+    'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',
     'src/stores/**',

+ 3 - 0
apps/app/.gitignore

@@ -14,3 +14,6 @@
 /public/uploads
 /src/styles/prebuilt
 /tmp/
+
+# cache
+/.swc/

BIN
apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c


+ 3 - 0
apps/app/docker/codebuild/.terraform.lock.hcl

@@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" {
   version     = "6.12.0"
   constraints = ">= 5.0.0, >= 6.0.0, ~> 6.0"
   hashes = [
+    "h1:8u90EMle+I3Auh4f/LPP6fEfRsAF6xCFnUZF4b7ngEs=",
     "h1:QiSzB4pjONZ4hek1L8Rcd6S9vtP+yMr5iOfczJg5/JI=",
     "zh:054bcbf13c6ac9ddd2247876f82f9b56493e2f71d8c88baeec142386a395165d",
     "zh:195489f16ad5621db2cec80be997d33060462a3b8d442c890bef3eceba34fa4d",
@@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" {
   version     = "3.7.2"
   constraints = ">= 2.1.0"
   hashes = [
+    "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=",
     "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
     "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
     "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
@@ -48,6 +50,7 @@ provider "registry.terraform.io/hashicorp/tls" {
   version     = "4.1.0"
   constraints = ">= 4.0.0"
   hashes = [
+    "h1:Ka8mEwRFXBabR33iN/WTIEW6RP0z13vFsDlwn11Pf2I=",
     "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
     "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
     "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",

+ 1 - 1
apps/app/docker/codebuild/main.tf

@@ -18,6 +18,6 @@ terraform {
 }
 
 provider "aws" {
-  profile = "weseek"
+  profile = "weseek-tf"
   region  = "ap-northeast-1"
 }

+ 8 - 0
apps/app/docker/codebuild/oidc.tf

@@ -23,4 +23,12 @@ data "aws_iam_policy_document" "policy_document" {
       module.codebuild.project_arn
     ]
   }
+  statement {
+    actions = [
+      "logs:GetLogEvents"
+    ]
+    resources = [
+      "arn:aws:logs:*:*:log-group:/aws/codebuild/${module.codebuild.project_name}:*"
+    ]
+  }
 }

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.0",
+  "version": "7.4.1-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

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

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useSiteUrl } from '~/states/global';
@@ -9,8 +8,7 @@ const isValidUrl = (str: string): boolean => {
     // eslint-disable-next-line no-new
     new URL(str);
     return true;
-  }
-  catch {
+  } catch {
     return false;
   }
 };
@@ -26,9 +24,12 @@ export const AlertSiteUrlUndefined = (): JSX.Element => {
   return (
     <div className="alert alert-danger rounded-0 d-edit-none mb-0 px-4 py-2">
       <span className="material-symbols-outlined">error</span>
-      {
-        t('alert.siteUrl_is_not_set', { link: t('headers.app_settings') })
-      } &gt;&gt; <a href="/admin/app">{t('headers.app_settings')}<span className="material-symbols-outlined">login</span></a>
+      {t('alert.siteUrl_is_not_set', { link: t('headers.app_settings') })}{' '}
+      &gt;&gt;{' '}
+      <a href="/admin/app">
+        {t('headers.app_settings')}
+        <span className="material-symbols-outlined">login</span>
+      </a>
     </div>
   );
 };

+ 27 - 21
apps/app/src/client/components/Comments.tsx

@@ -1,11 +1,8 @@
-import React, {
-  useEffect, useMemo, useRef, type JSX,
-} from 'react';
-
+import React, { type JSX, useEffect, useMemo, useRef } from 'react';
+import dynamic from 'next/dynamic';
 import type { IRevisionHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
 import { debounce } from 'throttle-debounce';
 
 import { useCurrentUser } from '~/states/global';
@@ -13,25 +10,28 @@ import { useIsTrashPage } from '~/states/page';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
-
 const { isTopPage } = pagePathUtils;
 
-
-const PageComment = dynamic(() => import('~/client/components/PageComment').then(mod => mod.PageComment), { ssr: false });
-const CommentEditorPre = dynamic(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditorPre), { ssr: false });
+const PageComment = dynamic(
+  () =>
+    import('~/client/components/PageComment').then((mod) => mod.PageComment),
+  { ssr: false },
+);
+const CommentEditorPre = dynamic(
+  () =>
+    import('./PageComment/CommentEditor').then((mod) => mod.CommentEditorPre),
+  { ssr: false },
+);
 
 type CommentsProps = {
-  pageId: string,
-  pagePath: string,
-  revision: IRevisionHasId,
-  onLoaded?: () => void,
-}
+  pageId: string;
+  pagePath: string;
+  revision: IRevisionHasId;
+  onLoaded?: () => void;
+};
 
 export const Comments = (props: CommentsProps): JSX.Element => {
-
-  const {
-    pageId, pagePath, revision, onLoaded,
-  } = props;
+  const { pageId, pagePath, revision, onLoaded } = props;
 
   const { t } = useTranslation('');
 
@@ -42,7 +42,10 @@ export const Comments = (props: CommentsProps): JSX.Element => {
 
   const pageCommentParentRef = useRef<HTMLDivElement>(null);
 
-  const onLoadedDebounced = useMemo(() => debounce(500, () => onLoaded?.()), [onLoaded]);
+  const onLoadedDebounced = useMemo(
+    () => debounce(500, () => onLoaded?.()),
+    [onLoaded],
+  );
 
   useEffect(() => {
     const parent = pageCommentParentRef.current;
@@ -73,7 +76,11 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   return (
     <div className="page-comments-row mt-5 py-4 border-top d-edit-none d-print-none">
       <h4 className="mb-3">{t('page_comment.comments')}</h4>
-      <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
+      <div
+        id="page-comments-list"
+        className="page-comments-list"
+        ref={pageCommentParentRef}
+      >
         <PageComment
           pageId={pageId}
           pagePath={pagePath}
@@ -93,5 +100,4 @@ export const Comments = (props: CommentsProps): JSX.Element => {
       )}
     </div>
   );
-
 };

+ 2 - 2
apps/app/src/client/components/CompleteUserRegistration.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 export const CompleteUserRegistration: FC = () => {
@@ -15,7 +14,8 @@ export const CompleteUserRegistration: FC = () => {
           </p>
           {/* If the transition source is "/login", use <a /> tag since the transition will not occur if next/link is used. */}
           <a href="/login">
-            <span className="material-symbols-outlined">login</span>{t('Sign in is here')}
+            <span className="material-symbols-outlined">login</span>
+            {t('Sign in is here')}
           </a>
         </div>
       </div>

+ 74 - 58
apps/app/src/client/components/CompleteUserRegistrationForm.tsx

@@ -1,32 +1,28 @@
-import React, { useState, useEffect, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import type React from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
 
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 
 import { toastError } from '../util/toastr';
-
 import { CompleteUserRegistration } from './CompleteUserRegistration';
 
-
 import styles from './CompleteUserRegistrationForm.module.scss';
 
 const moduleClass = styles['complete-user-registration-form'] ?? '';
 
-
 interface Props {
-  email: string,
-  token: string,
-  errorCode?: UserActivationErrorCode,
-  registrationMode: RegistrationMode,
-  isEmailAuthenticationEnabled: boolean,
+  email: string;
+  token: string;
+  errorCode?: UserActivationErrorCode;
+  registrationMode: RegistrationMode;
+  isEmailAuthenticationEnabled: boolean;
 }
 
 export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
-
   const { t } = useTranslation();
   const {
     email,
@@ -48,14 +44,13 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
   const router = useRouter();
 
   useEffect(() => {
-    const delayDebounceFn = setTimeout(async() => {
+    const delayDebounceFn = setTimeout(async () => {
       try {
         const { data } = await apiv3Get('/check-username', { username });
         if (data.ok) {
           setUsernameAvailable(data.valid);
         }
-      }
-      catch (error) {
+      } catch (error) {
         toastError(error);
       }
     }, 500);
@@ -63,64 +58,83 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
     return () => clearTimeout(delayDebounceFn);
   }, [username]);
 
-  const handleSubmitRegistration = useCallback(async(e) => {
-    e.preventDefault();
-    setDisableForm(true);
-    try {
-      const res = await apiv3Post('/complete-registration', {
-        username, name, password, token,
-      });
-
-      setIsSuccessToRagistration(true);
-
-      const { redirectTo } = res.data;
-      if (redirectTo != null) {
-        router.push(redirectTo);
+  const handleSubmitRegistration = useCallback(
+    async (e) => {
+      e.preventDefault();
+      setDisableForm(true);
+      try {
+        const res = await apiv3Post('/complete-registration', {
+          username,
+          name,
+          password,
+          token,
+        });
+
+        setIsSuccessToRagistration(true);
+
+        const { redirectTo } = res.data;
+        if (redirectTo != null) {
+          router.push(redirectTo);
+        }
+      } catch (err) {
+        toastError(err);
+        setDisableForm(false);
+        setIsSuccessToRagistration(false);
       }
-    }
-    catch (err) {
-      toastError(err);
-      setDisableForm(false);
-      setIsSuccessToRagistration(false);
-    }
-  }, [username, name, password, token, router]);
-
-  if (isSuccessToRagistration && registrationMode === RegistrationMode.RESTRICTED) {
+    },
+    [username, name, password, token, router],
+  );
+
+  if (
+    isSuccessToRagistration &&
+    registrationMode === RegistrationMode.RESTRICTED
+  ) {
     return <CompleteUserRegistration />;
   }
 
   return (
     <>
-      <div className={`${moduleClass} nologin-dialog mx-auto rounded-4 rounded-top-0`} id="nologin-dialog">
+      <div
+        className={`${moduleClass} nologin-dialog mx-auto rounded-4 rounded-top-0`}
+        id="nologin-dialog"
+      >
         <div className="row mx-0">
           <div className="col-12 px-4">
+            {errorCode != null &&
+              errorCode === UserActivationErrorCode.TOKEN_NOT_FOUND && (
+                <p className="alert alert-danger">
+                  <span>Token not found</span>
+                </p>
+              )}
 
-            { (errorCode != null && errorCode === UserActivationErrorCode.TOKEN_NOT_FOUND) && (
-              <p className="alert alert-danger">
-                <span>Token not found</span>
-              </p>
-            )}
-
-            { (errorCode != null && errorCode === UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE) && (
-              <p className="alert alert-danger">
-                <span>{t('message.incorrect_token_or_expired_url')}</span>
-              </p>
-            )}
+            {errorCode != null &&
+              errorCode ===
+                UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE && (
+                <p className="alert alert-danger">
+                  <span>{t('message.incorrect_token_or_expired_url')}</span>
+                </p>
+              )}
 
-            { !isEmailAuthenticationEnabled && (
+            {!isEmailAuthenticationEnabled && (
               <p className="alert alert-danger">
                 <span>{t('message.email_authentication_is_not_enabled')}</span>
               </p>
             )}
 
-            <form role="form" onSubmit={handleSubmitRegistration} id="registration-form">
+            <form onSubmit={handleSubmitRegistration} id="registration-form">
               <input type="hidden" name="token" value={token} />
 
               <div className="input-group">
                 <span className="p-2 text-white opacity-75">
                   <span className="material-symbols-outlined">mail</span>
                 </span>
-                <input type="text" className="form-control rounded" placeholder={t('Email')} disabled value={email} />
+                <input
+                  type="text"
+                  className="form-control rounded"
+                  placeholder={t('Email')}
+                  disabled
+                  value={email}
+                />
               </div>
 
               <div className="input-group" id="input-group-username">
@@ -132,7 +146,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   className="form-control rounded"
                   placeholder={t('User ID')}
                   name="username"
-                  onChange={e => setUsername(e.target.value)}
+                  onChange={(e) => setUsername(e.target.value)}
                   required
                   disabled={forceDisableForm || disableForm}
                 />
@@ -158,7 +172,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   placeholder={t('Name')}
                   name="name"
                   value={name}
-                  onChange={e => setName(e.target.value)}
+                  onChange={(e) => setName(e.target.value)}
                   required
                   disabled={forceDisableForm || disableForm}
                 />
@@ -174,7 +188,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   placeholder={t('Password')}
                   name="password"
                   value={password}
-                  onChange={e => setPassword(e.target.value)}
+                  onChange={(e) => setPassword(e.target.value)}
                   required
                   disabled={forceDisableForm || disableForm}
                 />
@@ -187,7 +201,9 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   className="btn btn-secondary btn-register col-6 mx-auto d-flex"
                 >
                   <span>
-                    <span className="material-symbols-outlined">person_add</span>
+                    <span className="material-symbols-outlined">
+                      person_add
+                    </span>
                   </span>
                   <span className="flex-grow-1">{t('Create')}</span>
                 </button>
@@ -195,7 +211,8 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
 
               <div className="input-group mt-5 d-flex">
                 <a href="https://growi.org" className="link-growi-org">
-                  <span className="growi">GROWI</span><span className="org">.org</span>
+                  <span className="growi">GROWI</span>
+                  <span className="org">.org</span>
                 </a>
               </div>
             </form>
@@ -204,5 +221,4 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
       </div>
     </>
   );
-
 };

+ 31 - 7
apps/app/src/client/components/ContentLinkButtons.tsx

@@ -1,13 +1,18 @@
 import React, { type JSX } from 'react';
-
-import { USER_STATUS, type IUserHasId } from '@growi/core';
+import { type IUserHasId, USER_STATUS } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { Link as ScrollLink } from 'react-scroll';
 
+import {
+  BOOKMARKS_LIST_ID,
+  RECENT_ACTIVITY_LIST_ID,
+  RECENTLY_CREATED_LIST_ID,
+} from './UsersHomepageFooter.consts';
+
 const BookMarkLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to="bookmarks-list" offset={-120}>
+    <ScrollLink to={BOOKMARKS_LIST_ID} offset={-120}>
       <button
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
@@ -24,7 +29,7 @@ BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 const RecentlyCreatedLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to="recently-created-list" offset={-120}>
+    <ScrollLink to={RECENTLY_CREATED_LIST_ID} offset={-120}>
       <button
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
@@ -38,12 +43,30 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
+const RecentActivityLinkButton = React.memo(() => {
+  const { t } = useTranslation();
+  return (
+    <ScrollLink to={RECENT_ACTIVITY_LIST_ID} offset={-120}>
+      <button
+        type="button"
+        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
+      >
+        <span className="material-symbols-outlined mx-1">update</span>
+        <span>{t('user_home_page.recent_activity')}</span>
+      </button>
+    </ScrollLink>
+  );
+});
+
+RecentActivityLinkButton.displayName = 'RecentActivityLinkButton';
 
 export type ContentLinkButtonsProps = {
-  author?: IUserHasId,
-}
+  author?: IUserHasId;
+};
 
-export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
+export const ContentLinkButtons = (
+  props: ContentLinkButtonsProps,
+): JSX.Element => {
   const { author } = props;
 
   if (author == null || author.status === USER_STATUS.DELETED) {
@@ -54,6 +77,7 @@ export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element
     <div className="d-grid gap-2">
       <BookMarkLinkButton />
       <RecentlyCreatedLinkButton />
+      <RecentActivityLinkButton />
     </div>
   );
 };

+ 20 - 7
apps/app/src/client/components/DataTransferForm.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
@@ -15,19 +14,31 @@ const DataTransferForm = (): JSX.Element => {
   return (
     <div data-testid="installerForm" className="py-3 px-4">
       <p className="text-white fs-5 mt-2">
-        <strong>{ t('g2g_data_transfer.transfer_data_to_this_growi')}</strong>
+        <strong>{t('g2g_data_transfer.transfer_data_to_this_growi')}</strong>
       </p>
 
       <div className="row mt-3">
         <div className="col-md-12">
-          <button type="button" className="btn btn-primary w-100" onClick={generateTransferKey}>
+          <button
+            type="button"
+            className="btn btn-primary w-100"
+            onClick={generateTransferKey}
+          >
             {t('g2g_data_transfer.publish_transfer_key')}
           </button>
         </div>
         <div className="col-md-12 mt-2">
           <div className="d-flex">
-            <input className="form-control" type="text" value={transferKey} readOnly />
-            <CustomCopyToClipBoard textToBeCopied={transferKey} message="copied_to_clipboard" />
+            <input
+              className="form-control"
+              type="text"
+              value={transferKey}
+              readOnly
+            />
+            <CustomCopyToClipBoard
+              textToBeCopied={transferKey}
+              message="copied_to_clipboard"
+            />
           </div>
         </div>
       </div>
@@ -37,9 +48,11 @@ const DataTransferForm = (): JSX.Element => {
         <p className="mb-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
         <p
           className="mb-0"
-          // eslint-disable-next-line react/no-danger
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML link
           dangerouslySetInnerHTML={{
-            __html: t('g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+            __html: t('g2g_data_transfer.transfer_to_growi_cloud', {
+              documentationUrl,
+            }),
           }}
         />
       </div>

+ 77 - 51
apps/app/src/client/components/DescendantsPageList.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import type {
   IDataWithMeta,
   IPageHasId,
@@ -11,10 +10,16 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPagingResult } from '~/interfaces/paging-result';
 import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import {
   mutatePageTree,
-  useSWRxPageInfoForList, useSWRxPageList, mutateRecentlyUpdated,
+  mutateRecentlyUpdated,
+  useSWRxPageInfoForList,
+  useSWRxPageList,
 } from '~/stores/page-listing';
 
 import type { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
@@ -22,30 +27,36 @@ import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
 type SubstanceProps = {
-  pagingResult: IPagingResult<IPageHasId> | undefined,
-  activePage: number,
-  setActivePage: (activePage: number) => void,
-  forceHideMenuItems?: ForceHideMenuItems,
-  onPagesDeleted?: OnDeletedFunction,
-  onPagePutBacked?: OnPutBackedFunction,
-}
-
-const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> => {
+  pagingResult: IPagingResult<IPageHasId> | undefined;
+  activePage: number;
+  setActivePage: (activePage: number) => void;
+  forceHideMenuItems?: ForceHideMenuItems;
+  onPagesDeleted?: OnDeletedFunction;
+  onPagePutBacked?: OnPutBackedFunction;
+};
+
+const convertToIDataWithMeta = (
+  page: IPageHasId,
+): IDataWithMeta<IPageHasId> => {
   return { data: page };
 };
 
 const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
-
   const { t } = useTranslation();
 
   const {
-    pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+    pagingResult,
+    activePage,
+    setActivePage,
+    forceHideMenuItems,
+    onPagesDeleted,
+    onPagePutBacked,
   } = props;
 
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
 
-  const pageIds = pagingResult?.items?.map(page => page._id);
+  const pageIds = pagingResult?.items?.map((page) => page._id);
   const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
@@ -53,36 +64,43 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
   // initial data
   if (pagingResult != null) {
     // convert without meta at first
-    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    const dataWithMetas = pagingResult.items.map((page) =>
+      convertToIDataWithMeta(page),
+    );
     // inject data for listing
     pageWithMetas = injectTo(dataWithMetas);
   }
 
-  const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
-    const path = args[0];
-    const isCompletely = args[2];
-    if (path == null || isCompletely == null) {
-      toastSuccess(t('deleted_page'));
-    }
-    else {
-      toastSuccess(t('deleted_pages_completely', { path }));
-    }
-    mutateRecentlyUpdated();
-    mutatePageTree();
-    if (onPagesDeleted != null) {
-      onPagesDeleted(...args);
-    }
-  }, [onPagesDeleted, t]);
-
-  const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
-    toastSuccess(t('page_has_been_reverted', { path }));
-
-    mutateRecentlyUpdated();
-    mutatePageTree();
-    if (onPagePutBacked != null) {
-      onPagePutBacked(path);
-    }
-  }, [onPagePutBacked, t]);
+  const pageDeletedHandler: OnDeletedFunction = useCallback(
+    (...args) => {
+      const path = args[0];
+      const isCompletely = args[2];
+      if (path == null || isCompletely == null) {
+        toastSuccess(t('deleted_page'));
+      } else {
+        toastSuccess(t('deleted_pages_completely', { path }));
+      }
+      mutateRecentlyUpdated();
+      mutatePageTree();
+      if (onPagesDeleted != null) {
+        onPagesDeleted(...args);
+      }
+    },
+    [onPagesDeleted, t],
+  );
+
+  const pagePutBackedHandler: OnPutBackedFunction = useCallback(
+    (path) => {
+      toastSuccess(t('page_has_been_reverted', { path }));
+
+      mutateRecentlyUpdated();
+      mutatePageTree();
+      if (onPagePutBacked != null) {
+        onPagePutBacked(path);
+      }
+    },
+    [onPagePutBacked, t],
+  );
 
   if (pagingResult == null) {
     return (
@@ -107,35 +125,43 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
         onPagePutBacked={pagePutBackedHandler}
       />
 
-      { showPager && (
+      {showPager && (
         <div className="my-4">
           <PaginationWrapper
             activePage={activePage}
-            changePage={selectedPageNumber => setActivePage(selectedPageNumber)}
+            changePage={(selectedPageNumber) =>
+              setActivePage(selectedPageNumber)
+            }
             totalItemsCount={pagingResult.totalCount}
             pagingLimit={pagingResult.limit}
             align="center"
           />
         </div>
-      ) }
+      )}
     </>
   );
 };
 
 export type DescendantsPageListProps = {
-  path: string,
-  limit?: number,
-  forceHideMenuItems?: ForceHideMenuItems,
-}
+  path: string;
+  limit?: number;
+  forceHideMenuItems?: ForceHideMenuItems;
+};
 
-export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
+export const DescendantsPageList = (
+  props: DescendantsPageListProps,
+): JSX.Element => {
   const { path, limit, forceHideMenuItems } = props;
 
   const [activePage, setActivePage] = useState(1);
 
   const isSharedUser = useIsSharedUser();
 
-  const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
+  const {
+    data: pagingResult,
+    error,
+    mutate,
+  } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
 
   if (error != null) {
     return (

+ 16 - 19
apps/app/src/client/components/DuplicatedPathsTable.tsx

@@ -1,23 +1,21 @@
-import React from 'react';
-
+import type React from 'react';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 
-
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 type DuplicatedPathsTableProps = {
-  existingPaths: string[],
-  fromPath: string,
-  toPath: string
-}
+  existingPaths: string[];
+  fromPath: string;
+  toPath: string;
+};
 
-const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: DuplicatedPathsTableProps) => {
+const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (
+  props: DuplicatedPathsTableProps,
+) => {
   const { t } = useTranslation();
 
-  const {
-    fromPath, toPath, existingPaths,
-  } = props;
+  const { fromPath, toPath, existingPaths } = props;
 
   return (
     <table className="table table-bordered grw-duplicated-paths-table">
@@ -29,17 +27,17 @@ const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: Duplic
       </thead>
       <tbody className="overflow-auto d-block">
         {existingPaths.map((existPath) => {
-          const convertedPath = convertToNewAffiliationPath(toPath, fromPath, existPath);
+          const convertedPath = convertToNewAffiliationPath(
+            toPath,
+            fromPath,
+            existPath,
+          );
           return (
             <tr key={existPath} className="d-flex">
               <td className="text-break w-50">
-                <a href={convertedPath}>
-                  {convertedPath}
-                </a>
-              </td>
-              <td className="text-break text-danger w-50">
-                {existPath}
+                <a href={convertedPath}>{convertedPath}</a>
               </td>
+              <td className="text-break text-danger w-50">{existPath}</td>
             </tr>
           );
         })}
@@ -48,5 +46,4 @@ const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: Duplic
   );
 };
 
-
 export default DuplicatedPathsTable;

+ 3 - 5
apps/app/src/client/components/EmptyTrashButton.tsx

@@ -1,13 +1,11 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 type EmptyTrashButtonProps = {
-  onEmptyTrashButtonClick: () => void,
-  disableEmptyButton: boolean
+  onEmptyTrashButtonClick: () => void;
+  disableEmptyButton: boolean;
 };
 
-
 const EmptyTrashButton = (props: EmptyTrashButtonProps): JSX.Element => {
   const { onEmptyTrashButtonClick, disableEmptyButton } = props;
   const { t } = useTranslation();

+ 4 - 5
apps/app/src/client/components/ErrorBoudary.jsx

@@ -1,12 +1,10 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
 
 /**
  * @see https://reactjs.org/docs/error-boundaries.html
  */
 class ErrorBoundary extends React.Component {
-
   constructor(props) {
     super(props);
     this.state = { error: null, errorInfo: null };
@@ -26,7 +24,6 @@ class ErrorBoundary extends React.Component {
   render() {
     const { error, errorInfo } = this.state;
     if (errorInfo != null) {
-
       // split componetStack
       // see https://regex101.com/r/Uc448G/1
       const firstStack = errorInfo.componentStack.split(/\s*in\s/)[1];
@@ -36,7 +33,10 @@ class ErrorBoundary extends React.Component {
           <div className="card-header">Error occured in {firstStack}</div>
           <div className="card-body">
             <h5>{error && error.toString()}</h5>
-            <details className="card custom-card small mb-0" style={{ whiteSpace: 'pre-wrap' }}>
+            <details
+              className="card custom-card small mb-0"
+              style={{ whiteSpace: 'pre-wrap' }}
+            >
               {errorInfo.componentStack}
             </details>
           </div>
@@ -47,7 +47,6 @@ class ErrorBoundary extends React.Component {
     // Normally, just render children
     return this.props.children;
   }
-
 }
 
 ErrorBoundary.propTypes = {

+ 6 - 6
apps/app/src/client/components/ExpandOrContractButton.tsx

@@ -4,14 +4,13 @@ import React from 'react';
 import styles from './ExpandOrContractButton.module.scss';
 
 type Props = {
-  isWindowExpanded: boolean,
-  contractWindow?: () => void,
-  expandWindow?: () => void,
+  isWindowExpanded: boolean;
+  contractWindow?: () => void;
+  expandWindow?: () => void;
 };
 
 const moduleClass = styles['btn-expand-or-contract'] ?? '';
 
-
 const ExpandOrContractButton: FC<Props> = (props: Props) => {
   const { isWindowExpanded, contractWindow, expandWindow } = props;
 
@@ -31,7 +30,9 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
     <button
       type="button"
       className={`btn ${moduleClass}`}
-      onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
+      onClick={
+        isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler
+      }
     >
       <span className="material-symbols-outlined fw-bold">
         {isWindowExpanded ? 'close_fullscreen' : 'open_in_full'}
@@ -40,5 +41,4 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
   );
 };
 
-
 export default ExpandOrContractButton;

+ 11 - 6
apps/app/src/client/components/ForbiddenPage.tsx

@@ -1,10 +1,9 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  isLinkSharingDisabled?: boolean,
-}
+  isLinkSharingDisabled?: boolean;
+};
 
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -14,7 +13,9 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
       <div className="row not-found-message-row mb-4">
         <div className="col-lg-12">
           <h2 className="text-muted">
-            <span className="material-symbols-outlined" aria-hidden="true">block</span>
+            <span className="material-symbols-outlined" aria-hidden="true">
+              block
+            </span>
             Forbidden
           </h2>
         </div>
@@ -23,8 +24,12 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
       <div className="row row-alerts d-edit-none">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
-            <span className="material-symbols-outlined" aria-hidden="true">lock</span>
-            { props.isLinkSharingDisabled ? t('share_links.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
+            <span className="material-symbols-outlined" aria-hidden="true">
+              lock
+            </span>
+            {props.isLinkSharingDisabled
+              ? t('share_links.link_sharing_is_disabled')
+              : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>

+ 10 - 6
apps/app/src/client/components/FormattedDistanceDate.jsx

@@ -1,13 +1,12 @@
 import React from 'react';
-
-import { format, formatDistanceStrict, differenceInSeconds } from 'date-fns';
+import { differenceInSeconds, format, formatDistanceStrict } from 'date-fns';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
 const FormattedDistanceDate = (props) => {
-
   // cast to date if string
-  const date = (typeof props.date === 'string') ? new Date(props.date) : props.date;
+  const date =
+    typeof props.date === 'string' ? new Date(props.date) : props.date;
 
   const baseDate = props.baseDate || new Date();
 
@@ -23,14 +22,19 @@ const FormattedDistanceDate = (props) => {
   return (
     <>
       <span id={elemId}>{formatDistanceStrict(date, baseDate)}</span>
-      {props.isShowTooltip && <UncontrolledTooltip placement="bottom" fade={false} target={elemId}>{dateFormatted}</UncontrolledTooltip>}
+      {props.isShowTooltip && (
+        <UncontrolledTooltip placement="bottom" fade={false} target={elemId}>
+          {dateFormatted}
+        </UncontrolledTooltip>
+      )}
     </>
   );
 };
 
 FormattedDistanceDate.propTypes = {
   id: PropTypes.string.isRequired,
-  date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired,
+  date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)])
+    .isRequired,
   baseDate: PropTypes.instanceOf(Date),
   // the number(sec) from 'baseDate' to avoid format
   differenceForAvoidingFormat: PropTypes.number,

+ 25 - 16
apps/app/src/client/components/IdenticalPathPage.tsx

@@ -1,23 +1,25 @@
 import type { FC, JSX } from 'react';
 import React from 'react';
-
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 
 import { useCurrentPathname } from '~/states/global';
-import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
+import {
+  useSWRxPageInfoForList,
+  useSWRxPagesByPath,
+} from '~/stores/page-listing';
 
 import { PageListItemL } from './PageList/PageListItemL';
 
-
 import styles from './IdenticalPathPage.module.scss';
 
-
 type IdenticalPathAlertProps = {
-  path? : string | null,
-}
+  path?: string | null;
+};
 
-const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+const IdenticalPathAlert: FC<IdenticalPathAlertProps> = (
+  props: IdenticalPathAlertProps,
+) => {
   const { path } = props;
   const { t } = useTranslation();
 
@@ -30,16 +32,26 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
     _pageName = devidedPath.latter;
   }
 
-
   return (
     <div className="alert alert-warning py-3">
-      <h5 className="fw-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: _pageName })}</h5>
+      <h5 className="fw-bold mt-1">
+        {t('duplicated_page_alert.same_page_name_exists', {
+          pageName: _pageName,
+        })}
+      </h5>
       <p>
-        {t('duplicated_page_alert.same_page_name_exists_at_path',
-          { path: _path, pageName: _pageName })}<br />
+        {t('duplicated_page_alert.same_page_name_exists_at_path', {
+          path: _path,
+          pageName: _pageName,
+        })}
+        <br />
         <span
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { title: t('GROWI.5.0_new_schema') }) }}
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML link
+          dangerouslySetInnerHTML={{
+            __html: t('See_more_detail_on_new_schema', {
+              title: t('GROWI.5.0_new_schema'),
+            }),
+          }}
         />
       </p>
       <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
@@ -47,9 +59,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
   );
 };
 
-
 export const IdenticalPathPage = (): JSX.Element => {
-
   const currentPath = useCurrentPathname();
 
   const { data: pages } = useSWRxPagesByPath(currentPath);
@@ -83,7 +93,6 @@ export const IdenticalPathPage = (): JSX.Element => {
           })}
         </ul>
       </div>
-
     </>
   );
 };

+ 20 - 19
apps/app/src/client/components/InfiniteScroll.tsx

@@ -1,18 +1,17 @@
-import type { Ref, JSX } from 'react';
-import React, { useEffect, useState } from 'react';
-
+import type React from 'react';
+import type { JSX, Ref } from 'react';
+import { useEffect, useState } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import type { SWRInfiniteResponse } from 'swr/infinite';
 
-
 type Props<T> = {
-  swrInifiniteResponse: SWRInfiniteResponse<T>
-  children: React.ReactNode,
-  loadingIndicator?: React.ReactNode
-  endingIndicator?: React.ReactNode
-  isReachingEnd?: boolean
-  offset?: number
-}
+  swrInifiniteResponse: SWRInfiniteResponse<T>;
+  children: React.ReactNode;
+  loadingIndicator?: React.ReactNode;
+  endingIndicator?: React.ReactNode;
+  isReachingEnd?: boolean;
+  offset?: number;
+};
 
 const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
   const [intersecting, setIntersecting] = useState<boolean>(false);
@@ -27,7 +26,12 @@ const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
     }
     return;
   }, [element]);
-  return [intersecting, (el) => { if (el != null) setElement(el); }];
+  return [
+    intersecting,
+    (el) => {
+      if (el != null) setElement(el);
+    },
+  ];
 };
 
 const LoadingIndicator = (): JSX.Element => {
@@ -38,11 +42,9 @@ const LoadingIndicator = (): JSX.Element => {
   );
 };
 
-const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
+const InfiniteScroll = <E,>(props: Props<E>): React.ReactElement<Props<E>> => {
   const {
-    swrInifiniteResponse: {
-      setSize, isValidating,
-    },
+    swrInifiniteResponse: { setSize, isValidating },
     children,
     loadingIndicator,
     endingIndicator,
@@ -54,7 +56,7 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
 
   useEffect(() => {
     if (intersecting && !isValidating && !isReachingEnd) {
-      setSize(size => size + 1);
+      setSize((size) => size + 1);
     }
   }, [setSize, intersecting, isValidating, isReachingEnd]);
 
@@ -65,8 +67,7 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
         <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
         {isReachingEnd
           ? endingIndicator
-          : loadingIndicator || <LoadingIndicator />
-        }
+          : loadingIndicator || <LoadingIndicator />}
       </div>
     </>
   );

+ 143 - 112
apps/app/src/client/components/InstallerForm.tsx

@@ -1,10 +1,9 @@
 import type { FormEventHandler, JSX } from 'react';
 import { memo, useCallback, useState } from 'react';
-
-import { Lang, AllLang } from '@growi/core';
+import { useRouter } from 'next/router';
+import { AllLang, Lang } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
@@ -13,15 +12,13 @@ import { useTWithOpt } from '~/client/util/t-with-opt';
 import { toastError } from '~/client/util/toastr';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 
-
 import styles from './InstallerForm.module.scss';
 
-
 const moduleClass = styles['installer-form'] ?? '';
 
 type Props = {
-  minPasswordLength: number,
-}
+  minPasswordLength: number;
+};
 
 const InstallerForm = memo((props: Props): JSX.Element => {
   const { t, i18n } = useTranslation();
@@ -35,86 +32,101 @@ const InstallerForm = memo((props: Props): JSX.Element => {
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
 
   const [isLoading, setIsLoading] = useState(false);
-  const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
+  const [currentLocale, setCurrentLocale] = useState(
+    isSupportedLang ? i18n.language : Lang.en_US,
+  );
 
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
 
-  const onClickLanguageItem = useCallback((locale) => {
-    i18n.changeLanguage(locale);
-    setCurrentLocale(locale);
-  }, [i18n]);
-
-  const submitHandler: FormEventHandler = useCallback(async(e: any) => {
-    e.preventDefault();
-
-    setIsLoading(true);
-
-    const formData = e.target.elements;
-
-    const {
-      'registerForm[username]': { value: username },
-      'registerForm[name]': { value: name },
-      'registerForm[email]': { value: email },
-      'registerForm[password]': { value: password },
-    } = formData;
-
-    const data = {
-      registerForm: {
-        username,
-        name,
-        email,
-        password,
-        'app:globalLang': currentLocale,
-      },
-    };
-
-    try {
-      setRegisterErrors([]);
-      await apiv3Post('/installer', data);
-      router.push('/');
-    }
-    catch (errs) {
-      const err = errs[0];
-      const code = err.code;
-      setIsLoading(false);
-      setRegisterErrors(errs);
-
-      if (code === 'failed_to_login_after_install') {
-        toastError(t('installer.failed_to_login_after_install'));
-        setTimeout(() => { router.push('/login') }, 700); // Wait 700 ms to show toastr
-      }
+  const onClickLanguageItem = useCallback(
+    (locale) => {
+      i18n.changeLanguage(locale);
+      setCurrentLocale(locale);
+    },
+    [i18n],
+  );
 
-      toastError(t('installer.failed_to_install'));
-    }
-  }, [currentLocale, router, t]);
+  const submitHandler: FormEventHandler = useCallback(
+    async (e: any) => {
+      e.preventDefault();
+
+      setIsLoading(true);
+
+      const formData = e.target.elements;
+
+      const {
+        'registerForm[username]': { value: username },
+        'registerForm[name]': { value: name },
+        'registerForm[email]': { value: email },
+        'registerForm[password]': { value: password },
+      } = formData;
+
+      const data = {
+        registerForm: {
+          username,
+          name,
+          email,
+          password,
+          'app:globalLang': currentLocale,
+        },
+      };
+
+      try {
+        setRegisterErrors([]);
+        await apiv3Post('/installer', data);
+        router.push('/');
+      } catch (errs) {
+        const err = errs[0];
+        const code = err.code;
+        setIsLoading(false);
+        setRegisterErrors(errs);
+
+        if (code === 'failed_to_login_after_install') {
+          toastError(t('installer.failed_to_login_after_install'));
+          setTimeout(() => {
+            router.push('/login');
+          }, 700); // Wait 700 ms to show toastr
+        }
+
+        toastError(t('installer.failed_to_install'));
+      }
+    },
+    [currentLocale, router, t],
+  );
 
   return (
-    <div data-testid="installerForm" className={`${moduleClass} nologin-dialog py-3 px-4 rounded-4 rounded-top-0 mx-auto`}>
+    <div
+      data-testid="installerForm"
+      className={`${moduleClass} nologin-dialog py-3 px-4 rounded-4 rounded-top-0 mx-auto`}
+    >
       <div className="row mt-3">
         <div className="col-md-12">
           <p className="alert alert-success">
-            <strong>{ t('installer.create_initial_account') }</strong><br />
-            <small>{ t('installer.initial_account_will_be_administrator_automatically') }</small>
+            <strong>{t('installer.create_initial_account')}</strong>
+            <br />
+            <small>
+              {t(
+                'installer.initial_account_will_be_administrator_automatically',
+              )}
+            </small>
           </p>
         </div>
       </div>
       <div className="row mt-2">
-
-        {
-          registerErrors != null && registerErrors.length > 0 && (
-            <div className="col-12">
-              <div className="alert alert-danger text-center">
-                {registerErrors.map(err => (
-                  <span>
-                    {tWithOpt(err.message, err.args)}<br />
-                  </span>
-                ))}
-              </div>
+        {registerErrors != null && registerErrors.length > 0 && (
+          <div className="col-12">
+            <div className="alert alert-danger text-center">
+              {registerErrors.map((err) => (
+                <span key={err.message}>
+                  {tWithOpt(err.message, err.args)}
+                  <br />
+                </span>
+              ))}
             </div>
-          )
-        }
+          </div>
+        )}
 
-        <form role="form" id="register-form" className="ps-1" onSubmit={submitHandler}>
+        <form id="register-form" className="ps-1" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
             <div className="input-group dropdown-with-icon">
               <span className="p-2 text-white opacity-75">
@@ -129,43 +141,44 @@ const InstallerForm = memo((props: Props): JSX.Element => {
                 aria-haspopup="true"
                 aria-expanded="true"
               >
-                <span className="float-start">
-                  {t('meta.display_name')}
-                </span>
+                <span className="float-start">{t('meta.display_name')}</span>
               </button>
-              <input
-                type="hidden"
-                name="registerForm[app:globalLang]"
-              />
-              <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
-                {
-                  i18nConfig.locales.map((locale) => {
-                    let fixedT;
-                    if (i18n != null) {
-                      fixedT = i18n.getFixedT(locale);
-                      i18n.loadLanguages(i18nConfig.locales);
-                    }
-
-                    return (
-                      <button
-                        key={locale}
-                        data-testid={`dropdownLanguageMenu-${locale}`}
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => { onClickLanguageItem(locale) }}
-                      >
-                        {fixedT?.('meta.display_name')}
-                      </button>
-                    );
-                  })
-                }
+              <input type="hidden" name="registerForm[app:globalLang]" />
+              <div className="dropdown-menu">
+                {i18nConfig.locales.map((locale) => {
+                  let fixedT: ((key: string) => string) | undefined;
+                  if (i18n != null) {
+                    fixedT = i18n.getFixedT(locale);
+                    i18n.loadLanguages(i18nConfig.locales);
+                  }
+
+                  return (
+                    <button
+                      key={locale}
+                      data-testid={`dropdownLanguageMenu-${locale}`}
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => {
+                        onClickLanguageItem(locale);
+                      }}
+                    >
+                      {fixedT?.('meta.display_name')}
+                    </button>
+                  );
+                })}
               </div>
             </div>
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('User ID')} htmlFor="tiUsername">
-              <span className="material-symbols-outlined" aria-hidden>person</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('User ID')}
+              htmlFor="tiUsername"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                person
+              </span>
             </label>
             <input
               id="tiUsername"
@@ -178,8 +191,14 @@ const InstallerForm = memo((props: Props): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('Name')} htmlFor="tiName">
-              <span className="material-symbols-outlined" aria-hidden>sell</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('Name')}
+              htmlFor="tiName"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                sell
+              </span>
             </label>
             <input
               id="tiName"
@@ -192,8 +211,14 @@ const InstallerForm = memo((props: Props): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('Email')} htmlFor="tiEmail">
-              <span className="material-symbols-outlined" aria-hidden>mail</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('Email')}
+              htmlFor="tiEmail"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                mail
+              </span>
             </label>
             <input
               id="tiEmail"
@@ -206,8 +231,14 @@ const InstallerForm = memo((props: Props): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('Password')} htmlFor="tiPassword">
-              <span className="material-symbols-outlined" aria-hidden>lock</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('Password')}
+              htmlFor="tiPassword"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                lock
+              </span>
             </label>
             <input
               minLength={minPasswordLength}
@@ -233,16 +264,16 @@ const InstallerForm = memo((props: Props): JSX.Element => {
                   <span className="material-symbols-outlined">person_add</span>
                 )}
               </span>
-              <span className="flex-grow-1">{ t('Create') }</span>
+              <span className="flex-grow-1">{t('Create')}</span>
             </button>
           </div>
 
           <div>
             <a href="https://growi.org" className="link-growi-org">
-              <span className="growi">GROWI</span><span className="org">.org</span>
+              <span className="growi">GROWI</span>
+              <span className="org">.org</span>
             </a>
           </div>
-
         </form>
       </div>
     </div>

+ 50 - 38
apps/app/src/client/components/InvitedForm.tsx

@@ -1,22 +1,21 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
+import { useRouter } from 'next/router';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { useForm } from 'react-hook-form';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/states/global';
 
 type InvitedFormProps = {
-  invitedFormUsername: string,
-  invitedFormName: string,
-}
+  invitedFormUsername: string;
+  invitedFormName: string;
+};
 
 type InvitedFormValues = {
-  name: string,
-  username: string,
-  password: string,
+  name: string;
+  username: string;
+  password: string;
 };
 
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
@@ -39,42 +38,49 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
     },
   });
 
-  const submitHandler = useCallback(async(values: InvitedFormValues) => {
-    setIsLoading(true);
+  const submitHandler = useCallback(
+    async (values: InvitedFormValues) => {
+      setIsLoading(true);
 
-    const invitedForm = {
-      name: values.name,
-      username: values.username,
-      password: values.password,
-    };
+      const invitedForm = {
+        name: values.name,
+        username: values.username,
+        password: values.password,
+      };
 
-    try {
-      const res = await apiv3Post('/invited', { invitedForm });
-      const { redirectTo } = res.data;
-      router.push(redirectTo ?? '/');
-    }
-    catch (err) {
-      setLoginErrors(err);
-      setIsLoading(false);
-    }
-  }, [router]);
+      try {
+        const res = await apiv3Post('/invited', { invitedForm });
+        const { redirectTo } = res.data;
+        router.push(redirectTo ?? '/');
+      } catch (err) {
+        setLoginErrors(err);
+        setIsLoading(false);
+      }
+    },
+    [router],
+  );
 
   const formNotification = useCallback(() => {
-
     return (
       <>
-        { loginErrors != null && loginErrors.length > 0 ? (
+        {loginErrors != null && loginErrors.length > 0 ? (
           <p className="alert alert-danger">
-            { loginErrors.map((err) => {
-              return <span>{ t(err.message) }<br /></span>;
-            }) }
+            {loginErrors.map((err) => {
+              return (
+                <span key={err.message}>
+                  {t(err.message)}
+                  <br />
+                </span>
+              );
+            })}
           </p>
         ) : (
           <p className="alert alert-success">
-            <strong>{ t('invited.discription_heading') }</strong><br></br>
-            <small>{ t('invited.discription') }</small>
+            <strong>{t('invited.discription_heading')}</strong>
+            <br></br>
+            <small>{t('invited.discription')}</small>
           </p>
-        ) }
+        )}
       </>
     );
   }, [loginErrors, t]);
@@ -85,8 +91,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
   return (
     <div className="nologin-dialog px-3 pb-3 mx-auto" id="nologin-dialog">
-      { formNotification() }
-      <form role="form" onSubmit={handleSubmit(submitHandler)} id="invited-form">
+      {formNotification()}
+      <form onSubmit={handleSubmit(submitHandler)} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
           <span className="input-group-text">
@@ -144,7 +150,12 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* Create Button */}
         <div className="input-group justify-content-center d-flex mt-4">
-          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading || isSubmitting}>
+          <button
+            type="submit"
+            className="btn btn-fill"
+            id="register"
+            disabled={isLoading || isSubmitting}
+          >
             <span className="btn-label">
               {isLoading ? (
                 <LoadingSpinner />
@@ -158,7 +169,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
       </form>
       <div className="input-group mt-4 d-flex justify-content-center">
         <a href="https://growi.org" className="link-growi-org">
-          <span className="growi">GROWI</span><span className="org">.ORG</span>
+          <span className="growi">GROWI</span>
+          <span className="org">.ORG</span>
         </a>
       </div>
     </div>

+ 15 - 13
apps/app/src/client/components/NotAvailable.tsx

@@ -1,21 +1,23 @@
 import React, { type JSX } from 'react';
-
 import { Disable } from 'react-disable';
 import type { UncontrolledTooltipProps } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 type NotAvailableProps = {
-  children: JSX.Element
-  isDisabled: boolean
-  title: string
-  classNamePrefix?: string
-  placement?: UncontrolledTooltipProps['placement']
-}
+  children: JSX.Element;
+  isDisabled: boolean;
+  title: string;
+  classNamePrefix?: string;
+  placement?: UncontrolledTooltipProps['placement'];
+};
 
 export const NotAvailable = ({
-  children, isDisabled, title, classNamePrefix = 'grw-not-available', placement = 'top',
+  children,
+  isDisabled,
+  title,
+  classNamePrefix = 'grw-not-available',
+  placement = 'top',
 }: NotAvailableProps): JSX.Element => {
-
   if (!isDisabled) {
     return children;
   }
@@ -25,11 +27,11 @@ export const NotAvailable = ({
   return (
     <>
       <div id={id}>
-        <Disable disabled={isDisabled}>
-          {children}
-        </Disable>
+        <Disable disabled={isDisabled}>{children}</Disable>
       </div>
-      <UncontrolledTooltip placement={placement} target={id}>{title}</UncontrolledTooltip>
+      <UncontrolledTooltip placement={placement} target={id}>
+        {title}
+      </UncontrolledTooltip>
     </>
   );
 };

+ 19 - 18
apps/app/src/client/components/NotAvailableForGuest.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useIsGuestUser } from '~/states/context';
@@ -7,24 +6,26 @@ import { useIsGuestUser } from '~/states/context';
 import { NotAvailable } from './NotAvailable';
 
 type NotAvailableForGuestProps = {
-  children: JSX.Element
-}
+  children: JSX.Element;
+};
 
-export const NotAvailableForGuest = React.memo(({ children }: NotAvailableForGuestProps): JSX.Element => {
-  const { t } = useTranslation();
-  const isGuestUser = useIsGuestUser();
+export const NotAvailableForGuest = React.memo(
+  ({ children }: NotAvailableForGuestProps): JSX.Element => {
+    const { t } = useTranslation();
+    const isGuestUser = useIsGuestUser();
 
-  const isDisabled = !!isGuestUser;
-  const title = t('Not available for guest');
+    const isDisabled = !!isGuestUser;
+    const title = t('Not available for guest');
 
-  return (
-    <NotAvailable
-      isDisabled={isDisabled}
-      title={title}
-      classNamePrefix="grw-not-available-for-guest"
-    >
-      {children}
-    </NotAvailable>
-  );
-});
+    return (
+      <NotAvailable
+        isDisabled={isDisabled}
+        title={title}
+        classNamePrefix="grw-not-available-for-guest"
+      >
+        {children}
+      </NotAvailable>
+    );
+  },
+);
 NotAvailableForGuest.displayName = 'NotAvailableForGuest';

+ 17 - 17
apps/app/src/client/components/NotAvailableForNow.tsx

@@ -1,27 +1,27 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { NotAvailable } from './NotAvailable';
 
-
 type NotAvailableForNowProps = {
-  children: JSX.Element
-}
+  children: JSX.Element;
+};
 
-export const NotAvailableForNow = React.memo(({ children }: NotAvailableForNowProps): JSX.Element => {
-  const { t } = useTranslation();
+export const NotAvailableForNow = React.memo(
+  ({ children }: NotAvailableForNowProps): JSX.Element => {
+    const { t } = useTranslation();
 
-  const title = t('Not available in this version');
+    const title = t('Not available in this version');
 
-  return (
-    <NotAvailable
-      isDisabled
-      title={title}
-      classNamePrefix="grw-not-available-for-now"
-    >
-      {children}
-    </NotAvailable>
-  );
-});
+    return (
+      <NotAvailable
+        isDisabled
+        title={title}
+        classNamePrefix="grw-not-available-for-now"
+      >
+        {children}
+      </NotAvailable>
+    );
+  },
+);
 NotAvailableForNow.displayName = 'NotAvailableForNow';

+ 19 - 16
apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx

@@ -1,11 +1,8 @@
 import type { ReactNode } from 'react';
-
 import { render, screen } from '@testing-library/react';
 import { Provider } from 'jotai';
 import { useHydrateAtoms } from 'jotai/utils';
-import {
-  describe, it, expect, vi,
-} from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
 
 import { isRomUserAllowedToCommentAtom } from '~/states/server-configurations';
 
@@ -18,21 +15,28 @@ vi.mock('~/states/context', () => ({
 }));
 
 vi.mock('react-disable', () => ({
-  Disable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => (
-    <div aria-hidden={disabled ? 'true' : undefined}>
-      {children}
-    </div>
-  ),
+  Disable: ({
+    children,
+    disabled,
+  }: {
+    children: ReactNode;
+    disabled: boolean;
+  }) => <div aria-hidden={disabled ? 'true' : undefined}>{children}</div>,
 }));
 
-const HydrateAtoms = ({ children, initialValues }: { children: ReactNode; initialValues: Array<[typeof isRomUserAllowedToCommentAtom, boolean]> }) => {
+const HydrateAtoms = ({
+  children,
+  initialValues,
+}: {
+  children: ReactNode;
+  initialValues: Array<[typeof isRomUserAllowedToCommentAtom, boolean]>;
+}) => {
   useHydrateAtoms(initialValues);
   return <>{children}</>;
 };
 
 describe('NotAvailableForReadOnlyUser.tsx', () => {
-
-  it('renders NotAvailable component as enable when user is read-only and comments by rom users is allowed', async() => {
+  it('renders NotAvailable component as enable when user is read-only and comments by rom users is allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(true);
 
     render(
@@ -53,7 +57,7 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     expect(wrapperElement).not.toHaveAttribute('aria-hidden', 'true');
   });
 
-  it('renders NotAvailable component as disable when user is read-only and comments by rom users is not allowed', async() => {
+  it('renders NotAvailable component as disable when user is read-only and comments by rom users is not allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(true);
 
     render(
@@ -74,7 +78,7 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     expect(wrapperElement).toHaveAttribute('aria-hidden', 'true');
   });
 
-  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is allowed', async() => {
+  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(false);
 
     render(
@@ -95,7 +99,7 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     expect(wrapperElement).not.toHaveAttribute('aria-hidden', 'true');
   });
 
-  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is not allowed', async() => {
+  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is not allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(false);
 
     render(
@@ -115,5 +119,4 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     // then
     expect(wrapperElement).not.toHaveAttribute('aria-hidden', 'true');
   });
-
 });

+ 20 - 7
apps/app/src/client/components/NotAvailableForReadOnlyUser.tsx

@@ -1,5 +1,5 @@
-import React, { type JSX } from 'react';
-
+import type React from 'react';
+import type { JSX } from 'react';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 
@@ -9,13 +9,19 @@ import { isRomUserAllowedToCommentAtom } from '~/states/server-configurations';
 import { NotAvailable } from './NotAvailable';
 
 // eslint-disable-next-line react/prop-types
-export const NotAvailableForReadOnlyUser: React.FC<{ children: JSX.Element }> = ({ children }) => {
+export const NotAvailableForReadOnlyUser: React.FC<{
+  children: JSX.Element;
+}> = ({ children }) => {
   const { t } = useTranslation();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isDisabled = !!isReadOnlyUser;
   const title = t('Not available for read only user');
   return (
-    <NotAvailable isDisabled={isDisabled} title={title} classNamePrefix="grw-not-available-for-read-only-user">
+    <NotAvailable
+      isDisabled={isDisabled}
+      title={title}
+      classNamePrefix="grw-not-available-for-read-only-user"
+    >
       {children}
     </NotAvailable>
   );
@@ -23,16 +29,23 @@ export const NotAvailableForReadOnlyUser: React.FC<{ children: JSX.Element }> =
 NotAvailableForReadOnlyUser.displayName = 'NotAvailableForReadOnlyUser';
 
 // eslint-disable-next-line react/prop-types
-export const NotAvailableIfReadOnlyUserNotAllowedToComment: React.FC<{ children: JSX.Element }> = ({ children }) => {
+export const NotAvailableIfReadOnlyUserNotAllowedToComment: React.FC<{
+  children: JSX.Element;
+}> = ({ children }) => {
   const { t } = useTranslation();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isRomUserAllowedToComment = useAtomValue(isRomUserAllowedToCommentAtom);
   const isDisabled = !!isReadOnlyUser && !isRomUserAllowedToComment;
   const title = t('page_comment.comment_management_is_not_allowed');
   return (
-    <NotAvailable isDisabled={isDisabled} title={title} classNamePrefix="grw-not-available-for-read-only-user">
+    <NotAvailable
+      isDisabled={isDisabled}
+      title={title}
+      classNamePrefix="grw-not-available-for-read-only-user"
+    >
       {children}
     </NotAvailable>
   );
 };
-NotAvailableIfReadOnlyUserNotAllowedToComment.displayName = 'NotAvailableIfReadOnlyUserNotAllowedToComment';
+NotAvailableIfReadOnlyUserNotAllowedToComment.displayName =
+  'NotAvailableIfReadOnlyUserNotAllowedToComment';

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

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 export const NotCreatablePage: FC = () => {
@@ -10,8 +9,10 @@ export const NotCreatablePage: FC = () => {
     <div className="row not-found-message-row">
       <div className="col-md-12">
         <h2 className="text-muted">
-          <span className="material-symbols-outlined" aria-hidden="true">block</span>
-          { t('not_creatable_page.message') }
+          <span className="material-symbols-outlined" aria-hidden="true">
+            block
+          </span>
+          {t('not_creatable_page.message')}
         </h2>
       </div>
     </div>

+ 22 - 9
apps/app/src/client/components/NotFoundPage.tsx

@@ -1,5 +1,4 @@
-import React, { useMemo, type JSX } from 'react';
-
+import React, { type JSX, useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
@@ -7,32 +6,46 @@ import { DescendantsPageList } from './DescendantsPageList';
 import { PageTimeline } from './PageTimeline';
 
 type NotFoundPageProps = {
-  path: string,
-}
+  path: string;
+};
+
+const PageListIcon = () => (
+  <span className="material-symbols-outlined">subject</span>
+);
+const TimelineIcon = () => (
+  <span className="material-symbols-outlined">timeline</span>
+);
 
 const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
   const { t } = useTranslation();
 
   const { path } = props;
 
+  const PageListContent = useMemo(() => {
+    return () => <DescendantsPageList path={path} />;
+  }, [path]);
+
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
-        Content: () => <DescendantsPageList path={path} />,
+        Icon: PageListIcon,
+        Content: PageListContent,
         i18n: t('page_list'),
       },
       timeLine: {
-        Icon: () => <span className="material-symbols-outlined">timeline</span>,
+        Icon: TimelineIcon,
         Content: PageTimeline,
         i18n: t('Timeline View'),
       },
     };
-  }, [path, t]);
+  }, [PageListContent, t]);
 
   return (
     <div className="d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        tabContentClasses={['py-4']}
+      />
     </div>
   );
 };

+ 243 - 203
apps/app/src/client/components/PageComment.tsx

@@ -1,12 +1,7 @@
 import type { FC, JSX } from 'react';
-import React, {
-  useState, useMemo, memo, useCallback,
-} from 'react';
-
+import React, { memo, useCallback, useMemo, useState } from 'react';
 import type { IRevision, Ref } from '@growi/core';
-import {
-  isPopulated, getIdStringForRef,
-} from '@growi/core';
+import { getIdStringForRef, isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
@@ -16,9 +11,11 @@ import type { RendererOptions } from '~/interfaces/renderer-options';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
-import type { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
+import type {
+  ICommentHasId,
+  ICommentHasIdList,
+} from '../../interfaces/comment';
 import { useSWRxPageComment } from '../../stores/comment';
-
 import { NotAvailableForGuest } from './NotAvailableForGuest';
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from './NotAvailableForReadOnlyUser';
 import { Comment } from './PageComment/Comment';
@@ -28,206 +25,249 @@ import { ReplyComments } from './PageComment/ReplyComments';
 
 import styles from './PageComment.module.scss';
 
-
 type PageCommentProps = {
-  rendererOptions?: RendererOptions,
-  pageId: string,
-  pagePath: string,
-  revision: Ref<IRevision>,
-  currentUser: any,
-  isReadOnly: boolean,
-}
-
-export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
-
-  const {
-    rendererOptions: rendererOptionsByProps,
-    pageId, pagePath, revision, currentUser, isReadOnly,
-  } = props;
-
-  const { data: comments, mutate } = useSWRxPageComment(pageId);
-  const { data: rendererOptionsForCurrentPage } = useCommentForCurrentPageOptions();
-
-  const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
-  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
-  const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
-  const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
-  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
-
-  const { t } = useTranslation('');
-
-  const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
-  const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
-    () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
-  );
-  const allReplies = {};
-
-  if (commentsFromOldest != null) {
-    commentsFromOldest.forEach((comment) => {
-      if (comment.replyTo != null) {
-        allReplies[comment.replyTo] = allReplies[comment.replyTo] == null ? [comment] : [...allReplies[comment.replyTo], comment];
+  rendererOptions?: RendererOptions;
+  pageId: string;
+  pagePath: string;
+  revision: Ref<IRevision>;
+  currentUser: any;
+  isReadOnly: boolean;
+};
+
+export const PageComment: FC<PageCommentProps> = memo(
+  (props: PageCommentProps): JSX.Element => {
+    const {
+      rendererOptions: rendererOptionsByProps,
+      pageId,
+      pagePath,
+      revision,
+      currentUser,
+      isReadOnly,
+    } = props;
+
+    const { data: comments, mutate } = useSWRxPageComment(pageId);
+    const { data: rendererOptionsForCurrentPage } =
+      useCommentForCurrentPageOptions();
+
+    const [commentToBeDeleted, setCommentToBeDeleted] =
+      useState<ICommentHasId | null>(null);
+    const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] =
+      useState<boolean>(false);
+    const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
+    const [errorMessageOnDelete, setErrorMessageOnDelete] =
+      useState<string>('');
+    const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
+
+    const { t } = useTranslation('');
+
+    const commentsFromOldest = useMemo(
+      () => (comments != null ? [...comments].reverse() : null),
+      [comments],
+    );
+    const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
+      () => commentsFromOldest?.filter((comment) => comment.replyTo == null),
+      [commentsFromOldest],
+    );
+    const allReplies = {};
+
+    if (commentsFromOldest != null) {
+      commentsFromOldest.forEach((comment) => {
+        if (comment.replyTo != null) {
+          allReplies[comment.replyTo] =
+            allReplies[comment.replyTo] == null
+              ? [comment]
+              : [...allReplies[comment.replyTo], comment];
+        }
+      });
+    }
+
+    const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
+      setCommentToBeDeleted(comment);
+      setIsDeleteConfirmModalShown(true);
+    }, []);
+
+    const onCancelDeleteComment = useCallback(() => {
+      setCommentToBeDeleted(null);
+      setIsDeleteConfirmModalShown(false);
+    }, []);
+
+    const onDeleteCommentAfterOperation = useCallback(() => {
+      onCancelDeleteComment();
+      mutate();
+      mutatePageInfo();
+    }, [mutate, onCancelDeleteComment, mutatePageInfo]);
+
+    const onDeleteComment = useCallback(async () => {
+      if (commentToBeDeleted == null) return;
+      try {
+        await apiPost('/comments.remove', {
+          comment_id: commentToBeDeleted._id,
+        });
+        onDeleteCommentAfterOperation();
+      } catch (error: unknown) {
+        const message =
+          error instanceof Error ? error.message : (error as any).toString();
+
+        setErrorMessageOnDelete(message);
+        toastError(message);
       }
-    });
-  }
-
-  const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
-    setCommentToBeDeleted(comment);
-    setIsDeleteConfirmModalShown(true);
-  }, []);
-
-  const onCancelDeleteComment = useCallback(() => {
-    setCommentToBeDeleted(null);
-    setIsDeleteConfirmModalShown(false);
-  }, []);
-
-  const onDeleteCommentAfterOperation = useCallback(() => {
-    onCancelDeleteComment();
-    mutate();
-    mutatePageInfo();
-  }, [mutate, onCancelDeleteComment, mutatePageInfo]);
-
-  const onDeleteComment = useCallback(async() => {
-    if (commentToBeDeleted == null) return;
-    try {
-      await apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
-      onDeleteCommentAfterOperation();
+    }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
+
+    const removeShowEditorId = useCallback((commentId: string) => {
+      setShowEditorIds((previousState) => {
+        return new Set([...previousState].filter((id) => id !== commentId));
+      });
+    }, []);
+
+    const onReplyButtonClickHandler = useCallback((commentId: string) => {
+      setShowEditorIds(
+        (previousState) => new Set([...previousState, commentId]),
+      );
+    }, []);
+
+    const onCommentButtonClickHandler = useCallback(
+      (commentId: string) => {
+        removeShowEditorId(commentId);
+        mutate();
+        mutatePageInfo();
+      },
+      [removeShowEditorId, mutate, mutatePageInfo],
+    );
+
+    if (comments?.length === 0) {
+      return <></>;
     }
-    catch (error: unknown) {
-      const message = error instanceof Error
-        ? error.message
-        : (error as any).toString();
 
-      setErrorMessageOnDelete(message);
-      toastError(message);
+    const rendererOptions =
+      rendererOptionsByProps ?? rendererOptionsForCurrentPage;
+
+    if (
+      commentsFromOldest == null ||
+      commentsExceptReply == null ||
+      rendererOptions == null
+    ) {
+      return <></>;
     }
-  }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
-
-  const removeShowEditorId = useCallback((commentId: string) => {
-    setShowEditorIds((previousState) => {
-      return new Set([...previousState].filter(id => id !== commentId));
-    });
-  }, []);
-
-  const onReplyButtonClickHandler = useCallback((commentId: string) => {
-    setShowEditorIds(previousState => new Set([...previousState, commentId]));
-  }, []);
-
-  const onCommentButtonClickHandler = useCallback((commentId: string) => {
-    removeShowEditorId(commentId);
-    mutate();
-    mutatePageInfo();
-  }, [removeShowEditorId, mutate, mutatePageInfo]);
-
-  if (comments?.length === 0) {
-    return <></>;
-  }
-
-  const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
-
-  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
-    return <></>;
-  }
-
-  const revisionId = getIdStringForRef(revision);
-  const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
-
-  const commentElement = (comment: ICommentHasId) => (
-    <Comment
-      rendererOptions={rendererOptions}
-      comment={comment}
-      revisionId={revisionId}
-      revisionCreatedAt={revisionCreatedAt as Date}
-      currentUser={currentUser}
-      isReadOnly={isReadOnly}
-      pageId={pageId}
-      pagePath={pagePath}
-      deleteBtnClicked={onClickDeleteButton}
-      onComment={mutate}
-    />
-  );
-
-  const replyCommentsElement = (replyComments: ICommentHasIdList) => (
-    <ReplyComments
-      rendererOptions={rendererOptions}
-      isReadOnly={isReadOnly}
-      revisionId={revisionId}
-      revisionCreatedAt={revisionCreatedAt as Date}
-      currentUser={currentUser}
-      replyList={replyComments}
-      pageId={pageId}
-      pagePath={pagePath}
-      deleteBtnClicked={onClickDeleteButton}
-      onComment={mutate}
-    />
-  );
-
-  return (
-    <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-      <div className="page-comments">
-        <div className="page-comments-list mb-3" id="page-comments-list">
-          {commentsExceptReply.map((comment) => {
-
-            const defaultCommentThreadClasses = 'page-comment-thread mb-2';
-            const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
-
-            let commentThreadClasses = '';
-            commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
-
-            return (
-              <div key={comment._id} className={commentThreadClasses}>
-                {/* Comment */}
-                {commentElement(comment)}
-                {/* Reply comments */}
-                {hasReply && replyCommentsElement(allReplies[comment._id])}
-
-                {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                  <div className="d-flex flex-row-reverse">
-                    <NotAvailableForGuest>
-                      <NotAvailableIfReadOnlyUserNotAllowedToComment>
-                        <button
-                          type="button"
-                          data-testid="comment-reply-button"
-                          className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
-                          onClick={() => onReplyButtonClickHandler(comment._id)}
-                        >
-                          <UserPicture user={currentUser} noLink noTooltip className="me-2" />
-                          <span className="material-symbols-outlined me-1 fs-5 pb-1">reply</span><small>{t('page_comment.reply')}...</small>
-                        </button>
-                      </NotAvailableIfReadOnlyUserNotAllowedToComment>
-                    </NotAvailableForGuest>
-                  </div>
-                )}
-
-                {/* Editor to reply */}
-                {(!isReadOnly && showEditorIds.has(comment._id)) && (
-                  <CommentEditor
-                    pageId={pageId}
-                    replyTo={comment._id}
-                    onCanceled={() => {
-                      removeShowEditorId(comment._id);
-                    }}
-                    onCommented={() => onCommentButtonClickHandler(comment._id)}
-                    revisionId={revisionId}
-                  />
-                )}
-              </div>
-            );
-
-          })}
+
+    const revisionId = getIdStringForRef(revision);
+    const revisionCreatedAt = isPopulated(revision)
+      ? revision.createdAt
+      : undefined;
+
+    const commentElement = (comment: ICommentHasId) => (
+      <Comment
+        rendererOptions={rendererOptions}
+        comment={comment}
+        revisionId={revisionId}
+        revisionCreatedAt={revisionCreatedAt as Date}
+        currentUser={currentUser}
+        isReadOnly={isReadOnly}
+        pageId={pageId}
+        pagePath={pagePath}
+        deleteBtnClicked={onClickDeleteButton}
+        onComment={mutate}
+      />
+    );
+
+    const replyCommentsElement = (replyComments: ICommentHasIdList) => (
+      <ReplyComments
+        rendererOptions={rendererOptions}
+        isReadOnly={isReadOnly}
+        revisionId={revisionId}
+        revisionCreatedAt={revisionCreatedAt as Date}
+        currentUser={currentUser}
+        replyList={replyComments}
+        pageId={pageId}
+        pagePath={pagePath}
+        deleteBtnClicked={onClickDeleteButton}
+        onComment={mutate}
+      />
+    );
+
+    return (
+      <div
+        className={`${styles['page-comment-styles']} page-comments-row comment-list`}
+      >
+        <div className="page-comments">
+          <div className="page-comments-list mb-3" id="page-comments-list">
+            {commentsExceptReply.map((comment) => {
+              const defaultCommentThreadClasses = 'page-comment-thread mb-2';
+              const hasReply: boolean = Object.keys(allReplies).includes(
+                comment._id,
+              );
+
+              let commentThreadClasses = '';
+              commentThreadClasses = hasReply
+                ? `${defaultCommentThreadClasses} page-comment-thread-no-replies`
+                : defaultCommentThreadClasses;
+
+              return (
+                <div key={comment._id} className={commentThreadClasses}>
+                  {/* Comment */}
+                  {commentElement(comment)}
+                  {/* Reply comments */}
+                  {hasReply && replyCommentsElement(allReplies[comment._id])}
+
+                  {!isReadOnly && !showEditorIds.has(comment._id) && (
+                    <div className="d-flex flex-row-reverse">
+                      <NotAvailableForGuest>
+                        <NotAvailableIfReadOnlyUserNotAllowedToComment>
+                          <button
+                            type="button"
+                            data-testid="comment-reply-button"
+                            className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
+                            onClick={() =>
+                              onReplyButtonClickHandler(comment._id)
+                            }
+                          >
+                            <UserPicture
+                              user={currentUser}
+                              noLink
+                              noTooltip
+                              className="me-2"
+                            />
+                            <span className="material-symbols-outlined me-1 fs-5 pb-1">
+                              reply
+                            </span>
+                            <small>{t('page_comment.reply')}...</small>
+                          </button>
+                        </NotAvailableIfReadOnlyUserNotAllowedToComment>
+                      </NotAvailableForGuest>
+                    </div>
+                  )}
+
+                  {/* Editor to reply */}
+                  {!isReadOnly && showEditorIds.has(comment._id) && (
+                    <CommentEditor
+                      pageId={pageId}
+                      replyTo={comment._id}
+                      onCanceled={() => {
+                        removeShowEditorId(comment._id);
+                      }}
+                      onCommented={() =>
+                        onCommentButtonClickHandler(comment._id)
+                      }
+                      revisionId={revisionId}
+                    />
+                  )}
+                </div>
+              );
+            })}
+          </div>
         </div>
-      </div>
 
-      {!isReadOnly && (
-        <DeleteCommentModalLazyLoaded
-          isShown={isDeleteConfirmModalShown}
-          comment={commentToBeDeleted}
-          errorMessage={errorMessageOnDelete}
-          cancelToDelete={onCancelDeleteComment}
-          confirmToDelete={onDeleteComment}
-        />
-      )}
-    </div>
-  );
-});
+        {!isReadOnly && (
+          <DeleteCommentModalLazyLoaded
+            isShown={isDeleteConfirmModalShown}
+            comment={commentToBeDeleted}
+            errorMessage={errorMessageOnDelete}
+            cancelToDelete={onCancelDeleteComment}
+            confirmToDelete={onDeleteComment}
+          />
+        )}
+      </div>
+    );
+  },
+);
 
 PageComment.displayName = 'PageComment';

+ 139 - 76
apps/app/src/client/components/PageCreateModal.tsx

@@ -1,18 +1,20 @@
-import React, {
-  useEffect, useState, useMemo, useCallback,
-} from 'react';
-
-import path from 'path';
-
-
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { format } from 'date-fns/format';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
+import path from 'path';
 import {
-  Modal, ModalHeader, ModalBody, UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  Modal,
+  ModalBody,
+  ModalHeader,
+  UncontrolledButtonDropdown,
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -21,15 +23,16 @@ import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useCurrentUser } from '~/states/global';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
-import { usePageCreateModalStatus, usePageCreateModalActions } from '~/states/ui/modal/page-create';
+import {
+  usePageCreateModalActions,
+  usePageCreateModalStatus,
+} from '~/states/ui/modal/page-create';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 import styles from './PageCreateModal.module.scss';
 
-const {
-  isCreatablePage, isUsersHomepage,
-} = pagePathUtils;
+const { isCreatablePage, isUsersHomepage } = pagePathUtils;
 
 const PageCreateModal: React.FC = () => {
   const { t } = useTranslation();
@@ -45,19 +48,34 @@ const PageCreateModal: React.FC = () => {
   const isReachable = useAtomValue(isSearchServiceReachableAtom);
 
   // Memoize computed values
-  const userHomepagePath = useMemo(() => pagePathUtils.userHomepagePath(currentUser), [currentUser]);
-  const isCreatable = useMemo(() => isCreatablePage(pathname) || isUsersHomepage(pathname), [pathname]);
-  const pageNameInputInitialValue = useMemo(() => (isCreatable ? pathUtils.addTrailingSlash(pathname) : '/'), [isCreatable, pathname]);
+  const userHomepagePath = useMemo(
+    () => pagePathUtils.userHomepagePath(currentUser),
+    [currentUser],
+  );
+  const isCreatable = useMemo(
+    () => isCreatablePage(pathname) || isUsersHomepage(pathname),
+    [pathname],
+  );
+  const pageNameInputInitialValue = useMemo(
+    () => (isCreatable ? pathUtils.addTrailingSlash(pathname) : '/'),
+    [isCreatable, pathname],
+  );
   const now = useMemo(() => format(new Date(), 'yyyy/MM/dd'), []);
   const todaysParentPath = useMemo(
-    () => [userHomepagePath, t('create_page_dropdown.todays.memo', { ns: 'commons' }), now].join('/'),
+    () =>
+      [
+        userHomepagePath,
+        t('create_page_dropdown.todays.memo', { ns: 'commons' }),
+        now,
+      ].join('/'),
     [userHomepagePath, t, now],
   );
 
   const [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
-  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
+  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] =
+    useState(false);
 
   const checkIsUsersHomepageDebounce = useMemo(() => {
     return debounce(1000, (input: string) => {
@@ -96,11 +114,14 @@ const PageCreateModal: React.FC = () => {
   /**
    * access today page
    */
-  const createTodayPage = useCallback(async() => {
+  const createTodayPage = useCallback(async () => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
     return create(
       {
-        path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
+        path: joinedPath,
+        parentPath: todaysParentPath,
+        wip: true,
+        origin: Origin.View,
       },
       { onTerminated: closeCreateModal },
     );
@@ -109,7 +130,7 @@ const PageCreateModal: React.FC = () => {
   /**
    * access input page
    */
-  const createInputPage = useCallback(async() => {
+  const createInputPage = useCallback(async () => {
     const targetPath = normalizePath(pageNameInput);
     const parentPath = path.dirname(targetPath);
 
@@ -127,9 +148,8 @@ const PageCreateModal: React.FC = () => {
   /**
    * access template page
    */
-  const createTemplatePage = useCallback(async() => {
-
-    const label = (template === 'children') ? '_template' : '__template';
+  const createTemplatePage = useCallback(async () => {
+    const label = template === 'children' ? '_template' : '__template';
 
     await createTemplate?.(label);
     closeCreateModal();
@@ -146,22 +166,28 @@ const PageCreateModal: React.FC = () => {
     return (
       <div className="row">
         <fieldset className="col-12 mb-4">
-          <h3 className="pb-2">{t('create_page_dropdown.todays.desc', { ns: 'commons' })}</h3>
+          <h3 className="pb-2">
+            {t('create_page_dropdown.todays.desc', { ns: 'commons' })}
+          </h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
-
             <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
               <div className="d-flex align-items-center text-nowrap">
                 <span>{todaysParentPath}/</span>
               </div>
-              <form className="mt-1 mt-lg-0 ms-lg-2 w-100" onSubmit={(e) => { transitBySubmitEvent(e, createTodaysMemoWithToastr) }}>
+              <form
+                className="mt-1 mt-lg-0 ms-lg-2 w-100"
+                onSubmit={(e) => {
+                  transitBySubmitEvent(e, createTodaysMemoWithToastr);
+                }}
+              >
                 <input
                   type="text"
                   className="page-today-input2 form-control w-100"
                   id="page-today-input2"
                   placeholder={t('Input page name (optional)')}
                   value={todayInput}
-                  onChange={e => onChangeTodayInputHandler(e.target.value)}
+                  onChange={(e) => onChangeTodayInputHandler(e.target.value)}
                 />
               </form>
             </div>
@@ -173,16 +199,23 @@ const PageCreateModal: React.FC = () => {
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
                 onClick={createTodaysMemoWithToastr}
               >
-                <span className="material-symbols-outlined">description</span>{t('Create')}
+                <span className="material-symbols-outlined">description</span>
+                {t('Create')}
               </button>
             </div>
-
           </div>
-
         </fieldset>
       </div>
     );
-  }, [isOpened, todaysParentPath, todayInput, t, onChangeTodayInputHandler, transitBySubmitEvent, createTodaysMemoWithToastr]);
+  }, [
+    isOpened,
+    todaysParentPath,
+    todayInput,
+    t,
+    onChangeTodayInputHandler,
+    transitBySubmitEvent,
+    createTodaysMemoWithToastr,
+  ]);
 
   const renderInputPageForm = useMemo(() => {
     if (!isOpened) {
@@ -195,28 +228,30 @@ const PageCreateModal: React.FC = () => {
 
           <div className="d-sm-flex align-items-center justify-items-between">
             <div className="flex-fill">
-              {isReachable
-                ? (
-                  <PagePathAutoComplete
-                    initializedPath={pageNameInputInitialValue}
-                    addTrailingSlash
-                    onSubmit={createInputPageWithToastr}
-                    onInputChange={value => setPageNameInput(value)}
-                    autoFocus
+              {isReachable ? (
+                <PagePathAutoComplete
+                  initializedPath={pageNameInputInitialValue}
+                  addTrailingSlash
+                  onSubmit={createInputPageWithToastr}
+                  onInputChange={(value) => setPageNameInput(value)}
+                  autoFocus
+                />
+              ) : (
+                <form
+                  onSubmit={(e) => {
+                    transitBySubmitEvent(e, createInputPageWithToastr);
+                  }}
+                >
+                  <input
+                    type="text"
+                    value={pageNameInput}
+                    className="form-control flex-fill"
+                    placeholder={t('Input page name')}
+                    onChange={(e) => setPageNameInput(e.target.value)}
+                    required
                   />
-                )
-                : (
-                  <form onSubmit={(e) => { transitBySubmitEvent(e, createInputPageWithToastr) }}>
-                    <input
-                      type="text"
-                      value={pageNameInput}
-                      className="form-control flex-fill"
-                      placeholder={t('Input page name')}
-                      onChange={e => setPageNameInput(e.target.value)}
-                      required
-                    />
-                  </form>
-                )}
+                </form>
+              )}
             </div>
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
@@ -227,19 +262,29 @@ const PageCreateModal: React.FC = () => {
                 onClick={createInputPageWithToastr}
                 disabled={isMatchedWithUserHomepagePath}
               >
-                <span className="material-symbols-outlined">description</span>{t('Create')}
+                <span className="material-symbols-outlined">description</span>
+                {t('Create')}
               </button>
             </div>
-
           </div>
-          { isMatchedWithUserHomepagePath && (
-            <p className="text-danger mt-2">Error: Cannot create page under /user page directory.</p>
-          ) }
-
+          {isMatchedWithUserHomepagePath && (
+            <p className="text-danger mt-2">
+              Error: Cannot create page under /user page directory.
+            </p>
+          )}
         </fieldset>
       </div>
     );
-  }, [isOpened, isReachable, pageNameInputInitialValue, createInputPageWithToastr, pageNameInput, isMatchedWithUserHomepagePath, t, transitBySubmitEvent]);
+  }, [
+    isOpened,
+    isReachable,
+    pageNameInputInitialValue,
+    createInputPageWithToastr,
+    pageNameInput,
+    isMatchedWithUserHomepagePath,
+    t,
+    transitBySubmitEvent,
+  ]);
 
   const renderTemplatePageForm = useMemo(() => {
     if (!isOpened) {
@@ -248,28 +293,42 @@ const PageCreateModal: React.FC = () => {
     return (
       <div className="row">
         <fieldset className="col-12">
-
           <h3 className="pb-2">
-            {t('template.modal_label.Create template under')}<br />
-            <code className="h6" data-testid="grw-page-create-modal-path-name">{pathname}</code>
+            {t('template.modal_label.Create template under')}
+            <br />
+            <code className="h6" data-testid="grw-page-create-modal-path-name">
+              {pathname}
+            </code>
           </h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
-
-            <UncontrolledButtonDropdown id="dd-template-type" className="flex-fill text-center">
+            <UncontrolledButtonDropdown
+              id="dd-template-type"
+              className="flex-fill text-center"
+            >
               <DropdownToggle id="template-type" caret>
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
                 {template === 'descendants' && t('template.descendants.label')}
               </DropdownToggle>
               <DropdownMenu>
-                <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
-                  {t('template.children.label')} (_template)<br className="d-block d-md-none" />
-                  <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
+                <DropdownItem
+                  onClick={() => onChangeTemplateHandler('children')}
+                >
+                  {t('template.children.label')} (_template)
+                  <br className="d-block d-md-none" />
+                  <small className="text-muted text-wrap">
+                    - {t('template.children.desc')}
+                  </small>
                 </DropdownItem>
-                <DropdownItem onClick={() => onChangeTemplateHandler('descendants')}>
-                  {t('template.descendants.label')} (__template) <br className="d-block d-md-none" />
-                  <small className="text-muted">- {t('template.descendants.desc')}</small>
+                <DropdownItem
+                  onClick={() => onChangeTemplateHandler('descendants')}
+                >
+                  {t('template.descendants.label')} (__template){' '}
+                  <br className="d-block d-md-none" />
+                  <small className="text-muted">
+                    - {t('template.descendants.desc')}
+                  </small>
                 </DropdownItem>
               </DropdownMenu>
             </UncontrolledButtonDropdown>
@@ -282,16 +341,22 @@ const PageCreateModal: React.FC = () => {
                 onClick={createTemplateWithToastr}
                 disabled={template == null}
               >
-                <span className="material-symbols-outlined">description</span>{t('Edit')}
+                <span className="material-symbols-outlined">description</span>
+                {t('Edit')}
               </button>
             </div>
-
           </div>
-
         </fieldset>
       </div>
     );
-  }, [isOpened, pathname, template, onChangeTemplateHandler, createTemplateWithToastr, t]);
+  }, [
+    isOpened,
+    pathname,
+    template,
+    onChangeTemplateHandler,
+    createTemplateWithToastr,
+    t,
+  ]);
 
   return (
     <Modal
@@ -311,9 +376,7 @@ const PageCreateModal: React.FC = () => {
         {renderTemplatePageForm}
       </ModalBody>
     </Modal>
-
   );
 };
 
-
 export default PageCreateModal;

+ 8 - 13
apps/app/src/client/components/PagePathAutoComplete.jsx

@@ -1,15 +1,11 @@
 import React from 'react';
-
 import { pathUtils } from '@growi/core/dist/utils';
 import PropTypes from 'prop-types';
 
 import SearchTypeahead from './SearchTypeahead';
 
 const PagePathAutoComplete = (props) => {
-
-  const {
-    addTrailingSlash, initializedPath,
-  } = props;
+  const { addTrailingSlash, initializedPath } = props;
 
   function getKeywordOnInit(path) {
     if (path == null) {
@@ -29,22 +25,21 @@ const PagePathAutoComplete = (props) => {
       autoFocus={props.autoFocus}
     />
   );
-
 };
 
 PagePathAutoComplete.propTypes = {
-  initializedPath:  PropTypes.string,
+  initializedPath: PropTypes.string,
   addTrailingSlash: PropTypes.bool,
 
-  onChange:         PropTypes.func,
-  onSubmit:         PropTypes.func,
-  onInputChange:    PropTypes.func,
-  autoFocus:        PropTypes.bool,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onInputChange: PropTypes.func,
+  autoFocus: PropTypes.bool,
 };
 
 PagePathAutoComplete.defaultProps = {
-  initializedPath:  '/',
-  autoFocus:        false,
+  initializedPath: '/',
+  autoFocus: false,
 };
 
 export default PagePathAutoComplete;

+ 36 - 13
apps/app/src/client/components/PageStatusAlert.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
@@ -30,10 +29,16 @@ export const PageStatusAlert = (): JSX.Element => {
     pageStatusAlertData?.onResolveConflict?.();
   }, [pageStatusAlertData]);
 
-  const hasResolveConflictHandler = pageStatusAlertData?.onResolveConflict != null;
+  const hasResolveConflictHandler =
+    pageStatusAlertData?.onResolveConflict != null;
   const hasRefreshPageHandler = pageStatusAlertData?.onRefleshPage != null;
 
-  if (!pageStatusAlertData?.isOpen || !!isGuestUser || !!isReadOnlyUser || !isRevisionOutdated) {
+  if (
+    !pageStatusAlertData?.isOpen ||
+    !!isGuestUser ||
+    !!isReadOnlyUser ||
+    !isRevisionOutdated
+  ) {
     return <></>;
   }
 
@@ -42,23 +47,41 @@ export const PageStatusAlert = (): JSX.Element => {
   }
 
   return (
-    <div className={`${styles['grw-page-status-alert']} card fixed-bottom animated fadeInUp faster text-bg-warning`}>
+    <div
+      className={`${styles['grw-page-status-alert']} card fixed-bottom animated fadeInUp faster text-bg-warning`}
+    >
       <div className="card-body">
         <p className="card-text grw-card-label-container">
-          {hasResolveConflictHandler
-            ? <>{t('modal_resolve_conflict.file_conflicting_with_newer_remote')}</>
-            : <><Username user={remoteRevisionLastUpdateUser} /> {t('edited this page')}</>
-          }
+          {hasResolveConflictHandler ? (
+            <>
+              {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+            </>
+          ) : (
+            <>
+              <Username user={remoteRevisionLastUpdateUser} />{' '}
+              {t('edited this page')}
+            </>
+          )}
         </p>
         <p className="card-text grw-card-btn-container">
           {hasRefreshPageHandler && (
-            <button type="button" onClick={onClickRefreshPage} className="btn btn-outline-white">
-              <span className="material-symbols-outlined">refresh</span>{t('Load latest')}
+            <button
+              type="button"
+              onClick={onClickRefreshPage}
+              className="btn btn-outline-white"
+            >
+              <span className="material-symbols-outlined">refresh</span>
+              {t('Load latest')}
             </button>
           )}
           {hasResolveConflictHandler && (
-            <button type="button" onClick={onClickResolveConflict} className="btn btn-outline-white">
-              <span className="material-symbols-outlined">description</span>{t('modal_resolve_conflict.resolve_conflict')}
+            <button
+              type="button"
+              onClick={onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <span className="material-symbols-outlined">description</span>
+              {t('modal_resolve_conflict.resolve_conflict')}
             </button>
           )}
         </p>

+ 15 - 16
apps/app/src/client/components/PageTimeline.tsx

@@ -1,8 +1,7 @@
 import React, { type JSX } from 'react';
-
+import Link from 'next/link';
 import type { IPageHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 
 import { useCurrentPagePath } from '~/states/page';
 import { useSWRINFxPageTimeline } from '~/stores/page-timeline';
@@ -13,13 +12,11 @@ import { RevisionLoader } from './Page/RevisionLoader';
 
 import styles from './PageTimeline.module.scss';
 
-
 type TimelineCardProps = {
-  page: IPageHasId,
-}
+  page: IPageHasId;
+};
 
 const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
-
   const { data: rendererOptions } = useTimelineOptions(page.path);
 
   return (
@@ -30,29 +27,32 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
         </Link>
       </div>
       <div className="card-body">
-        { rendererOptions != null && page.revision != null && (
+        {rendererOptions != null && page.revision != null && (
           <RevisionLoader
             rendererOptions={rendererOptions}
             pageId={page._id}
             revisionId={page.revision}
           />
-        ) }
+        )}
       </div>
     </div>
   );
 };
 
 export const PageTimeline = (): JSX.Element => {
-
   const PER_PAGE = 3;
   const { t } = useTranslation();
   const currentPagePath = useCurrentPagePath();
 
-  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(currentPagePath ?? undefined, PER_PAGE);
+  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(
+    currentPagePath ?? undefined,
+    PER_PAGE,
+  );
   const { data } = swrInfinitexPageTimeline;
 
   const isEmpty = data?.[0]?.pages.length === 0;
-  const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
+  const isReachingEnd =
+    isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
   if (data == null || isEmpty) {
     return (
@@ -68,11 +68,10 @@ export const PageTimeline = (): JSX.Element => {
         swrInifiniteResponse={swrInfinitexPageTimeline}
         isReachingEnd={isReachingEnd}
       >
-        { data != null && data.flatMap(apiResult => apiResult.pages)
-          .map(page => (
-            <TimelineCard key={page._id} page={page} />
-          ))
-        }
+        {data != null &&
+          data
+            .flatMap((apiResult) => apiResult.pages)
+            .map((page) => <TimelineCard key={page._id} page={page} />)}
       </InfiniteScroll>
     </div>
   );

+ 46 - 27
apps/app/src/client/components/PaginationWrapper.tsx

@@ -1,25 +1,18 @@
 import type { FC } from 'react';
-import React, {
-  memo, useCallback, useMemo, type JSX,
-} from 'react';
-
+import React, { type JSX, memo, useCallback, useMemo } from 'react';
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
-
 type Props = {
-  activePage: number,
-  changePage?: (activePage: number) => void,
-  totalItemsCount: number,
-  pagingLimit?: number,
-  align?: string,
-  size?: string,
+  activePage: number;
+  changePage?: (activePage: number) => void;
+  totalItemsCount: number;
+  pagingLimit?: number;
+  align?: string;
+  size?: string;
 };
 
-
 const PaginationWrapper: FC<Props> = memo((props: Props) => {
-  const {
-    activePage, changePage, totalItemsCount, pagingLimit, align,
-  } = props;
+  const { activePage, changePage, totalItemsCount, pagingLimit, align } = props;
 
   /**
    * various numbers used to generate pagination dom
@@ -29,7 +22,9 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     const limit = pagingLimit || Infinity;
 
     // calc totalPageNumber
-    const totalPage = Math.floor(totalItemsCount / limit) + (totalItemsCount % limit === 0 ? 0 : 1);
+    const totalPage =
+      Math.floor(totalItemsCount / limit) +
+      (totalItemsCount % limit === 0 ? 0 : 1);
 
     let paginationStart = activePage - 2;
     let maxViewPageNum = activePage + 2;
@@ -67,14 +62,23 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     if (activePage !== 1) {
       paginationItems.push(
         <PaginationItem key="painationItemFirst">
-          <PaginationLink first onClick={() => { return changePage != null && changePage(1) }} />
+          <PaginationLink
+            first
+            onClick={() => {
+              return changePage != null && changePage(1);
+            }}
+          />
         </PaginationItem>,
         <PaginationItem key="painationItemPrevious">
-          <PaginationLink previous onClick={() => { return changePage != null && changePage(activePage - 1) }} />
+          <PaginationLink
+            previous
+            onClick={() => {
+              return changePage != null && changePage(activePage - 1);
+            }}
+          />
         </PaginationItem>,
       );
-    }
-    else {
+    } else {
       paginationItems.push(
         <PaginationItem key="painationItemFirst" disabled>
           <PaginationLink first />
@@ -96,8 +100,15 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     const paginationItems: JSX.Element[] = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
-        <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
-          <PaginationLink onClick={() => { return changePage != null && changePage(number) }}>
+        <PaginationItem
+          key={`paginationItem-${number}`}
+          active={number === activePage}
+        >
+          <PaginationLink
+            onClick={() => {
+              return changePage != null && changePage(number);
+            }}
+          >
             {number}
           </PaginationLink>
         </PaginationItem>,
@@ -116,14 +127,23 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     if (totalPage !== activePage) {
       paginationItems.push(
         <PaginationItem key="painationItemNext">
-          <PaginationLink next onClick={() => { return changePage != null && changePage(activePage + 1) }} />
+          <PaginationLink
+            next
+            onClick={() => {
+              return changePage != null && changePage(activePage + 1);
+            }}
+          />
         </PaginationItem>,
         <PaginationItem key="painationItemLast">
-          <PaginationLink last onClick={() => { return changePage != null && changePage(totalPage) }} />
+          <PaginationLink
+            last
+            onClick={() => {
+              return changePage != null && changePage(totalPage);
+            }}
+          />
         </PaginationItem>,
       );
-    }
-    else {
+    } else {
       paginationItems.push(
         <PaginationItem key="painationItemNext" disabled>
           <PaginationLink next />
@@ -158,7 +178,6 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
       </Pagination>
     </React.Fragment>
   );
-
 });
 
 PaginationWrapper.displayName = 'PaginationWrapper';

+ 24 - 17
apps/app/src/client/components/PasswordResetExecutionForm.tsx

@@ -1,16 +1,14 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
-import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:passwordReset');
 
-
 const PasswordResetExecutionForm: FC = () => {
   const { t } = useTranslation(['translation', 'commons']);
 
@@ -22,7 +20,7 @@ const PasswordResetExecutionForm: FC = () => {
   const pathname = window.location.pathname.split('/');
   const token = pathname[2];
 
-  const changePassword = async(e) => {
+  const changePassword = async (e) => {
     e.preventDefault();
 
     if (newPassword === '' || newPasswordConfirm === '') {
@@ -31,28 +29,32 @@ const PasswordResetExecutionForm: FC = () => {
     }
 
     if (newPassword !== newPasswordConfirm) {
-      setValidationErrorI18n('forgot_password.password_and_confirm_password_does_not_match');
+      setValidationErrorI18n(
+        'forgot_password.password_and_confirm_password_does_not_match',
+      );
       return;
     }
 
     try {
       await apiv3Put('/forgot-password', {
-        token, newPassword, newPasswordConfirm,
+        token,
+        newPassword,
+        newPasswordConfirm,
       });
 
       setValidationErrorI18n('');
 
-      toastSuccess(t('toaster.update_successed', { target: t('Password'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', { target: t('Password'), ns: 'commons' }),
+      );
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
-
   };
 
   return (
-    <form role="form" onSubmit={changePassword}>
+    <form onSubmit={changePassword}>
       <div>
         <div className="input-group">
           <input
@@ -60,7 +62,7 @@ const PasswordResetExecutionForm: FC = () => {
             placeholder={t('forgot_password.new_password')}
             className="form-control"
             type="password"
-            onChange={e => setNewPassword(e.target.value)}
+            onChange={(e) => setNewPassword(e.target.value)}
           />
         </div>
       </div>
@@ -71,7 +73,7 @@ const PasswordResetExecutionForm: FC = () => {
             placeholder={t('forgot_password.confirm_new_password')}
             className="form-control"
             type="password"
-            onChange={e => setNewPasswordConfirm(e.target.value)}
+            onChange={(e) => setNewPasswordConfirm(e.target.value)}
           />
         </div>
         {validationErrorI18n !== '' && (
@@ -79,14 +81,19 @@ const PasswordResetExecutionForm: FC = () => {
         )}
       </div>
       <div>
-        <input name="reset-password-btn" className="btn btn-lg btn-primary" value={t('forgot_password.reset_password')} type="submit" />
+        <input
+          name="reset-password-btn"
+          className="btn btn-lg btn-primary"
+          value={t('forgot_password.reset_password')}
+          type="submit"
+        />
       </div>
       <Link href="/login" prefetch={false}>
-        <span className="material-symbols-outlined">login</span>{t('forgot_password.sign_in_instead')}
+        <span className="material-symbols-outlined">login</span>
+        {t('forgot_password.sign_in_instead')}
       </Link>
     </form>
   );
 };
 
-
 export default PasswordResetExecutionForm;

+ 28 - 22
apps/app/src/client/components/PasswordResetRequestForm.tsx

@@ -1,12 +1,11 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
+import Link from 'next/link';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 
 const PasswordResetRequestForm: FC = () => {
@@ -18,21 +17,23 @@ const PasswordResetRequestForm: FC = () => {
     setEmail(inputValue);
   }, []);
 
-  const sendPasswordResetRequestMail = useCallback(async(e) => {
-    e.preventDefault();
-    if (email === '') {
-      toastError(t('forgot_password.email_is_required'));
-      return;
-    }
+  const sendPasswordResetRequestMail = useCallback(
+    async (e) => {
+      e.preventDefault();
+      if (email === '') {
+        toastError(t('forgot_password.email_is_required'));
+        return;
+      }
 
-    try {
-      await apiv3Post('/forgot-password', { email });
-      toastSuccess(t('forgot_password.success_to_send_email'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, email]);
+      try {
+        await apiv3Post('/forgot-password', { email });
+        toastSuccess(t('forgot_password.success_to_send_email'));
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, email],
+  );
 
   return (
     <form onSubmit={sendPasswordResetRequestMail}>
@@ -43,8 +44,12 @@ const PasswordResetRequestForm: FC = () => {
       ) : (
         <>
           {/* lock-icon large */}
-          <h1><span className="material-symbols-outlined">lock</span></h1>
-          <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
+          <h1>
+            <span className="material-symbols-outlined">lock</span>
+          </h1>
+          <h1 className="text-center">
+            {t('forgot_password.forgot_password')}
+          </h1>
           <h3>{t('forgot_password.password_reset_request_desc')}</h3>
           <div>
             <div className="input-group">
@@ -54,7 +59,7 @@ const PasswordResetRequestForm: FC = () => {
                 className="form-control"
                 type="email"
                 disabled={!isMailerSetup}
-                onChange={e => changeEmail(e.target.value)}
+                onChange={(e) => changeEmail(e.target.value)}
               />
             </div>
           </div>
@@ -70,7 +75,8 @@ const PasswordResetRequestForm: FC = () => {
         </>
       )}
       <Link href="/login" prefetch={false}>
-        <span className="material-symbols-outlined">login</span>{t('forgot_password.return_to_login')}
+        <span className="material-symbols-outlined">login</span>
+        {t('forgot_password.return_to_login')}
       </Link>
     </form>
   );

+ 45 - 24
apps/app/src/client/components/PrivateLegacyPagesMigrationModal.tsx

@@ -1,17 +1,20 @@
-import React, { useState, useCallback, useMemo } from 'react';
-
+import type React from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import type { ILegacyPrivatePage, PrivateLegacyPagesMigrationModalSubmitedHandler } from '~/states/ui/modal/private-legacy-pages-migration';
-import { usePrivateLegacyPagesMigrationModalActions, usePrivateLegacyPagesMigrationModalStatus } from '~/states/ui/modal/private-legacy-pages-migration';
+import type {
+  ILegacyPrivatePage,
+  PrivateLegacyPagesMigrationModalSubmitedHandler,
+} from '~/states/ui/modal/private-legacy-pages-migration';
+import {
+  usePrivateLegacyPagesMigrationModalActions,
+  usePrivateLegacyPagesMigrationModalStatus,
+} from '~/states/ui/modal/private-legacy-pages-migration';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-
 /**
  * PrivateLegacyPagesMigrationModalSubstance - Presentation component (all logic here)
  */
@@ -24,7 +27,10 @@ type PrivateLegacyPagesMigrationModalSubstanceProps = {
   close: () => void;
 };
 
-const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLegacyPagesMigrationModalSubstanceProps): React.JSX.Element => {
+const PrivateLegacyPagesMigrationModalSubstance = ({
+  status,
+  close,
+}: PrivateLegacyPagesMigrationModalSubstanceProps): React.JSX.Element => {
   const { t } = useTranslation();
 
   const [isRecursively, setIsRecursively] = useState(true);
@@ -33,13 +39,13 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
   const [errs, setErrs] = useState<Error[] | null>(null);
 
   // Memoize submit handler
-  const submit = useCallback(async() => {
+  const submit = useCallback(async () => {
     if (status == null || status.pages == null || status.pages.length === 0) {
       return;
     }
 
     const { pages, onSubmit } = status;
-    const pageIds = pages.map(page => page.pageId);
+    const pageIds = pages.map((page) => page.pageId);
     try {
       await apiv3Post<void>('/pages/legacy-pages-migration', {
         pageIds,
@@ -49,16 +55,18 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
       if (onSubmit != null) {
         onSubmit(pages, isRecursively);
       }
-    }
-    catch (err) {
+    } catch (err) {
       setErrs([err]);
     }
   }, [status, isRecursively]);
 
   // Memoize checkbox handler
-  const handleRecursivelyChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    setIsRecursively(e.target.checked);
-  }, []);
+  const handleRecursivelyChange = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      setIsRecursively(e.target.checked);
+    },
+    [],
+  );
 
   // Memoize form rendering
   const renderForm = useMemo(() => {
@@ -71,9 +79,15 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
           checked={isRecursively}
           onChange={handleRecursivelyChange}
         />
-        <label className="form-label form-check-label" htmlFor="convertRecursively">
-          { t('private_legacy_pages.modal.convert_recursively_label') }
-          <p className="form-text text-muted mt-0"> { t('private_legacy_pages.modal.convert_recursively_desc') }</p>
+        <label
+          className="form-label form-check-label"
+          htmlFor="convertRecursively"
+        >
+          {t('private_legacy_pages.modal.convert_recursively_label')}
+          <p className="form-text text-muted mt-0">
+            {' '}
+            {t('private_legacy_pages.modal.convert_recursively_desc')}
+          </p>
         </label>
       </div>
     );
@@ -82,7 +96,11 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
   // Memoize page IDs rendering
   const renderPageIds = useMemo(() => {
     if (status != null && status.pages != null) {
-      return status.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+      return status.pages.map((page) => (
+        <div key={page.pageId}>
+          <code>{page.path}</code>
+        </div>
+      ));
     }
     return <></>;
   }, [status]);
@@ -90,11 +108,12 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
   return (
     <div>
       <ModalHeader tag="h4" toggle={close}>
-        { t('private_legacy_pages.modal.title') }
+        {t('private_legacy_pages.modal.title')}
       </ModalHeader>
       <ModalBody>
         <div className="grw-scrollable-modal-body pb-1">
-          <label>{ t('private_legacy_pages.modal.converting_pages') }:</label><br />
+          <div>{t('private_legacy_pages.modal.converting_pages')}:</div>
+          <br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPageIds}
@@ -104,8 +123,10 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
         <button type="button" className="btn btn-primary" onClick={submit}>
-          <span className="material-symbols-outlined" aria-hidden="true">refresh</span>
-          { t('private_legacy_pages.modal.button_label') }
+          <span className="material-symbols-outlined" aria-hidden="true">
+            refresh
+          </span>
+          {t('private_legacy_pages.modal.button_label')}
         </button>
       </ModalFooter>
     </div>

+ 82 - 13
apps/app/src/client/components/RecentActivity/ActivityListItem.tsx

@@ -1,9 +1,10 @@
 import { formatDistanceToNow } from 'date-fns';
-import { useTranslation } from 'next-i18next';
 import { type Locale } from 'date-fns/locale';
-import { getLocale } from '~/server/util/locale-utils';
-import type { ActivityHasUserId, SupportedActivityActionType } from '~/interfaces/activity';
+import { useTranslation } from 'next-i18next';
+
+import type { SupportedActivityActionType, ActivityHasTargetPage } from '~/interfaces/activity';
 import { ActivityLogActions } from '~/interfaces/activity';
+import { getLocale } from '~/server/util/locale-utils';
 
 
 export const ActivityActionTranslationMap: Record<
@@ -36,6 +37,18 @@ export const IconActivityTranslationMap: Record<
   [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment',
 };
 
+type ActivityListItemProps = {
+  activity: ActivityHasTargetPage,
+}
+
+type AllowPageDisplayPayload = {
+  grant: number | undefined,
+  status: string,
+  wip: boolean,
+  deletedAt?: Date,
+  path: string,
+}
+
 const translateAction = (action: SupportedActivityActionType): string => {
   return ActivityActionTranslationMap[action] || 'unknown_action';
 };
@@ -53,29 +66,85 @@ const calculateTimePassed = (date: Date, locale: Locale): string => {
   return timePassed;
 };
 
+const pageAllowedForDisplay = (allowDisplayPayload: AllowPageDisplayPayload): boolean => {
+  const {
+    grant, status, wip, deletedAt,
+  } = allowDisplayPayload;
+  if (grant !== 1) return false;
+
+  if (status !== 'published') return false;
+
+  if (wip) return false;
+
+  if (deletedAt) return false;
+
+  return true;
+};
+
+const setPath = (path: string, allowed: boolean): string => {
+  if (allowed) return path;
 
-export const ActivityListItem = ({ activity }: { activity: ActivityHasUserId }): JSX.Element => {
+  return '';
+};
+
+
+export const ActivityListItem = ({ props }: { props: ActivityListItemProps }): JSX.Element => {
   const { t, i18n } = useTranslation();
   const currentLangCode = i18n.language;
   const dateFnsLocale = getLocale(currentLangCode);
 
+  const { activity } = props;
+
+  const {
+    path, grant, status, wip, deletedAt,
+  } = activity.target;
+
+
+  const allowDisplayPayload: AllowPageDisplayPayload = {
+    grant,
+    status,
+    wip,
+    deletedAt,
+    path,
+  };
+
+  const isPageAllowed = pageAllowedForDisplay(allowDisplayPayload);
+
   const action = activity.action as SupportedActivityActionType;
   const keyToTranslate = translateAction(action);
   const fullKeyPath = `user_home_page.${keyToTranslate}`;
 
   return (
     <div className="activity-row">
-      <p className="mb-1">
-        <span className="material-symbols-outlined me-2">{setIcon(action)}</span>
-
-        <span className="dark:text-white">
-          {' '}{t(fullKeyPath)}
+      <div className="d-flex align-items-center">
+        <span className="material-symbols-outlined me-2 flex-shrink-0">
+          {setIcon(action)}
         </span>
 
-        <span className="text-secondary small ms-3">
-          {calculateTimePassed(activity.createdAt, dateFnsLocale)}
-        </span>
-      </p>
+        <div className="flex-grow-1 ms-2">
+          <div className="activity-path-line mb-0">
+            <a
+              href={setPath(path, isPageAllowed)}
+              className="activity-target-link fw-bold text-wrap d-block"
+            >
+              <span>
+                {setPath(path, isPageAllowed)}
+              </span>
+            </a>
+          </div>
+
+          <div className="activity-details-line d-flex">
+            <span>
+              {t(fullKeyPath)}
+            </span>
+
+            <span className="text-secondary small ms-3 align-self-center">
+              {calculateTimePassed(activity.createdAt, dateFnsLocale)}
+            </span>
+
+          </div>
+        </div>
+      </div>
     </div>
   );
 };

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

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import { toastError } from '~/client/util/toastr';
-import type { IActivityHasId, ActivityHasUserId } from '~/interfaces/activity';
+import type { IActivityHasId, ActivityHasTargetPage } from '~/interfaces/activity';
 import { useSWRxRecentActivity } from '~/stores/recent-activity';
 import loggerFactory from '~/utils/logger';
 
@@ -18,15 +18,17 @@ type RecentActivityProps = {
   userId: string,
 }
 
-const hasUser = (activity: IActivityHasId): activity is ActivityHasUserId => {
+const hasTargetPage = (activity: IActivityHasId): activity is ActivityHasTargetPage => {
   return activity.user != null
-        && typeof activity.user === 'object';
+         && typeof activity.user === 'object'
+         && activity.target != null
+         && typeof activity.target === 'object';
 };
 
 export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
   const { userId } = props;
 
-  const [activities, setActivities] = useState<ActivityHasUserId[]>([]);
+  const [activities, setActivities] = useState<ActivityHasTargetPage[]>([]);
   const [activePage, setActivePage] = useState(1);
   const [limit] = useState(10);
   const [offset, setOffset] = useState(0);
@@ -49,7 +51,7 @@ export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
 
     if (paginatedData) {
       const activitiesWithPages = paginatedData.docs
-        .filter(hasUser);
+        .filter(hasTargetPage);
 
       setActivities(activitiesWithPages);
     }
@@ -63,7 +65,7 @@ export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
       <ul className="page-list-ul page-list-ul-flat mb-3">
         {activities.map(activity => (
           <li key={`recent-activity-view:${activity._id}`} className="mt-4">
-            <ActivityListItem activity={activity} />
+            <ActivityListItem props={{ activity }} />
           </li>
         ))}
       </ul>

+ 169 - 117
apps/app/src/client/components/SearchTypeahead.tsx

@@ -1,12 +1,23 @@
+import type React from 'react';
 import type {
-  FC, ForwardRefRenderFunction,
-  KeyboardEvent, MouseEvent,
+  FC,
+  ForwardRefRenderFunction,
+  KeyboardEvent,
+  MouseEvent,
 } from 'react';
-import React, {
-  forwardRef, useImperativeHandle, useCallback, useRef, useState, useEffect,
+import {
+  forwardRef,
+  useCallback,
+  useEffect,
+  useImperativeHandle,
+  useRef,
+  useState,
 } from 'react';
-
-import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
+import {
+  PageListMeta,
+  PagePathLabel,
+  UserPicture,
+} from '@growi/ui/dist/components';
 import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
@@ -15,16 +26,16 @@ import type { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahe
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import { useSWRxSearch } from '~/stores/search';
 
-
 import styles from './SearchTypeahead.module.scss';
 
-
 type ResetFormButtonProps = {
-  input?: string,
-  onReset: (e: MouseEvent<HTMLButtonElement>) => void,
-}
+  input?: string;
+  onReset: (e: MouseEvent<HTMLButtonElement>) => void;
+};
 
-const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps) => {
+const ResetFormButton: FC<ResetFormButtonProps> = (
+  props: ResetFormButtonProps,
+) => {
   const { input, onReset } = props;
 
   const isHidden = input == null || input.length === 0;
@@ -32,88 +43,113 @@ const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps)
   return isHidden ? (
     <span />
   ) : (
-    <button type="button" className="btn btn-outline-secondary search-clear text-muted border-0" onMouseDown={onReset}>
+    <button
+      type="button"
+      className="btn btn-outline-secondary search-clear text-muted border-0"
+      onMouseDown={onReset}
+    >
       <span className="material-symbols-outlined">close</span>
     </button>
   );
 };
 
-
 type Props = TypeaheadProps & {
-  onSearchError?: (err: Error) => void,
-  onSubmit?: (input: string) => void,
-  keywordOnInit?: string,
-  disableIncrementalSearch?: boolean,
-  helpElement?: React.ReactNode,
+  onSearchError?: (err: Error) => void;
+  onSubmit?: (input: string) => void;
+  keywordOnInit?: string;
+  disableIncrementalSearch?: boolean;
+  helpElement?: React.ReactNode;
 };
 
-const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
+const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (
+  props: Props,
+  ref,
+) => {
   const {
-    onSearchError, onSearch, onInputChange, onChange, onSubmit,
-    inputProps, keywordOnInit, disableIncrementalSearch, helpElement,
-    onBlur, onFocus,
+    onSearchError,
+    onSearch,
+    onInputChange,
+    onChange,
+    onSubmit,
+    inputProps,
+    keywordOnInit,
+    disableIncrementalSearch,
+    helpElement,
+    onBlur,
+    onFocus,
   } = props;
 
   const [input, setInput] = useState(keywordOnInit);
   const [searchKeyword, setSearchKeyword] = useState('');
   const [isFocused, setFocused] = useState(false);
 
-  const { data: searchResult, error: searchError, isLoading } = useSWRxSearch(
-    disableIncrementalSearch ? null : searchKeyword,
-    null,
-    { limit: 10 },
-  );
+  const {
+    data: searchResult,
+    error: searchError,
+    isLoading,
+  } = useSWRxSearch(disableIncrementalSearch ? null : searchKeyword, null, {
+    limit: 10,
+  });
 
   const typeaheadRef = useRef<TypeaheadRef>(null);
 
-  const focusToTypeahead = () => {
+  const focusToTypeahead = useCallback(() => {
     const instance = typeaheadRef.current;
     if (instance != null) {
       instance.focus();
     }
-  };
+  }, []);
 
-  const clearTypeahead = () => {
+  const clearTypeahead = useCallback(() => {
     const instance = typeaheadRef.current;
     if (instance != null) {
       instance.clear();
     }
-  };
+  }, []);
 
   // publish focus()
   useImperativeHandle(ref, () => ({
     focus: focusToTypeahead,
   }));
 
-  const resetForm = useCallback((e: MouseEvent<HTMLButtonElement>) => {
-    e.preventDefault();
+  const resetForm = useCallback(
+    (e: MouseEvent<HTMLButtonElement>) => {
+      e.preventDefault();
 
-    setInput('');
-    setSearchKeyword('');
+      setInput('');
+      setSearchKeyword('');
 
-    clearTypeahead();
-    focusToTypeahead();
+      clearTypeahead();
+      focusToTypeahead();
 
-    if (onSearch != null) {
-      onSearch('');
-    }
-  }, [onSearch]);
+      if (onSearch != null) {
+        onSearch('');
+      }
+    },
+    [onSearch, clearTypeahead, focusToTypeahead],
+  );
 
-  const searchHandler = useCallback((text: string) => {
-    setSearchKeyword(text);
+  const searchHandler = useCallback(
+    (text: string) => {
+      setSearchKeyword(text);
 
-    if (onSearch != null) {
-      onSearch(text);
-    }
-  }, [onSearch]);
+      if (onSearch != null) {
+        onSearch(text);
+      }
+    },
+    [onSearch],
+  );
 
-  const inputChangeHandler = useCallback((text: string) => {
-    setInput(text);
+  const inputChangeHandler = useCallback(
+    (text: string) => {
+      setInput(text);
 
-    if (onInputChange != null) {
-      onInputChange(text);
-    }
-  }, [onInputChange]);
+      if (onInputChange != null) {
+        onInputChange(text);
+      }
+    },
+    [onInputChange],
+  );
 
   /* -------------------------------------------------------------------------------------------------------
    *
@@ -127,38 +163,47 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   const DELAY_FOR_SUBMISSION = 100;
   const timeoutIdRef = useRef<NodeJS.Timeout>();
 
-  const changeHandler = useCallback((selectedItems: IPageWithSearchMeta[]) => {
-    // cancel schedule to submit
-    if (timeoutIdRef.current != null) {
-      clearTimeout(timeoutIdRef.current);
-    }
+  const changeHandler = useCallback(
+    (selectedItems: IPageWithSearchMeta[]) => {
+      // cancel schedule to submit
+      if (timeoutIdRef.current != null) {
+        clearTimeout(timeoutIdRef.current);
+      }
 
-    if (selectedItems.length > 0) {
-      setInput(selectedItems[0].data.path);
+      if (selectedItems.length > 0) {
+        setInput(selectedItems[0].data.path);
 
-      if (onInputChange != null) {
-        onInputChange(selectedItems[0].data.path);
-      }
+        if (onInputChange != null) {
+          onInputChange(selectedItems[0].data.path);
+        }
 
-      if (onChange != null) {
-        onChange(selectedItems);
+        if (onChange != null) {
+          onChange(selectedItems);
+        }
       }
-    }
-  }, [onChange, onInputChange]);
-
-  const keyDownHandler = useCallback((event: KeyboardEvent) => {
-    if (event.key === 'Enter') {
-      // do nothing while composing
-      // "event.isComposing" is not supported
-      if (event.nativeEvent.isComposing) {
-        return;
-      }
-      if (onSubmit != null && input != null && input.length > 0) {
-        // schedule to submit with 100ms delay
-        timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);
+    },
+    [onChange, onInputChange],
+  );
+
+  const keyDownHandler = useCallback(
+    (event: KeyboardEvent) => {
+      if (event.key === 'Enter') {
+        // do nothing while composing
+        // "event.isComposing" is not supported
+        if (event.nativeEvent.isComposing) {
+          return;
+        }
+        if (onSubmit != null && input != null && input.length > 0) {
+          // schedule to submit with 100ms delay
+          timeoutIdRef.current = setTimeout(
+            () => onSubmit(input),
+            DELAY_FOR_SUBMISSION,
+          );
+        }
       }
-    }
-  }, [input, onSubmit]);
+    },
+    [input, onSubmit],
+  );
   /*
    * -------------------------------------------------------------------------------------------------------
    */
@@ -179,49 +224,59 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     }
   }, [keywordOnInit]);
 
-
   const labelKey = useCallback((option: IPageWithSearchMeta) => {
     return option.data.path ?? '';
   }, []);
 
-  const renderMenu = useCallback((options: IPageWithSearchMeta[], menuProps) => {
-    if (!isFocused) {
-      return <></>;
-    }
+  const renderMenu = useCallback(
+    (options: IPageWithSearchMeta[], menuProps) => {
+      if (!isFocused) {
+        return <></>;
+      }
 
-    const isEmptyInput = input == null || input.length === 0;
-    if (isEmptyInput) {
-      if (helpElement == null) {
+      const isEmptyInput = input == null || input.length === 0;
+      if (isEmptyInput) {
+        if (helpElement == null) {
+          return <></>;
+        }
+
+        return (
+          <Menu {...menuProps}>
+            <div className="p-3">{helpElement}</div>
+          </Menu>
+        );
+      }
+
+      if (disableIncrementalSearch) {
         return <></>;
       }
 
       return (
         <Menu {...menuProps}>
-          <div className="p-3">
-            {helpElement}
-          </div>
+          {options.map((pageWithMeta, index) => (
+            <MenuItem
+              key={pageWithMeta.data._id}
+              option={pageWithMeta}
+              position={index}
+            >
+              <span>
+                <UserPicture
+                  user={pageWithMeta.data.lastUpdateUser}
+                  size="sm"
+                  noLink
+                />
+                <span className="ms-1 me-2 text-break text-wrap">
+                  <PagePathLabel path={pageWithMeta.data.path} />
+                </span>
+                <PageListMeta page={pageWithMeta.data} />
+              </span>
+            </MenuItem>
+          ))}
         </Menu>
       );
-    }
-
-    if (disableIncrementalSearch) {
-      return <></>;
-    }
-
-    return (
-      <Menu {...menuProps}>
-        {options.map((pageWithMeta, index) => (
-          <MenuItem key={pageWithMeta.data._id} option={pageWithMeta} position={index}>
-            <span>
-              <UserPicture user={pageWithMeta.data.lastUpdateUser} size="sm" noLink />
-              <span className="ms-1 me-2 text-break text-wrap"><PagePathLabel path={pageWithMeta.data.path} /></span>
-              <PageListMeta page={pageWithMeta.data} />
-            </span>
-          </MenuItem>
-        ))}
-      </Menu>
-    );
-  }, [disableIncrementalSearch, helpElement, input, isFocused]);
+    },
+    [disableIncrementalSearch, helpElement, input, isFocused],
+  );
 
   const isOpenAlways = helpElement != null;
 
@@ -233,7 +288,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         ref={typeaheadRef}
         delay={400}
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        inputProps={{ autoComplete: 'off', ...(inputProps as any ?? {}) }}
+        inputProps={{ autoComplete: 'off', ...((inputProps as any) ?? {}) }}
         isLoading={isLoading}
         labelKey={labelKey}
         defaultInputValue={keywordOnInit}
@@ -259,10 +314,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
           }
         }}
       />
-      <ResetFormButton
-        input={input}
-        onReset={resetForm}
-      />
+      <ResetFormButton input={input} onReset={resetForm} />
     </div>
   );
 };

+ 55 - 49
apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx

@@ -1,77 +1,83 @@
-import React, { memo, type JSX } from 'react';
-
+import React, { type JSX, memo } from 'react';
 import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { useAppTitle, useConfidential, useIsDefaultLogo } from '~/states/global';
+import {
+  useAppTitle,
+  useConfidential,
+  useIsDefaultLogo,
+} from '~/states/global';
 
 import { SidebarBrandLogo } from '../SidebarBrandLogo';
 
 import styles from './AppTitle.module.scss';
 
-
 type Props = {
-  className?: string,
+  className?: string;
   hideAppTitle?: boolean;
-}
+};
 
-const AppTitleSubstance = memo(({ className = '', hideAppTitle = false }: Props): JSX.Element => {
+const AppTitleSubstance = memo(
+  ({ className = '', hideAppTitle = false }: Props): JSX.Element => {
+    const isDefaultLogo = useIsDefaultLogo();
+    const appTitle = useAppTitle();
+    const confidential = useConfidential();
 
-  const isDefaultLogo = useIsDefaultLogo();
-  const appTitle = useAppTitle();
-  const confidential = useConfidential();
-
-  return (
-    <div className={`${styles['grw-app-title']} ${className} d-flex`}>
-      {/* Brand Logo  */}
-      <Link href="/" className="grw-logo d-block">
-        <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
-      </Link>
-      <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
-        {!hideAppTitle && (
-          <div id="grw-site-name" className="grw-site-name text-truncate">
-            <Link href="/" className="fs-4">
-              {appTitle}
-            </Link>
-          </div>
+    return (
+      <div className={`${styles['grw-app-title']} ${className} d-flex`}>
+        {/* Brand Logo  */}
+        <Link href="/" className="grw-logo d-block">
+          <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
+        </Link>
+        <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
+          {!hideAppTitle && (
+            <div id="grw-site-name" className="grw-site-name text-truncate">
+              <Link href="/" className="fs-4">
+                {appTitle}
+              </Link>
+            </div>
+          )}
+        </div>
+        {!(confidential == null || confidential === '') && (
+          <UncontrolledTooltip
+            className="d-none d-sm-block confidential-tooltip"
+            innerClassName="text-start"
+            data-testid="confidential-tooltip"
+            placement="top"
+            target="grw-site-name"
+            fade={false}
+          >
+            {confidential}
+          </UncontrolledTooltip>
         )}
       </div>
-      {!(confidential == null || confidential === '')
-      && (
-        <UncontrolledTooltip
-          className="d-none d-sm-block confidential-tooltip"
-          innerClassName="text-start"
-          data-testid="confidential-tooltip"
-          placement="top"
-          target="grw-site-name"
-          fade={false}
-        >
-          {confidential}
-        </UncontrolledTooltip>
-      )}
-    </div>
-  );
-});
+    );
+  },
+);
 
 export const AppTitleOnSubnavigation = memo((): JSX.Element => {
-  return <AppTitleSubstance className={`position-absolute ${styles['on-subnavigation']}`} />;
-});
-
-export const AppTitleOnSidebarHead = memo(({ hideAppTitle }: Props): JSX.Element => {
   return (
     <AppTitleSubstance
-      className={`position-absolute z-1 ${styles['on-sidebar-head']}`}
-      hideAppTitle={hideAppTitle}
+      className={`position-absolute ${styles['on-subnavigation']}`}
     />
   );
 });
 
+export const AppTitleOnSidebarHead = memo(
+  ({ hideAppTitle }: Props): JSX.Element => {
+    return (
+      <AppTitleSubstance
+        className={`position-absolute z-1 ${styles['on-sidebar-head']}`}
+        hideAppTitle={hideAppTitle}
+      />
+    );
+  },
+);
+
 export const AppTitleOnEditorSidebarHead = memo((): JSX.Element => {
   return (
     <div className={`${styles['on-editor-sidebar-head']}`}>
-      <AppTitleSubstance
-        className={`${styles['on-sidebar-head']}`}
-      />
+      <AppTitleSubstance className={`${styles['on-sidebar-head']}`} />
     </div>
   );
 });

+ 2 - 6
apps/app/src/client/components/Sidebar/Bookmarks.tsx

@@ -1,13 +1,11 @@
-
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 import { useIsGuestUser } from '~/states/context';
 
 import { BookmarkContents } from './Bookmarks/BookmarkContents';
 
-export const Bookmarks = () : JSX.Element => {
+export const Bookmarks = (): JSX.Element => {
   const { t } = useTranslation();
   const isGuestUser = useIsGuestUser();
 
@@ -17,9 +15,7 @@ export const Bookmarks = () : JSX.Element => {
         <h3 className="fs-6 fw-bold mb-0 py-4">{t('Bookmarks')}</h3>
       </div>
       {isGuestUser ? (
-        <h4 className="fs-6">
-          { t('Not available for guest') }
-        </h4>
+        <h4 className="fs-6">{t('Not available for guest')}</h4>
       ) : (
         <BookmarkContents />
       )}

+ 20 - 22
apps/app/src/client/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { BookmarkFolderNameInput } from '~/client/components/Bookmarks/BookmarkFolderNameInput';
@@ -10,12 +9,13 @@ import { useCurrentUser } from '~/states/global';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 
 export const BookmarkContents = (): JSX.Element => {
-
   const { t } = useTranslation();
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
   const currentUser = useCurrentUser();
-  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
+  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(
+    currentUser?._id,
+  );
 
   const onClickNewBookmarkFolder = useCallback(() => {
     setIsCreateAction(true);
@@ -25,20 +25,22 @@ export const BookmarkContents = (): JSX.Element => {
     setIsCreateAction(false);
   }, []);
 
-  const create = useCallback(async(folderName: string) => {
-    if (folderName.trim() === '') {
-      return cancel();
-    }
+  const create = useCallback(
+    async (folderName: string) => {
+      if (folderName.trim() === '') {
+        return cancel();
+      }
 
-    try {
-      await addNewFolder(folderName.trim(), null);
-      await mutateBookmarkFolders();
-      setIsCreateAction(false);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [cancel, mutateBookmarkFolders]);
+      try {
+        await addNewFolder(folderName.trim(), null);
+        await mutateBookmarkFolders();
+        setIsCreateAction(false);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [cancel, mutateBookmarkFolders],
+  );
 
   return (
     <div>
@@ -48,7 +50,6 @@ export const BookmarkContents = (): JSX.Element => {
           className="btn btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"
           onClick={onClickNewBookmarkFolder}
         >
-
           <div className="d-flex align-items-center">
             <span className="material-symbols-outlined">create_new_folder</span>
             <span className="ms-2">{t('bookmark_folder.new_folder')}</span>
@@ -57,10 +58,7 @@ export const BookmarkContents = (): JSX.Element => {
       </div>
       {isCreateAction && (
         <div className="col-12 mb-2 ">
-          <BookmarkFolderNameInput
-            onSubmit={create}
-            onCancel={cancel}
-          />
+          <BookmarkFolderNameInput onSubmit={create} onCancel={cancel} />
         </div>
       )}
       <BookmarkFolderTree isOperable userId={currentUser?._id} />

+ 14 - 6
apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx

@@ -1,5 +1,4 @@
-import { Suspense, type JSX } from 'react';
-
+import { type JSX, Suspense } from 'react';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useTranslation } from 'react-i18next';
@@ -9,8 +8,13 @@ import { useSWRxPageByPath } from '~/stores/page';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 import DefaultContentSkeleton from '../Skeleton/DefaultContentSkeleton';
 
-
-const CustomSidebarContent = dynamic(() => import('./CustomSidebarSubstance').then(mod => mod.CustomSidebarSubstance), { ssr: false });
+const CustomSidebarContent = dynamic(
+  () =>
+    import('./CustomSidebarSubstance').then(
+      (mod) => mod.CustomSidebarSubstance,
+    ),
+  { ssr: false },
+);
 
 export const CustomSidebar = (): JSX.Element => {
   const { t } = useTranslation();
@@ -22,9 +26,13 @@ export const CustomSidebar = (): JSX.Element => {
       <div className="grw-sidebar-content-header d-flex">
         <h3 className="fs-6 fw-bold mb-0">
           {t('Custom Sidebar')}
-          { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
+          {!isLoading && (
+            <Link href="/Sidebar#edit" className="h6 ms-2">
+              <span className="material-symbols-outlined">edit</span>
+            </Link>
+          )}
         </h3>
-        { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
+        {!isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} />}
       </div>
 
       <Suspense fallback={<DefaultContentSkeleton />}>

+ 15 - 7
apps/app/src/client/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -1,5 +1,4 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { Origin } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
@@ -10,16 +9,25 @@ export const SidebarNotFound = (): JSX.Element => {
 
   const { create } = useCreatePage();
 
-  const clickCreateButtonHandler = useCallback(async() => {
-    create({ path: '/Sidebar', wip: false, origin: Origin.View }, { skipPageExistenceCheck: true });
+  const clickCreateButtonHandler = useCallback(async () => {
+    create(
+      { path: '/Sidebar', wip: false, origin: Origin.View },
+      { skipPageExistenceCheck: true },
+    );
   }, [create]);
 
   return (
     <div>
-      <button type="button" className="btn btn-lg btn-link" onClick={clickCreateButtonHandler}>
+      <button
+        type="button"
+        className="btn btn-lg btn-link"
+        onClick={clickCreateButtonHandler}
+      >
         <span className="material-symbols-outlined">edit_note</span>
-        {/* eslint-disable-next-line react/no-danger */}
-        <span dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}></span>
+        <span
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+          dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}
+        ></span>
       </button>
     </div>
   );

+ 11 - 12
apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.tsx

@@ -9,10 +9,8 @@ import { SidebarNotFound } from './CustomSidebarNotFound';
 
 import styles from './CustomSidebarSubstance.module.scss';
 
-
 const logger = loggerFactory('growi:components:CustomSidebarSubstance');
 
-
 export const CustomSidebarSubstance = (): JSX.Element => {
   const { data: rendererOptions } = useCustomSidebarOptions({ suspense: true });
   const { data: page } = useSWRxPageByPath('/Sidebar', { suspense: true });
@@ -22,16 +20,17 @@ export const CustomSidebarSubstance = (): JSX.Element => {
   const markdown = page?.revision?.body;
 
   return (
-    <div className={`py-4 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
-      { markdown == null
-        ? <SidebarNotFound />
-        : (
-          <RevisionRenderer
-            rendererOptions={rendererOptions}
-            markdown={markdown}
-          />
-        )
-      }
+    <div
+      className={`py-4 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}
+    >
+      {markdown == null ? (
+        <SidebarNotFound />
+      ) : (
+        <RevisionRenderer
+          rendererOptions={rendererOptions}
+          markdown={markdown}
+        />
+      )}
     </div>
   );
 };

+ 18 - 10
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

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

+ 38 - 28
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -1,28 +1,34 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import InAppNotificationList from '~/client/components/InAppNotification/InAppNotificationList';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { useSWRxInAppNotifications } from '~/stores/in-app-notification';
 
-
 type InAppNotificationFormsProps = {
-  onChangeUnopendNotificationsVisible: () => void
-}
-export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.Element => {
-  const { onChangeUnopendNotificationsVisible } = props;
+  isUnopendNotificationsVisible: boolean;
+  onChangeUnopendNotificationsVisible: () => void;
+};
+export const InAppNotificationForms = (
+  props: InAppNotificationFormsProps,
+): JSX.Element => {
+  const { isUnopendNotificationsVisible, onChangeUnopendNotificationsVisible } =
+    props;
   const { t } = useTranslation('commons');
 
   return (
     <div className="my-2">
       <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">{t('in_app_notification.only_unread')}</label>
+        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">
+          {t('in_app_notification.only_unread')}
+        </label>
         <input
           id="flexSwitchCheckDefault"
           className="form-check-input"
           type="checkbox"
           role="switch"
+          aria-checked={isUnopendNotificationsVisible}
+          checked={isUnopendNotificationsVisible}
           onChange={onChangeUnopendNotificationsVisible}
         />
       </div>
@@ -30,35 +36,39 @@ export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.
   );
 };
 
-
 type InAppNotificationContentProps = {
-  isUnopendNotificationsVisible: boolean
-}
-export const InAppNotificationContent = (props: InAppNotificationContentProps): JSX.Element => {
+  isUnopendNotificationsVisible: boolean;
+};
+export const InAppNotificationContent = (
+  props: InAppNotificationContentProps,
+): JSX.Element => {
   const { isUnopendNotificationsVisible } = props;
   const { t } = useTranslation('commons');
 
   // TODO: Infinite scroll implemented (https://redmine.weseek.co.jp/issues/138057)
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
-    6,
-    undefined,
-    isUnopendNotificationsVisible ? InAppNotificationStatuses.STATUS_UNOPENED : undefined,
-    { keepPreviousData: true },
-  );
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } =
+    useSWRxInAppNotifications(
+      6,
+      undefined,
+      isUnopendNotificationsVisible
+        ? InAppNotificationStatuses.STATUS_UNOPENED
+        : undefined,
+      { keepPreviousData: true },
+    );
 
   return (
     <>
-      {inAppNotificationData != null && inAppNotificationData.docs.length === 0
-      // no items
-        ? t('in_app_notification.no_notification')
-      // render list-group
-        : (
-          <InAppNotificationList
-            inAppNotificationData={inAppNotificationData}
-            onUnopenedNotificationOpend={mutateInAppNotificationData}
-          />
-        )
-      }
+      {inAppNotificationData != null &&
+      inAppNotificationData.docs.length === 0 ? (
+        // no items
+        t('in_app_notification.no_notification')
+      ) : (
+        // render list-group
+        <InAppNotificationList
+          inAppNotificationData={inAppNotificationData}
+          onUnopenedNotificationOpend={mutateInAppNotificationData}
+        />
+      )}
     </>
   );
 };

+ 51 - 39
apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx

@@ -6,42 +6,54 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 import { PrimaryItem, type PrimaryItemProps } from '../SidebarNav/PrimaryItem';
 
-type PrimaryItemForNotificationProps = Omit<PrimaryItemProps, 'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents' >
-
-export const PrimaryItemForNotification = memo((props: PrimaryItemForNotificationProps) => {
-  const { sidebarMode, onHover } = props;
-
-  const socket = useGlobalSocket();
-
-  const { data: notificationCount, mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
-
-  const badgeContents = notificationCount != null && notificationCount > 0 ? notificationCount : undefined;
-
-  const itemHoverHandler = useCallback((contents: SidebarContentsType) => {
-    onHover?.(contents);
-  }, [onHover]);
-
-  useEffect(() => {
-    if (socket != null) {
-      socket.on('notificationUpdated', () => {
-        mutateNotificationCount();
-      });
-
-      // clean up
-      return () => {
-        socket.off('notificationUpdated');
-      };
-    }
-  }, [mutateNotificationCount, socket]);
-
-  return (
-    <PrimaryItem
-      sidebarMode={sidebarMode}
-      contents={SidebarContentsType.NOTIFICATION}
-      label="In-App Notification"
-      iconName="notifications"
-      badgeContents={badgeContents}
-      onHover={itemHoverHandler}
-    />
-  );
-});
+type PrimaryItemForNotificationProps = Omit<
+  PrimaryItemProps,
+  'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents'
+>;
+
+export const PrimaryItemForNotification = memo(
+  (props: PrimaryItemForNotificationProps) => {
+    const { sidebarMode, onHover } = props;
+
+    const socket = useGlobalSocket();
+
+    const { data: notificationCount, mutate: mutateNotificationCount } =
+      useSWRxInAppNotificationStatus();
+
+    const badgeContents =
+      notificationCount != null && notificationCount > 0
+        ? notificationCount
+        : undefined;
+
+    const itemHoverHandler = useCallback(
+      (contents: SidebarContentsType) => {
+        onHover?.(contents);
+      },
+      [onHover],
+    );
+
+    useEffect(() => {
+      if (socket != null) {
+        socket.on('notificationUpdated', () => {
+          mutateNotificationCount();
+        });
+
+        // clean up
+        return () => {
+          socket.off('notificationUpdated');
+        };
+      }
+    }, [mutateNotificationCount, socket]);
+
+    return (
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.NOTIFICATION}
+        label="In-App Notification"
+        iconName="notifications"
+        badgeContents={badgeContents}
+        onHover={itemHoverHandler}
+      />
+    );
+  },
+);

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

@@ -6,8 +6,10 @@ import styles from './CreateButton.module.scss';
 
 const moduleClass = styles['btn-create'];
 
-
-type Props = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
+type Props = DetailedHTMLProps<
+  ButtonHTMLAttributes<HTMLButtonElement>,
+  HTMLButtonElement
+>;
 
 export const CreateButton = (props: Props): JSX.Element => {
   return (
@@ -17,7 +19,9 @@ export const CreateButton = (props: Props): JSX.Element => {
       className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
     >
       <Hexagon />
-      <span className="icon material-symbols-outlined position-absolute" aria-label="Create">edit</span>
+      <span className="icon material-symbols-outlined position-absolute">
+        edit
+      </span>
     </button>
   );
 };

+ 63 - 64
apps/app/src/client/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -1,78 +1,77 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'react-i18next';
-import { DropdownMenu, DropdownItem } from 'reactstrap';
+import { DropdownItem, DropdownMenu } from 'reactstrap';
 
 import type { LabelType } from '~/interfaces/template';
 
-
 type DropendMenuProps = {
-  onClickCreateNewPage: () => Promise<void>
-  onClickOpenPageCreateModal: () => void
-  onClickCreateTodaysMemo: () => Promise<void>
-  onClickCreateTemplate?: (label: LabelType) => Promise<void>
-  todaysPath: string | null,
-}
+  onClickCreateNewPage: () => Promise<void>;
+  onClickOpenPageCreateModal: () => void;
+  onClickCreateTodaysMemo: () => Promise<void>;
+  onClickCreateTemplate?: (label: LabelType) => Promise<void>;
+  todaysPath: string | null;
+};
 
-export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
-  const {
-    onClickCreateNewPage,
-    onClickOpenPageCreateModal,
-    onClickCreateTodaysMemo,
-    onClickCreateTemplate,
-    todaysPath,
-  } = props;
+export const DropendMenu = React.memo(
+  (props: DropendMenuProps): JSX.Element => {
+    const {
+      onClickCreateNewPage,
+      onClickOpenPageCreateModal,
+      onClickCreateTodaysMemo,
+      onClickCreateTemplate,
+      todaysPath,
+    } = props;
 
-  const { t } = useTranslation('commons');
-
-  return (
-    <DropdownMenu
-      container="body"
-      data-testid="grw-page-create-button-dropend-menu"
-    >
-      <DropdownItem
-        onClick={onClickCreateNewPage}
-      >
-        {t('create_page_dropdown.new_page')}
-      </DropdownItem>
+    const { t } = useTranslation('commons');
 
-      <DropdownItem
-        onClick={onClickOpenPageCreateModal}
+    return (
+      <DropdownMenu
+        container="body"
+        data-testid="grw-page-create-button-dropend-menu"
       >
-        {t('create_page_dropdown.open_page_create_modal')}
-      </DropdownItem>
+        <DropdownItem onClick={onClickCreateNewPage}>
+          {t('create_page_dropdown.new_page')}
+        </DropdownItem>
 
+        <DropdownItem onClick={onClickOpenPageCreateModal}>
+          {t('create_page_dropdown.open_page_create_modal')}
+        </DropdownItem>
 
-      { todaysPath != null && (
-        <>
-          <DropdownItem divider />
-          <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
-          <DropdownItem
-            aria-label="Create today page"
-            onClick={onClickCreateTodaysMemo}
-          >
-            {todaysPath}
-          </DropdownItem>
-        </>
-      )}
+        {todaysPath != null && (
+          <>
+            <DropdownItem divider />
+            <li>
+              <span className="text-muted px-3">
+                {t('create_page_dropdown.todays.desc')}
+              </span>
+            </li>
+            <DropdownItem
+              aria-label="Create today page"
+              onClick={onClickCreateTodaysMemo}
+            >
+              {todaysPath}
+            </DropdownItem>
+          </>
+        )}
 
-      { onClickCreateTemplate != null && (
-        <>
-          <DropdownItem divider />
-          <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
-          <DropdownItem
-            onClick={() => onClickCreateTemplate('_template')}
-          >
-            {t('create_page_dropdown.template.children')}
-          </DropdownItem>
-          <DropdownItem
-            onClick={() => onClickCreateTemplate('__template')}
-          >
-            {t('create_page_dropdown.template.descendants')}
-          </DropdownItem>
-        </>
-      ) }
-    </DropdownMenu>
-  );
-});
+        {onClickCreateTemplate != null && (
+          <>
+            <DropdownItem divider />
+            <li>
+              <span className="text-muted text-nowrap px-3">
+                {t('create_page_dropdown.template.desc')}
+              </span>
+            </li>
+            <DropdownItem onClick={() => onClickCreateTemplate('_template')}>
+              {t('create_page_dropdown.template.children')}
+            </DropdownItem>
+            <DropdownItem onClick={() => onClickCreateTemplate('__template')}>
+              {t('create_page_dropdown.template.descendants')}
+            </DropdownItem>
+          </>
+        )}
+      </DropdownMenu>
+    );
+  },
+);
 DropendMenu.displayName = 'DropendMenu';

+ 3 - 4
apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -1,15 +1,12 @@
 import type { JSX } from 'react';
-
 import { DropdownToggle } from 'reactstrap';
 
 import { Hexagon } from './Hexagon';
 
 import styles from './DropendToggle.module.scss';
 
-
 const moduleClass = styles['btn-toggle'];
 
-
 export const DropendToggle = (): JSX.Element => {
   return (
     <DropdownToggle
@@ -21,7 +18,9 @@ export const DropendToggle = (): JSX.Element => {
     >
       <Hexagon className="pe-none" />
       <div className="hitarea position-absolute" />
-      <span className="icon material-symbols-outlined position-absolute">chevron_right</span>
+      <span className="icon material-symbols-outlined position-absolute">
+        chevron_right
+      </span>
     </DropdownToggle>
   );
 };

+ 20 - 14
apps/app/src/client/components/Sidebar/PageCreateButton/Hexagon.tsx

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

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

@@ -1,5 +1,4 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { Dropdown } from 'reactstrap';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
@@ -12,7 +11,6 @@ import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { useCreateNewPage, useCreateTodaysMemo } from './hooks';
 
-
 export const PageCreateButton = React.memo((): JSX.Element => {
   const [isHovered, setIsHovered] = useState(false);
 
@@ -23,11 +21,16 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const { createNewPage, isCreating: isNewPageCreating } = useCreateNewPage();
   // TODO: https://redmine.weseek.co.jp/issues/138806
-  const { createTodaysMemo, isCreating: isTodaysPageCreating, todaysPath } = useCreateTodaysMemo();
+  const {
+    createTodaysMemo,
+    isCreating: isTodaysPageCreating,
+    todaysPath,
+  } = useCreateTodaysMemo();
   // TODO: https://redmine.weseek.co.jp/issues/138805
   const {
     createTemplate,
-    isCreating: isTemplatePageCreating, isCreatable: isTemplatePageCreatable,
+    isCreating: isTemplatePageCreating,
+    isCreatable: isTemplatePageCreatable,
   } = useCreateTemplatePage();
 
   const createNewPageWithToastr = useToastrOnError(createNewPage);
@@ -46,20 +49,23 @@ export const PageCreateButton = React.memo((): JSX.Element => {
   const toggle = () => setDropdownOpen(!dropdownOpen);
 
   return (
-    <div
-      className="d-flex flex-row mt-2"
+    <fieldset
+      className="d-flex flex-row mt-2 border-0 p-0 m-0"
       onMouseEnter={onMouseEnterHandler}
       onMouseLeave={onMouseLeaveHandler}
       data-testid="grw-page-create-button"
+      aria-label="Page create actions"
     >
       <div className="btn-group flex-grow-1">
         <CreateButton
           className="z-2"
           onClick={createNewPageWithToastr}
-          disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
+          disabled={
+            isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating
+          }
         />
       </div>
-      { isHovered && (
+      {isHovered && (
         <Dropdown
           isOpen={dropdownOpen}
           toggle={toggle}
@@ -69,13 +75,17 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           <DropendToggle />
           <DropendMenu
             onClickCreateNewPage={createNewPageWithToastr}
-            onClickOpenPageCreateModal={() => openPageCreateModal(currentPagePath)}
+            onClickOpenPageCreateModal={() =>
+              openPageCreateModal(currentPagePath)
+            }
             onClickCreateTodaysMemo={createTodaysMemoWithToastr}
-            onClickCreateTemplate={isTemplatePageCreatable ? createTemplateWithToastr : undefined}
+            onClickCreateTemplate={
+              isTemplatePageCreatable ? createTemplateWithToastr : undefined
+            }
             todaysPath={todaysPath}
           />
         </Dropdown>
       )}
-    </div>
+    </fieldset>
   );
 });

+ 4 - 6
apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -1,22 +1,20 @@
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 
 import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentPagePath } from '~/states/page';
 
-
 type UseCreateNewPage = () => {
-  isCreating: boolean,
-  createNewPage: () => Promise<void>,
-}
+  isCreating: boolean;
+  createNewPage: () => Promise<void>;
+};
 
 export const useCreateNewPage: UseCreateNewPage = () => {
   const currentPagePath = useCurrentPagePath();
 
   const { isCreating, create } = useCreatePage();
 
-  const createNewPage = useCallback(async() => {
+  const createNewPage = useCallback(async () => {
     if (currentPagePath == null) return;
 
     return create(

+ 12 - 15
apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { format } from 'date-fns/format';
@@ -8,12 +7,11 @@ import { useTranslation } from 'react-i18next';
 import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentUser } from '~/states/global';
 
-
 type UseCreateTodaysMemo = () => {
-  isCreating: boolean,
-  todaysPath: string | null,
-  createTodaysMemo: () => Promise<void>,
-}
+  isCreating: boolean;
+  todaysPath: string | null;
+  createTodaysMemo: () => Promise<void>;
+};
 
 export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const { t } = useTranslation('commons');
@@ -26,18 +24,17 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const parentDirName = t('create_page_dropdown.todays.memo');
   const now = format(new Date(), 'yyyy/MM/dd');
   const parentPath = `${userHomepagePath(currentUser)}/${parentDirName}`;
-  const todaysPath = isCreatable
-    ? `${parentPath}/${now}`
-    : null;
+  const todaysPath = isCreatable ? `${parentPath}/${now}` : null;
 
-  const createTodaysMemo = useCallback(async() => {
+  const createTodaysMemo = useCallback(async () => {
     if (!isCreatable || todaysPath == null) return;
 
-    return create(
-      {
-        path: todaysPath, parentPath, wip: true, origin: Origin.View,
-      },
-    );
+    return create({
+      path: todaysPath,
+      parentPath,
+      wip: true,
+      origin: Origin.View,
+    });
   }, [create, isCreatable, todaysPath, parentPath]);
 
   return {

+ 5 - 6
apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx

@@ -1,20 +1,17 @@
-import { Suspense, useState, type JSX } from 'react';
-
+import { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
-
 import { PageTreeHeader } from './PageTreeSubstance';
 
 const PageTreeContent = dynamic(
-  () => import('./PageTreeSubstance').then(mod => mod.PageTreeContent),
+  () => import('./PageTreeSubstance').then((mod) => mod.PageTreeContent),
   { ssr: false, loading: ItemsTreeContentSkeleton },
 );
 
-
 export const PageTree = (): JSX.Element => {
   const { t } = useTranslation();
 
@@ -27,7 +24,9 @@ export const PageTree = (): JSX.Element => {
         <Suspense>
           <PageTreeHeader
             isWipPageShown={isWipPageShown}
-            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+            onWipPageShownChange={() => {
+              setIsWipPageShown(!isWipPageShown);
+            }}
           />
         </Suspense>
       </div>

+ 133 - 115
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,7 +1,4 @@
-import React, {
-  memo, useCallback,
-} from 'react';
-
+import React, { memo, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { ItemsTree } from '~/features/page-tree/components';
@@ -10,141 +7,162 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageId, useCurrentPagePath } from '~/states/page';
 import { useSidebarScrollerElem } from '~/states/ui/sidebar';
 import {
-  mutatePageTree, mutateRecentlyUpdated, useSWRxRootPage, useSWRxV5MigrationStatus,
+  mutatePageTree,
+  mutateRecentlyUpdated,
+  useSWRxRootPage,
+  useSWRxV5MigrationStatus,
 } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
 import { PageTreeItem, pageTreeItemSize } from '../PageTreeItem';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
-
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
 const logger = loggerFactory('growi:cli:PageTreeSubstance');
 
 type HeaderProps = {
-  isWipPageShown: boolean,
-  onWipPageShownChange?: () => void
-}
-
-export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: HeaderProps) => {
-  const { t } = useTranslation();
-
-  const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
-  useSWRxV5MigrationStatus({ suspense: true });
-  const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
-
-  const mutate = useCallback(() => {
-    mutateRootPage();
-    mutatePageTree();
-    mutateRecentlyUpdated();
-    // Notify headless-tree to rebuild with fresh data
-    notifyUpdateAllTrees();
-  }, [mutateRootPage, notifyUpdateAllTrees]);
+  isWipPageShown: boolean;
+  onWipPageShownChange?: () => void;
+};
 
-  return (
-    <>
-      <SidebarHeaderReloadButton onClick={() => mutate()} />
-
-      <div className="me-1">
-        <button
-          color="transparent"
-          className="btn p-0 border-0"
-          type="button"
-          data-bs-toggle="dropdown"
-          data-bs-auto-close="outside"
-          aria-expanded="false"
-        >
-          <span className="material-symbols-outlined">more_horiz</span>
-        </button>
-
-        <ul className="dropdown-menu">
-          <li className="dropdown-item" onClick={onWipPageShownChange}>
-            <div className="form-check form-switch">
-              <input
-                className="form-check-input pe-none"
-                type="checkbox"
-                checked={isWipPageShown}
-                onChange={() => { }}
-              />
-              <label className="form-check-label pe-none">
-                {t('sidebar_header.show_wip_page')}
-              </label>
-            </div>
-          </li>
-        </ul>
-      </div>
-    </>
-  );
-});
+export const PageTreeHeader = memo(
+  ({ isWipPageShown, onWipPageShownChange }: HeaderProps) => {
+    const { t } = useTranslation();
+
+    const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
+    useSWRxV5MigrationStatus({ suspense: true });
+    const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
+
+    const mutate = useCallback(() => {
+      mutateRootPage();
+      mutatePageTree();
+      mutateRecentlyUpdated();
+      // Notify headless-tree to rebuild with fresh data
+      notifyUpdateAllTrees();
+    }, [mutateRootPage, notifyUpdateAllTrees]);
+
+    return (
+      <>
+        <SidebarHeaderReloadButton onClick={() => mutate()} />
+
+        <div className="me-1">
+          <button
+            color="transparent"
+            className="btn p-0 border-0"
+            type="button"
+            data-bs-toggle="dropdown"
+            data-bs-auto-close="outside"
+            aria-expanded="false"
+          >
+            <span className="material-symbols-outlined">more_horiz</span>
+          </button>
+
+          <ul className="dropdown-menu">
+            <li>
+              <button
+                type="button"
+                className="dropdown-item"
+                onClick={onWipPageShownChange}
+              >
+                <div className="form-check form-switch">
+                  <input
+                    id="page-tree-wip-toggle"
+                    className="form-check-input pe-none"
+                    type="checkbox"
+                    checked={isWipPageShown}
+                    onChange={() => {}}
+                  />
+                  <label
+                    className="form-check-label pe-none"
+                    htmlFor="page-tree-wip-toggle"
+                  >
+                    {t('sidebar_header.show_wip_page')}
+                  </label>
+                </div>
+              </button>
+            </li>
+          </ul>
+        </div>
+      </>
+    );
+  },
+);
 PageTreeHeader.displayName = 'PageTreeHeader';
 
-
 const PageTreeUnavailable = () => {
   const { t } = useTranslation();
 
   return (
     <div className="mt-5 mx-2 text-center">
-      <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
+      <h3 className="text-gray">
+        {t('v5_page_migration.page_tree_not_avaliable')}
+      </h3>
       <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
     </div>
   );
 };
 
 type PageTreeContentProps = {
-  isWipPageShown: boolean,
-}
-
-export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) => {
-
-  const isGuestUser = useIsGuestUser();
-  const isReadOnlyUser = useIsReadOnlyUser();
-  const currentPath = useCurrentPagePath();
-  const targetId = useCurrentPageId();
-
-  const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
-
-  const targetPathOrId = targetId || currentPath;
-  const path = currentPath || '/';
-
-  const sidebarScrollerElem = useSidebarScrollerElem();
-
-  const estimateTreeItemSize = useCallback(() => pageTreeItemSize, []);
-
-  if (!migrationStatus?.isV5Compatible) {
-    return <PageTreeUnavailable />;
-  }
-
-  /*
-   * dependencies
-   */
-  if (isGuestUser == null) {
-    return null;
-  }
+  isWipPageShown: boolean;
+};
 
-  return (
-    <div className="pt-4">
-      <ItemsTree
-        enableRenaming
-        enableDragAndDrop
-        isEnableActions={!isGuestUser}
-        isReadOnlyUser={!!isReadOnlyUser}
-        isWipPageShown={isWipPageShown}
-        targetPath={path}
-        targetPathOrId={targetPathOrId}
-        CustomTreeItem={PageTreeItem}
-        estimateTreeItemSize={estimateTreeItemSize}
-        scrollerElem={sidebarScrollerElem}
-      />
-
-      {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top mt-4 py-2 w-100">
-          <div className="private-legacy-pages-link px-3 py-2">
-            <PrivateLegacyPagesLink />
-          </div>
-        </div>
-      )}
-    </div>
-  );
-});
+export const PageTreeContent = memo(
+  ({ isWipPageShown }: PageTreeContentProps) => {
+    const isGuestUser = useIsGuestUser();
+    const isReadOnlyUser = useIsReadOnlyUser();
+    const currentPath = useCurrentPagePath();
+    const targetId = useCurrentPageId();
+
+    const { data: migrationStatus } = useSWRxV5MigrationStatus({
+      suspense: true,
+    });
+
+    const targetPathOrId = targetId || currentPath;
+    const path = currentPath || '/';
+
+    const sidebarScrollerElem = useSidebarScrollerElem();
+
+    const estimateTreeItemSize = useCallback(() => pageTreeItemSize, []);
+
+    if (!migrationStatus?.isV5Compatible) {
+      return <PageTreeUnavailable />;
+    }
+
+    /*
+     * dependencies
+     */
+    if (isGuestUser == null) {
+      return null;
+    }
+
+    return (
+      <div className="pt-4">
+        <ItemsTree
+          enableRenaming
+          enableDragAndDrop
+          isEnableActions={!isGuestUser}
+          isReadOnlyUser={!!isReadOnlyUser}
+          isWipPageShown={isWipPageShown}
+          targetPath={path}
+          targetPathOrId={targetPathOrId}
+          CustomTreeItem={PageTreeItem}
+          estimateTreeItemSize={estimateTreeItemSize}
+          scrollerElem={sidebarScrollerElem}
+        />
+
+        {!isGuestUser &&
+          !isReadOnlyUser &&
+          migrationStatus?.migratablePagesCount != null &&
+          migrationStatus.migratablePagesCount !== 0 && (
+            <div className="grw-pagetree-footer border-top mt-4 py-2 w-100">
+              <div className="private-legacy-pages-link px-3 py-2">
+                <PrivateLegacyPagesLink />
+              </div>
+            </div>
+          )}
+      </div>
+    );
+  },
+);
 
 PageTreeContent.displayName = 'PageTreeContent';

+ 3 - 4
apps/app/src/client/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -1,9 +1,7 @@
 import type { FC } from 'react';
 import React, { memo } from 'react';
-
-import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
-
+import { useTranslation } from 'next-i18next';
 
 export const PrivateLegacyPagesLink: FC = memo(() => {
   const { t } = useTranslation();
@@ -14,7 +12,8 @@ export const PrivateLegacyPagesLink: FC = memo(() => {
       className="h5 grw-private-legacy-pages-anchor text-decoration-none"
       prefetch={false}
     >
-      <span className="material-symbols-outlined me-2">bottom_drawer</span> {t('private_legacy_pages.title')}
+      <span className="material-symbols-outlined me-2">bottom_drawer</span>{' '}
+      {t('private_legacy_pages.title')}
     </Link>
   );
 });

+ 4 - 9
apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx

@@ -4,8 +4,9 @@ import CountBadge from '~/client/components/Common/CountBadge';
 import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 import { usePageTreeDescCountMap } from '~/features/page-tree/states';
 
-
-export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element => {
+export const CountBadgeForPageTreeItem = (
+  props: TreeItemToolProps,
+): JSX.Element => {
   const { getDescCount } = usePageTreeDescCountMap();
 
   const { item } = props;
@@ -13,11 +14,5 @@ export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element
 
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 
-  return (
-    <>
-      {descendantCount > 0 && (
-        <CountBadge count={descendantCount} />
-      )}
-    </>
-  );
+  return <>{descendantCount > 0 && <CountBadge count={descendantCount} />}</>;
 };

+ 5 - 3
apps/app/src/client/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx

@@ -1,9 +1,11 @@
 import type { JSX } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
-
-export const CreatingNewPageSpinner = ({ show }: { show?: boolean }): JSX.Element => {
+export const CreatingNewPageSpinner = ({
+  show,
+}: {
+  show?: boolean;
+}): JSX.Element => {
   if (!show) {
     return <></>;
   }

+ 92 - 62
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -1,21 +1,21 @@
 import type { FC } from 'react';
 import { useCallback } from 'react';
-
-import path from 'path';
-
+import { useRouter } from 'next/router';
 import type { IPageToDeleteWithMeta } from '@growi/core/dist/interfaces';
 import { getIdStringForRef } from '@growi/core/dist/interfaces';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
+import path from 'path';
 
 import { toastSuccess } from '~/client/util/toastr';
 import type { TreeItemProps } from '~/features/page-tree';
 import {
-  usePageTreeInformationUpdate, usePageRename, usePageCreate,
+  usePageCreate,
+  usePageRename,
+  usePageTreeInformationUpdate,
   usePlaceholderRenameEffect,
 } from '~/features/page-tree';
-import { TreeNameInput, TreeItemLayout } from '~/features/page-tree/components';
+import { TreeItemLayout, TreeNameInput } from '~/features/page-tree/components';
 import type { IPageForItem } from '~/interfaces/page';
 import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
 import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
@@ -23,10 +23,9 @@ import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
 import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
 import { mutateAllPageInfo } from '~/stores/page';
-import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+import { mutatePageList, mutatePageTree } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 
-
 import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
 import { usePageItemControl } from './use-page-item-control';
 
@@ -34,10 +33,8 @@ import styles from './PageTreeItem.module.scss';
 
 const moduleClass = styles['page-tree-item'] ?? '';
 
-
 export const pageTreeItemSize = 40; // in px
 
-
 export const PageTreeItem: FC<TreeItemProps> = ({
   item,
   targetPath,
@@ -59,52 +56,81 @@ export const PageTreeItem: FC<TreeItemProps> = ({
   const { open: openDeleteModal } = usePageDeleteModalActions();
   const { notifyUpdateItems } = usePageTreeInformationUpdate();
 
-  const onClickDuplicateMenuItem = useCallback((page: IPageForPageDuplicateModal) => {
-    const duplicatedHandler: OnDuplicatedFunction = (fromPath) => {
-      toastSuccess(t('duplicated_pages', { fromPath }));
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-
-      // Notify headless-tree update
-      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
-      notifyUpdateItems(parentIds);
-    };
-
-    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal, t, notifyUpdateItems, itemData.parent]);
-
-  const onClickDeleteMenuItem = useCallback((page: IPageToDeleteWithMeta) => {
-    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
-
-      if (isCompletely) {
-        toastSuccess(t('deleted_pages_completely', { path: pathOrPathsToDelete }));
-      }
-      else {
-        toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
-      }
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-      mutateAllPageInfo();
-
-      if (currentPagePath === pathOrPathsToDelete) {
-        fetchCurrentPage({ force: true });
-        router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
-      }
-
-      // Notify headless-tree update
-      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
-      notifyUpdateItems(parentIds);
-    };
-
-    openDeleteModal([page], { onDeleted: onDeletedHandler });
-  }, [openDeleteModal, t, currentPagePath, fetchCurrentPage, router, itemData.parent, notifyUpdateItems]);
+  const onClickDuplicateMenuItem = useCallback(
+    (page: IPageForPageDuplicateModal) => {
+      const duplicatedHandler: OnDuplicatedFunction = (fromPath) => {
+        toastSuccess(t('duplicated_pages', { fromPath }));
+
+        mutatePageTree();
+        mutateSearching();
+        mutatePageList();
+
+        // Notify headless-tree update
+        const parentIds =
+          itemData.parent != null
+            ? [getIdStringForRef(itemData.parent)]
+            : undefined;
+        notifyUpdateItems(parentIds);
+      };
+
+      openDuplicateModal(page, { onDuplicated: duplicatedHandler });
+    },
+    [openDuplicateModal, t, notifyUpdateItems, itemData.parent],
+  );
+
+  const onClickDeleteMenuItem = useCallback(
+    (page: IPageToDeleteWithMeta) => {
+      const onDeletedHandler: OnDeletedFunction = (
+        pathOrPathsToDelete,
+        isRecursively,
+        isCompletely,
+      ) => {
+        if (typeof pathOrPathsToDelete !== 'string') {
+          return;
+        }
+
+        if (isCompletely) {
+          toastSuccess(
+            t('deleted_pages_completely', { path: pathOrPathsToDelete }),
+          );
+        } else {
+          toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
+        }
+
+        mutatePageTree();
+        mutateSearching();
+        mutatePageList();
+        mutateAllPageInfo();
+
+        if (currentPagePath === pathOrPathsToDelete) {
+          fetchCurrentPage({ force: true });
+          router.push(
+            isCompletely
+              ? path.dirname(pathOrPathsToDelete)
+              : `/trash${pathOrPathsToDelete}`,
+          );
+        }
+
+        // Notify headless-tree update
+        const parentIds =
+          itemData.parent != null
+            ? [getIdStringForRef(itemData.parent)]
+            : undefined;
+        notifyUpdateItems(parentIds);
+      };
+
+      openDeleteModal([page], { onDeleted: onDeletedHandler });
+    },
+    [
+      openDeleteModal,
+      t,
+      currentPagePath,
+      fetchCurrentPage,
+      router,
+      itemData.parent,
+      notifyUpdateItems,
+    ],
+  );
 
   const { Control } = usePageItemControl();
 
@@ -112,7 +138,8 @@ export const PageTreeItem: FC<TreeItemProps> = ({
   const { isRenaming } = usePageRename();
 
   // Page create feature
-  const { cancelCreating, CreateButton, isCreatingPlaceholder } = usePageCreate();
+  const { cancelCreating, CreateButton, isCreatingPlaceholder } =
+    usePageCreate();
 
   // Manage placeholder renaming mode (auto-start, track, and cancel on Esc)
   usePlaceholderRenameEffect({
@@ -120,12 +147,15 @@ export const PageTreeItem: FC<TreeItemProps> = ({
     onCancelCreate: cancelCreating,
   });
 
-  const itemSelectedHandler = useCallback((page: IPageForItem) => {
-    if (page.path == null || page._id == null) return;
+  const itemSelectedHandler = useCallback(
+    (page: IPageForItem) => {
+      if (page.path == null || page._id == null) return;
 
-    const link = pathUtils.returnPathForURL(page.path, page._id);
-    router.push(link);
-  }, [router]);
+      const link = pathUtils.returnPathForURL(page.path, page._id);
+      router.push(link);
+    },
+    [router],
+  );
 
   const itemSelectedByWheelClickHandler = useCallback((page: IPageForItem) => {
     if (page.path == null || page._id == null) return;

+ 61 - 41
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -1,13 +1,16 @@
 import type { FC } from 'react';
 import React, { useCallback } from 'react';
-
 import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { DropdownToggle } from 'reactstrap';
 
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
-import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
+import {
+  bookmark,
+  resumeRenameOperation,
+  unbookmark,
+} from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -15,33 +18,36 @@ import { useSWRMUTxPageInfo } from '~/stores/page';
 
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 
-
 type UsePageItemControl = {
-  Control: FC<TreeItemToolProps>,
-}
+  Control: FC<TreeItemToolProps>;
+};
 
 export const usePageItemControl = (): UsePageItemControl => {
   const { t } = useTranslation();
 
-
   const Control: FC<TreeItemToolProps> = (props) => {
     const {
       item,
       isEnableActions,
       isReadOnlyUser,
-      onClickDuplicateMenuItem, onClickDeleteMenuItem,
+      onClickDuplicateMenuItem,
+      onClickDeleteMenuItem,
     } = props;
     const page = item.getItemData();
 
-    const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+    const { trigger: mutateCurrentUserBookmarks } =
+      useSWRMUTxCurrentUserBookmarks();
     const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
 
-    const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean): Promise<void> => {
-      const bookmarkOperation = _newValue ? bookmark : unbookmark;
-      await bookmarkOperation(_pageId);
-      mutateCurrentUserBookmarks();
-      mutatePageInfo();
-    }, [mutateCurrentUserBookmarks, mutatePageInfo]);
+    const bookmarkMenuItemClickHandler = useCallback(
+      async (_pageId: string, _newValue: boolean): Promise<void> => {
+        const bookmarkOperation = _newValue ? bookmark : unbookmark;
+        await bookmarkOperation(_pageId);
+        mutateCurrentUserBookmarks();
+        mutatePageInfo();
+      },
+      [mutateCurrentUserBookmarks, mutatePageInfo],
+    );
 
     const duplicateMenuItemClickHandler = useCallback((): void => {
       if (onClickDuplicateMenuItem == null) {
@@ -64,33 +70,41 @@ export const usePageItemControl = (): UsePageItemControl => {
       item.startRenaming();
     }, [item]);
 
-    const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-      if (onClickDeleteMenuItem == null) {
-        return;
-      }
-
-      if (page._id == null || page.path == null) {
-        throw Error('_id and path must not be null.');
-      }
-
-      const pageToDelete: IPageToDeleteWithMeta = {
-        data: {
-          _id: page._id,
-          revision: page.revision != null ? getIdStringForRef(page.revision) : null,
-          path: page.path,
-        },
-        meta: pageInfo,
-      };
-
-      onClickDeleteMenuItem(pageToDelete);
-    }, [onClickDeleteMenuItem, page]);
+    const deleteMenuItemClickHandler = useCallback(
+      async (
+        _pageId: string,
+        pageInfo: IPageInfoExt | undefined,
+      ): Promise<void> => {
+        if (onClickDeleteMenuItem == null) {
+          return;
+        }
+
+        if (page._id == null || page.path == null) {
+          throw Error('_id and path must not be null.');
+        }
+
+        const pageToDelete: IPageToDeleteWithMeta = {
+          data: {
+            _id: page._id,
+            revision:
+              page.revision != null ? getIdStringForRef(page.revision) : null,
+            path: page.path,
+          },
+          meta: pageInfo,
+        };
+
+        onClickDeleteMenuItem(pageToDelete);
+      },
+      [onClickDeleteMenuItem, page],
+    );
 
-    const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
+    const pathRecoveryMenuItemClickHandler = async (
+      pageId: string,
+    ): Promise<void> => {
       try {
         await resumeRenameOperation(pageId);
         toastSuccess(t('page_operation.paths_recovered'));
-      }
-      catch {
+      } catch {
         toastError(t('page_operation.path_recovery_failed'));
       }
     };
@@ -112,8 +126,16 @@ export const usePageItemControl = (): UsePageItemControl => {
             operationProcessData={page.processData}
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 mr-1">
-              <span id="option-button-in-page-tree" className="material-symbols-outlined p-1">more_vert</span>
+            <DropdownToggle
+              color="transparent"
+              className="border-0 rounded btn-page-item-control p-0 mr-1"
+            >
+              <span
+                id="option-button-in-page-tree"
+                className="material-symbols-outlined p-1"
+              >
+                more_vert
+              </span>
             </DropdownToggle>
           </PageItemControl>
         </div>
@@ -121,9 +143,7 @@ export const usePageItemControl = (): UsePageItemControl => {
     );
   };
 
-
   return {
     Control,
   };
-
 };

+ 15 - 7
apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx

@@ -1,17 +1,20 @@
-import { Suspense, useState, type JSX } from 'react';
-
+import { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
 import RecentChangesContentSkeleton from './RecentChangesContentSkeleton';
 
-const RecentChangesHeader = dynamic(() => import('./RecentChangesSubstance').then(mod => mod.RecentChangesHeader), { ssr: false });
+const RecentChangesHeader = dynamic(
+  () =>
+    import('./RecentChangesSubstance').then((mod) => mod.RecentChangesHeader),
+  { ssr: false },
+);
 const RecentChangesContent = dynamic(
-  () => import('./RecentChangesSubstance').then(mod => mod.RecentChangesContent),
+  () =>
+    import('./RecentChangesSubstance').then((mod) => mod.RecentChangesContent),
   { ssr: false, loading: RecentChangesContentSkeleton },
 );
 
-
 export const RecentChanges = (): JSX.Element => {
   const { t } = useTranslation();
 
@@ -27,13 +30,18 @@ export const RecentChanges = (): JSX.Element => {
             isSmall={isSmall}
             onSizeChange={setIsSmall}
             isWipPageShown={isWipPageShown}
-            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+            onWipPageShownChange={() => {
+              setIsWipPageShown(!isWipPageShown);
+            }}
           />
         </Suspense>
       </div>
 
       <Suspense fallback={<RecentChangesContentSkeleton />}>
-        <RecentChangesContent isWipPageShown={isWipPageShown} isSmall={isSmall} />
+        <RecentChangesContent
+          isWipPageShown={isWipPageShown}
+          isSmall={isSmall}
+        />
       </Suspense>
     </div>
   );

+ 12 - 6
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesContentSkeleton.tsx

@@ -5,18 +5,25 @@ import { Skeleton } from '~/client/components/Skeleton';
 import styles from './RecentChangesSubstance.module.scss';
 
 const SkeletonItem = () => {
-
   const isSmall = window.localStorage.isRecentChangesSidebarSmall === 'true';
 
   return (
-    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+    <li
+      className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}
+    >
       <div className="d-flex w-100">
         <Skeleton additionalClass="rounded-circle picture" roundedPill />
         <div className="flex-grow-1 ms-2">
-          <Skeleton additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`} />
-          <Skeleton additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`} />
+          <Skeleton
+            additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`}
+          />
+          <Skeleton
+            additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`}
+          />
           <div className="d-flex justify-content-end grw-recent-changes-item-lower pt-1">
-            <Skeleton additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`} />
+            <Skeleton
+              additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`}
+            />
           </div>
         </div>
       </div>
@@ -25,7 +32,6 @@ const SkeletonItem = () => {
 };
 
 const RecentChangesContentSkeleton = (): JSX.Element => {
-
   return (
     <div className="grw-recent-changes py-3">
       <ul className="list-group list-group-flush">

+ 169 - 110
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -1,10 +1,5 @@
-import React, {
-  memo, useCallback, useEffect, type JSX,
-} from 'react';
-
-import {
-  isPopulated, type IPageHasId,
-} from '@growi/core';
+import React, { type JSX, memo, useCallback, useEffect } from 'react';
+import { type IPageHasId, isPopulated } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'react-i18next';
@@ -28,17 +23,19 @@ const pageItemLowerClass = styles['grw-recent-changes-item-lower'];
 const logger = loggerFactory('growi:History');
 
 type PageItemLowerProps = {
-  page: IPageHasId,
-}
+  page: IPageHasId;
+};
 
 type PageItemProps = PageItemLowerProps & {
-  isSmall: boolean,
-  onClickTag?: (tagName: string) => void,
-}
+  isSmall: boolean;
+  onClickTag?: (tagName: string) => void;
+};
 
 const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
-    <div className={`${pageItemLowerClass} d-flex justify-content-between grw-recent-changes-item-lower`}>
+    <div
+      className={`${pageItemLowerClass} d-flex justify-content-between grw-recent-changes-item-lower`}
+    >
       <div className="d-flex align-items-center">
         <div className="">
           <span className="material-symbols-outlined p-0">footprint</span>
@@ -49,7 +46,10 @@ const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
           <span className="grw-list-counts ms-1">{page.commentCount}</span>
         </div>
       </div>
-      <div className="grw-formatted-distance-date mt-auto" data-vrt-blackout-datetime>
+      <div
+        className="grw-formatted-distance-date mt-auto"
+        data-vrt-blackout-datetime
+      >
         <FormattedDistanceDate id={page._id} date={page.updatedAt} />
       </div>
     </div>
@@ -61,104 +61,124 @@ type PageTagsProps = PageItemProps;
 const PageTags = memo((props: PageTagsProps): JSX.Element => {
   const { page, isSmall, onClickTag } = props;
 
-  if (isSmall || (page.tags.length === 0)) {
+  if (isSmall || page.tags.length === 0) {
     return <></>;
   }
 
   return (
     <>
-      { page.tags.map((tag) => {
+      {page.tags.map((tag) => {
         if (!isPopulated(tag)) {
           return <></>;
         }
         return (
-          <a
+          <button
             key={tag.name}
             type="button"
             className="grw-tag badge me-2"
             onClick={() => onClickTag?.(tag.name)}
           >
             {tag.name}
-          </a>
+          </button>
         );
-      }) }
+      })}
     </>
   );
 });
 PageTags.displayName = 'PageTags';
 
-const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Element => {
-  const dPagePath = new DevidedPagePath(page.path, false, true);
-  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-  const FormerLink = () => (
-    <div className={`${formerLinkClass} ${isSmall ? 'text-truncate small' : ''}`}>
-      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-    </div>
-  );
-
-  let locked;
-  if (page.grant !== 1) {
-    locked = <span className="material-symbols-outlined ms-2 fs-6">lock</span>;
-  }
-
-  const isTagElementsRendered = !(isSmall || (page.tags.length === 0));
-
-  return (
-    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
-      <div className="d-flex w-100">
-
-        <div>
-          <UserPicture user={page.lastUpdateUser} size="md" className="d-inline-block" />
-        </div>
+const PageItem = memo(
+  ({ page, isSmall, onClickTag }: PageItemProps): JSX.Element => {
+    const dPagePath = new DevidedPagePath(page.path, false, true);
+    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+    const formerLink = (
+      <div
+        className={`${formerLinkClass} ${isSmall ? 'text-truncate small' : ''}`}
+      >
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+      </div>
+    );
 
-        <div className="flex-grow-1 ms-2">
-          <div className={`row ${isSmall ? 'gy-0' : 'gy-1'}`}>
+    let locked: JSX.Element | null = null;
+    if (page.grant !== 1) {
+      locked = (
+        <span className="material-symbols-outlined ms-2 fs-6">lock</span>
+      );
+    }
 
-            <div className="col-12">
-              { !dPagePath.isRoot && <FormerLink /> }
-            </div>
+    const isTagElementsRendered = !(isSmall || page.tags.length === 0);
+
+    return (
+      <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
+        <div className="d-flex w-100">
+          <div>
+            <UserPicture
+              user={page.lastUpdateUser}
+              size="md"
+              className="d-inline-block"
+            />
+          </div>
 
-            <h6 className={`col-12 d-flex align-items-center ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
-              <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-              { page.wip && (
-                <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">WIP</span>
-              ) }
-              {locked}
-            </h6>
+          <div className="flex-grow-1 ms-2">
+            <div className={`row ${isSmall ? 'gy-0' : 'gy-1'}`}>
+              <div className="col-12">{!dPagePath.isRoot && formerLink}</div>
+
+              <h6
+                className={`col-12 d-flex align-items-center ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}
+              >
+                <PagePathHierarchicalLink
+                  linkedPagePath={linkedPagePathLatter}
+                  basePath={dPagePath.isRoot ? undefined : dPagePath.former}
+                />
+                {page.wip && (
+                  <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">
+                    WIP
+                  </span>
+                )}
+                {locked}
+              </h6>
+
+              {isTagElementsRendered && (
+                <div className="col-12">
+                  <PageTags
+                    isSmall={isSmall}
+                    page={page}
+                    onClickTag={onClickTag}
+                  />
+                </div>
+              )}
 
-            { isTagElementsRendered && (
               <div className="col-12">
-                <PageTags isSmall={isSmall} page={page} onClickTag={onClickTag} />
+                <PageItemLower page={page} />
               </div>
-            ) }
-
-            <div className="col-12">
-              <PageItemLower page={page} />
             </div>
-
           </div>
         </div>
-      </div>
-    </li>
-  );
-});
+      </li>
+    );
+  },
+);
 PageItem.displayName = 'PageItem';
 
-
 type HeaderProps = {
-  isSmall: boolean,
-  onSizeChange: (isSmall: boolean) => void,
-  isWipPageShown: boolean,
-  onWipPageShownChange: () => void,
-}
+  isSmall: boolean;
+  onSizeChange: (isSmall: boolean) => void;
+  isWipPageShown: boolean;
+  onWipPageShownChange: () => void;
+};
 
 export const RecentChangesHeader = ({
-  isSmall, onSizeChange, isWipPageShown, onWipPageShownChange,
+  isSmall,
+  onSizeChange,
+  isWipPageShown,
+  onWipPageShownChange,
 }: HeaderProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { mutate } = useSWRINFxRecentlyUpdated(isWipPageShown, { suspense: true });
+  const { mutate } = useSWRINFxRecentlyUpdated(isWipPageShown, {
+    suspense: true,
+  });
 
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
@@ -193,33 +213,55 @@ export const RecentChangesHeader = ({
         </button>
 
         <ul className="dropdown-menu">
-          <li className="dropdown-item" onClick={changeSizeHandler}>
-            <div className={`${styles['grw-recent-changes-resize-button']} form-check form-switch mb-0`}>
-              <input
-                id="recentChangesResize"
-                className="form-check-input pe-none"
-                type="checkbox"
-                checked={isSmall}
-                onChange={() => {}}
-              />
-              <label className="form-check-label pe-none" aria-disabled="true">
-                {t('sidebar_header.compact_view')}
-              </label>
-            </div>
+          <li>
+            <button
+              type="button"
+              className="dropdown-item"
+              onClick={changeSizeHandler}
+            >
+              <div
+                className={`${styles['grw-recent-changes-resize-button']} form-check form-switch mb-0`}
+              >
+                <input
+                  id="recent-changes-resize-toggle"
+                  className="form-check-input pe-none"
+                  type="checkbox"
+                  checked={isSmall}
+                  onChange={() => {}}
+                />
+                <label
+                  className="form-check-label pe-none"
+                  htmlFor="recent-changes-resize-toggle"
+                  aria-disabled="true"
+                >
+                  {t('sidebar_header.compact_view')}
+                </label>
+              </div>
+            </button>
           </li>
 
-          <li className="dropdown-item" onClick={onWipPageShownChange}>
-            <div className="form-check form-switch mb-0">
-              <input
-                id="wipPageVisibility"
-                className="form-check-input"
-                type="checkbox"
-                checked={isWipPageShown}
-              />
-              <label className="form-check-label pe-none">
-                {t('sidebar_header.show_wip_page')}
-              </label>
-            </div>
+          <li>
+            <button
+              type="button"
+              className="dropdown-item"
+              onClick={onWipPageShownChange}
+            >
+              <div className="form-check form-switch mb-0">
+                <input
+                  id="recent-changes-wip-toggle"
+                  className="form-check-input"
+                  type="checkbox"
+                  checked={isWipPageShown}
+                  onChange={() => {}}
+                />
+                <label
+                  className="form-check-label pe-none"
+                  htmlFor="recent-changes-wip-toggle"
+                >
+                  {t('sidebar_header.show_wip_page')}
+                </label>
+              </div>
+            </button>
           </li>
         </ul>
       </div>
@@ -228,18 +270,29 @@ export const RecentChangesHeader = ({
 };
 
 type ContentProps = {
-  isSmall: boolean,
-  isWipPageShown: boolean,
-}
+  isSmall: boolean;
+  isWipPageShown: boolean;
+};
 
-export const RecentChangesContent = ({ isSmall, isWipPageShown }: ContentProps): JSX.Element => {
-  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(isWipPageShown, { suspense: true });
+export const RecentChangesContent = ({
+  isSmall,
+  isWipPageShown,
+}: ContentProps): JSX.Element => {
+  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(
+    isWipPageShown,
+    { suspense: true },
+  );
   const { data } = swrInifinitexRecentlyUpdated;
 
   const setSearchKeyword = useSetSearchKeyword();
   const isEmpty = data?.[0]?.pages.length === 0;
   const lastPageIndex = data?.length ? data.length - 1 : 0;
-  const isReachingEnd = isEmpty || (data != null && lastPageIndex > 0 && data[lastPageIndex]?.pages.length < data[lastPageIndex - 1]?.pages.length);
+  const isReachingEnd =
+    isEmpty ||
+    (data != null &&
+      lastPageIndex > 0 &&
+      data[lastPageIndex]?.pages.length <
+        data[lastPageIndex - 1]?.pages.length);
   return (
     <div className="grw-recent-changes">
       <ul className="list-group list-group-flush">
@@ -247,11 +300,17 @@ export const RecentChangesContent = ({ isSmall, isWipPageShown }: ContentProps):
           swrInifiniteResponse={swrInifinitexRecentlyUpdated}
           isReachingEnd={isReachingEnd}
         >
-          { data != null && data.map(apiResult => apiResult.pages).flat()
-            .map(page => (
-              <PageItem key={page._id} page={page} isSmall={isSmall} onClickTag={tagName => setSearchKeyword(`tag:${tagName}`)} />
-            ))
-          }
+          {data != null &&
+            data
+              .flatMap((apiResult) => apiResult.pages)
+              .map((page) => (
+                <PageItem
+                  key={page._id}
+                  page={page}
+                  isSmall={isSmall}
+                  onClickTag={(tagName) => setSearchKeyword(`tag:${tagName}`)}
+                />
+              ))}
         </InfiniteScroll>
       </ul>
     </div>

+ 4 - 0
apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.module.scss

@@ -18,7 +18,11 @@
     left: -4px;
     width: 24px;
     height: 100%;
+    padding: 0;
+    appearance: none;
     cursor: ew-resize;
+    background: transparent;
+    border: 0;
   }
   .grw-navigation-draggable-line {
     position: absolute;

+ 63 - 54
apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.tsx

@@ -1,70 +1,77 @@
-import {
-  memo, useCallback, useRef, type JSX,
-} from 'react';
+import { type JSX, memo, useCallback, useRef } from 'react';
 
 import type { ResizableAreaProps } from './props';
 
 import styles from './ResizableArea.module.scss';
 
-
 export const ResizableArea = memo((props: ResizableAreaProps): JSX.Element => {
   const {
     className,
-    width, minWidth = 0,
-    disabled, children,
-    onResize, onResizeDone, onCollapsed,
+    width,
+    minWidth = 0,
+    disabled,
+    children,
+    onResize,
+    onResizeDone,
+    onCollapsed,
   } = props;
 
   const resizableContainer = useRef<HTMLDivElement>(null);
 
-  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
-    event.preventDefault();
-
-    const widthByMousePos = event.pageX;
-
-    const newWidth = Math.max(widthByMousePos, minWidth);
-    onResize?.(newWidth);
-    resizableContainer.current?.classList.add('dragging');
-  }, [minWidth, onResize]);
-
-  const dragableAreaMouseUpHandler = useCallback((event: MouseEvent) => {
-    if (resizableContainer.current == null) {
-      return;
-    }
-
-    const widthByMousePos = event.pageX;
-
-    if (widthByMousePos < minWidth / 2) {
-      // force collapsed
-      onCollapsed?.();
-    }
-    else {
-      const newWidth = resizableContainer.current.clientWidth;
-      onResizeDone?.(newWidth);
-    }
+  const draggableAreaMoveHandler = useCallback(
+    (event: MouseEvent) => {
+      event.preventDefault();
 
-    resizableContainer.current.classList.remove('dragging');
+      const widthByMousePos = event.pageX;
 
-  }, [minWidth, onCollapsed, onResizeDone]);
-
-  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
-    if (disabled) {
-      return;
-    }
-
-    event.preventDefault();
-
-    const removeEventListeners = () => {
-      document.removeEventListener('mousemove', draggableAreaMoveHandler);
-      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
-      document.removeEventListener('mouseup', removeEventListeners);
-    };
+      const newWidth = Math.max(widthByMousePos, minWidth);
+      onResize?.(newWidth);
+      resizableContainer.current?.classList.add('dragging');
+    },
+    [minWidth, onResize],
+  );
 
-    document.addEventListener('mousemove', draggableAreaMoveHandler);
-    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
-    document.addEventListener('mouseup', removeEventListeners);
+  const dragableAreaMouseUpHandler = useCallback(
+    (event: MouseEvent) => {
+      if (resizableContainer.current == null) {
+        return;
+      }
+
+      const widthByMousePos = event.pageX;
+
+      if (widthByMousePos < minWidth / 2) {
+        // force collapsed
+        onCollapsed?.();
+      } else {
+        const newWidth = resizableContainer.current.clientWidth;
+        onResizeDone?.(newWidth);
+      }
+
+      resizableContainer.current.classList.remove('dragging');
+    },
+    [minWidth, onCollapsed, onResizeDone],
+  );
 
-  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, disabled]);
+  const dragableAreaMouseDownHandler = useCallback(
+    (event: React.MouseEvent) => {
+      if (disabled) {
+        return;
+      }
+
+      event.preventDefault();
+
+      const removeEventListeners = () => {
+        document.removeEventListener('mousemove', draggableAreaMoveHandler);
+        document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
+        document.removeEventListener('mouseup', removeEventListeners);
+      };
+
+      document.addEventListener('mousemove', draggableAreaMoveHandler);
+      document.addEventListener('mouseup', dragableAreaMouseUpHandler);
+      document.addEventListener('mouseup', removeEventListeners);
+    },
+    [dragableAreaMouseUpHandler, draggableAreaMoveHandler, disabled],
+  );
 
   return (
     <>
@@ -76,15 +83,17 @@ export const ResizableArea = memo((props: ResizableAreaProps): JSX.Element => {
         {children}
       </div>
       <div className={styles['grw-navigation-draggable']}>
-        { !disabled && (
+        {!disabled && (
           <>
-            <div
+            <button
+              type="button"
               className="grw-navigation-draggable-hitarea"
+              aria-label="Resize sidebar"
               onMouseDown={dragableAreaMouseDownHandler}
             />
             <div className="grw-navigation-draggable-line"></div>
           </>
-        ) }
+        )}
       </div>
     </>
   );

+ 7 - 15
apps/app/src/client/components/Sidebar/ResizableArea/ResizableAreaFallback.tsx

@@ -1,24 +1,16 @@
-import { memo, type JSX } from 'react';
-
+import { type JSX, memo } from 'react';
 
 type Props = {
-  className?: string,
-  width?: number,
-  children?: React.ReactNode,
-}
+  className?: string;
+  width?: number;
+  children?: React.ReactNode;
+};
 
 export const ResizableAreaFallback = memo((props: Props): JSX.Element => {
-  const {
-    className = '',
-    width,
-    children,
-  } = props;
+  const { className = '', width, children } = props;
 
   return (
-    <div
-      className={className}
-      style={{ width }}
-    >
+    <div className={className} style={{ width }}>
       {children}
     </div>
   );

+ 9 - 9
apps/app/src/client/components/Sidebar/ResizableArea/props.d.ts

@@ -1,10 +1,10 @@
 export type ResizableAreaProps = {
-  className?: string,
-  width?: number,
-  minWidth?: number,
-  disabled?: boolean,
-  children?: React.ReactNode,
-  onResize?: (newWidth: number) => void,
-  onResizeDone?: (newWidth: number) => void,
-  onCollapsed?: () => void,
-}
+  className?: string;
+  width?: number;
+  minWidth?: number;
+  disabled?: boolean;
+  children?: React.ReactNode;
+  onResize?: (newWidth: number) => void;
+  onResizeDone?: (newWidth: number) => void;
+  onCollapsed?: () => void;
+};

+ 196 - 147
apps/app/src/client/components/Sidebar/Sidebar.tsx

@@ -1,55 +1,73 @@
 import {
-  type FC, memo, useCallback, useEffect, useState, useRef, type JSX,
+  type FC,
+  type JSX,
+  memo,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
 } from 'react';
-
-import withLoadingProps from 'next-dynamic-loading-props';
 import dynamic from 'next/dynamic';
+import withLoadingProps from 'next-dynamic-loading-props';
 import SimpleBar from 'simplebar-react';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 import { SidebarMode } from '~/interfaces/ui';
 import { useIsSearchPage } from '~/states/context';
-import { useDeviceLargerThanXl, useDeviceLargerThanMd } from '~/states/ui/device';
+import {
+  useDeviceLargerThanMd,
+  useDeviceLargerThanXl,
+} from '~/states/ui/device';
 import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import {
-  useDrawerOpened,
-  useSetPreferCollapsedMode,
-  useSidebarMode,
   useCollapsedContentsOpened,
   useCurrentProductNavWidth,
+  useDrawerOpened,
+  useSetPreferCollapsedMode,
   useSetSidebarScrollerRef,
+  useSidebarMode,
 } from '~/states/ui/sidebar';
 
 import { DrawerToggler } from '../Common/DrawerToggler';
-
-import { AppTitleOnSidebarHead, AppTitleOnEditorSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
-import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
+import {
+  AppTitleOnEditorSidebarHead,
+  AppTitleOnSidebarHead,
+  AppTitleOnSubnavigation,
+} from './AppTitle/AppTitle';
 import type { ResizableAreaProps } from './ResizableArea/props';
+import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
 import { SidebarHead } from './SidebarHead';
 import { SidebarNav, type SidebarNavProps } from './SidebarNav';
 
 import 'simplebar-react/dist/simplebar.min.css';
-import styles from './Sidebar.module.scss';
 
+import styles from './Sidebar.module.scss';
 
-const SidebarContents = dynamic(() => import('./SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
-const ResizableArea = withLoadingProps<ResizableAreaProps>(useLoadingProps => dynamic(
-  () => import('./ResizableArea').then(mod => mod.ResizableArea),
-  {
+const SidebarContents = dynamic(
+  () => import('./SidebarContents').then((mod) => mod.SidebarContents),
+  { ssr: false },
+);
+const ResizableArea = withLoadingProps<ResizableAreaProps>((useLoadingProps) =>
+  dynamic(() => import('./ResizableArea').then((mod) => mod.ResizableArea), {
     ssr: false,
     loading: () => {
       // eslint-disable-next-line react-hooks/rules-of-hooks
       const { children, ...rest } = useLoadingProps();
-      return <ResizableAreaFallback {...rest}>{children}</ResizableAreaFallback>;
+      return (
+        <ResizableAreaFallback {...rest}>{children}</ResizableAreaFallback>
+      );
     },
-  },
-));
-
+  }),
+);
 
 const resizableAreaMinWidth = 348;
 const sidebarNavCollapsedWidth = 48;
 
-const getWidthByMode = (isDrawerMode: boolean, isCollapsedMode: boolean, currentProductNavWidth: number | undefined): number | undefined => {
+const getWidthByMode = (
+  isDrawerMode: boolean,
+  isCollapsedMode: boolean,
+  currentProductNavWidth: number | undefined,
+): number | undefined => {
   if (isDrawerMode) {
     return undefined;
   }
@@ -59,59 +77,73 @@ const getWidthByMode = (isDrawerMode: boolean, isCollapsedMode: boolean, current
   return currentProductNavWidth;
 };
 
-
 type ResizableContainerProps = {
-  children?: React.ReactNode,
-}
-
-const ResizableContainer = memo((props: ResizableContainerProps): JSX.Element => {
-
-  const { children } = props;
-
-  const { isDrawerMode, isCollapsedMode, isDockMode } = useSidebarMode();
-  const [, setIsDrawerOpened] = useDrawerOpened();
-  const [currentProductNavWidth, setCurrentProductNavWidth] = useCurrentProductNavWidth();
-  const setPreferCollapsedMode = useSetPreferCollapsedMode();
-  const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
-
-  const [isClient, setClient] = useState(false);
-  const [resizableAreaWidth, setResizableAreaWidth] = useState<number | undefined>(
-    getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth),
-  );
-
-  const resizeHandler = useCallback((newWidth: number) => {
-    setResizableAreaWidth(newWidth);
-  }, []);
-
-  const resizeDoneHandler = useCallback((newWidth: number) => {
-    setCurrentProductNavWidth(newWidth);
-  }, [setCurrentProductNavWidth]);
+  children?: React.ReactNode;
+};
 
-  const collapsedByResizableAreaHandler = useCallback(() => {
-    setPreferCollapsedMode(true);
-    setCollapsedContentsOpened(false);
-  }, [setCollapsedContentsOpened, setPreferCollapsedMode]);
+const ResizableContainer = memo(
+  (props: ResizableContainerProps): JSX.Element => {
+    const { children } = props;
+
+    const { isDrawerMode, isCollapsedMode, isDockMode } = useSidebarMode();
+    const [, setIsDrawerOpened] = useDrawerOpened();
+    const [currentProductNavWidth, setCurrentProductNavWidth] =
+      useCurrentProductNavWidth();
+    const setPreferCollapsedMode = useSetPreferCollapsedMode();
+    const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
+
+    const [isClient, setClient] = useState(false);
+    const [resizableAreaWidth, setResizableAreaWidth] = useState<
+      number | undefined
+    >(
+      getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth),
+    );
 
-  useIsomorphicLayoutEffect(() => {
-    setClient(true);
-  }, []);
+    const resizeHandler = useCallback((newWidth: number) => {
+      setResizableAreaWidth(newWidth);
+    }, []);
 
-  // open/close resizable container when drawer mode
-  useEffect(() => {
-    setResizableAreaWidth(getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth));
-    setIsDrawerOpened(false);
-  }, [currentProductNavWidth, isCollapsedMode, isDrawerMode, setIsDrawerOpened]);
+    const resizeDoneHandler = useCallback(
+      (newWidth: number) => {
+        setCurrentProductNavWidth(newWidth);
+      },
+      [setCurrentProductNavWidth],
+    );
 
-  return !isClient
-    ? (
+    const collapsedByResizableAreaHandler = useCallback(() => {
+      setPreferCollapsedMode(true);
+      setCollapsedContentsOpened(false);
+    }, [setCollapsedContentsOpened, setPreferCollapsedMode]);
+
+    useIsomorphicLayoutEffect(() => {
+      setClient(true);
+    }, []);
+
+    // open/close resizable container when drawer mode
+    useEffect(() => {
+      setResizableAreaWidth(
+        getWidthByMode(
+          isDrawerMode(),
+          isCollapsedMode(),
+          currentProductNavWidth,
+        ),
+      );
+      setIsDrawerOpened(false);
+    }, [
+      currentProductNavWidth,
+      isCollapsedMode,
+      isDrawerMode,
+      setIsDrawerOpened,
+    ]);
+
+    return !isClient ? (
       <ResizableAreaFallback
         className="flex-expand-vert"
         width={resizableAreaWidth}
       >
         {children}
       </ResizableAreaFallback>
-    )
-    : (
+    ) : (
       <ResizableArea
         className="flex-expand-vert"
         width={resizableAreaWidth}
@@ -124,89 +156,97 @@ const ResizableContainer = memo((props: ResizableContainerProps): JSX.Element =>
         {children}
       </ResizableArea>
     );
-
-});
-
+  },
+);
 
 type CollapsibleContainerProps = {
-  Nav: FC<SidebarNavProps>,
-  className?: string,
-  children?: React.ReactNode,
-}
-
-const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Element => {
-
-  const { Nav, className, children } = props;
-
-  const { isCollapsedMode } = useSidebarMode();
-  const [currentProductNavWidth] = useCurrentProductNavWidth();
-  const [isCollapsedContentsOpened, setCollapsedContentsOpened] = useCollapsedContentsOpened();
-
-  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
-  const setSidebarScrollerRef = useSetSidebarScrollerRef();
-
-  // Set the ref once on mount
-  useEffect(() => {
-    setSidebarScrollerRef(sidebarScrollerRef);
-  }, [setSidebarScrollerRef]);
-
-
-  // open menu when collapsed mode
-  const primaryItemHoverHandler = useCallback(() => {
-    // reject other than collapsed mode
-    if (!isCollapsedMode()) {
-      return;
-    }
-
-    setCollapsedContentsOpened(true);
-  }, [isCollapsedMode, setCollapsedContentsOpened]);
-
-  // close menu when collapsed mode
-  const mouseLeaveHandler = useCallback(() => {
-    // reject other than collapsed mode
-    if (!isCollapsedMode()) {
-      return;
-    }
-
-    setCollapsedContentsOpened(false);
-  }, [isCollapsedMode, setCollapsedContentsOpened]);
-
-  const closedClass = isCollapsedMode() && !isCollapsedContentsOpened ? 'd-none' : '';
-  const openedClass = isCollapsedMode() && isCollapsedContentsOpened ? 'open' : '';
-  const collapsibleContentsWidth = isCollapsedMode() ? currentProductNavWidth : undefined;
+  Nav: FC<SidebarNavProps>;
+  className?: string;
+  children?: React.ReactNode;
+};
 
-  return (
-    <div className={`flex-expand-horiz ${className}`} onMouseLeave={mouseLeaveHandler}>
-      <Nav onPrimaryItemHover={primaryItemHoverHandler} />
-      <div
-        className={`sidebar-contents-container flex-grow-1 overflow-hidden ${closedClass} ${openedClass}`}
+const CollapsibleContainer = memo(
+  (props: CollapsibleContainerProps): JSX.Element => {
+    const { Nav, className, children } = props;
+
+    const { isCollapsedMode } = useSidebarMode();
+    const [currentProductNavWidth] = useCurrentProductNavWidth();
+    const [isCollapsedContentsOpened, setCollapsedContentsOpened] =
+      useCollapsedContentsOpened();
+
+    const sidebarScrollerRef = useRef<HTMLDivElement>(null);
+    const setSidebarScrollerRef = useSetSidebarScrollerRef();
+
+    // Set the ref once on mount
+    useEffect(() => {
+      setSidebarScrollerRef(sidebarScrollerRef);
+    }, [setSidebarScrollerRef]);
+
+    // open menu when collapsed mode
+    const primaryItemHoverHandler = useCallback(() => {
+      // reject other than collapsed mode
+      if (!isCollapsedMode()) {
+        return;
+      }
+
+      setCollapsedContentsOpened(true);
+    }, [isCollapsedMode, setCollapsedContentsOpened]);
+
+    // close menu when collapsed mode
+    const mouseLeaveHandler = useCallback(() => {
+      // reject other than collapsed mode
+      if (!isCollapsedMode()) {
+        return;
+      }
+
+      setCollapsedContentsOpened(false);
+    }, [isCollapsedMode, setCollapsedContentsOpened]);
+
+    const closedClass =
+      isCollapsedMode() && !isCollapsedContentsOpened ? 'd-none' : '';
+    const openedClass =
+      isCollapsedMode() && isCollapsedContentsOpened ? 'open' : '';
+    const collapsibleContentsWidth = isCollapsedMode()
+      ? currentProductNavWidth
+      : undefined;
+
+    return (
+      <fieldset
+        className={`flex-expand-horiz border-0 p-0 m-0 ${className}`}
+        onMouseLeave={mouseLeaveHandler}
       >
-        <SimpleBar
-          scrollableNodeProps={{ ref: sidebarScrollerRef }}
-          className="simple-scrollbar h-100"
-          style={{ width: collapsibleContentsWidth }}
-          autoHide
+        <Nav onPrimaryItemHover={primaryItemHoverHandler} />
+        <div
+          className={`sidebar-contents-container flex-grow-1 overflow-hidden ${closedClass} ${openedClass}`}
         >
-          {children}
-        </SimpleBar>
-      </div>
-    </div>
-  );
-
-});
+          <SimpleBar
+            scrollableNodeProps={{ ref: sidebarScrollerRef }}
+            className="simple-scrollbar h-100"
+            style={{ width: collapsibleContentsWidth }}
+            autoHide
+          >
+            {children}
+          </SimpleBar>
+        </div>
+      </fieldset>
+    );
+  },
+);
 
 // for data-* attributes
 type HTMLElementProps = JSX.IntrinsicElements &
-  Record<keyof JSX.IntrinsicElements, { [p: `data-${string}`]: string | number }>;
+  Record<
+    keyof JSX.IntrinsicElements,
+    { [p: `data-${string}`]: string | number }
+  >;
 
 type DrawableContainerProps = {
-  divProps?: HTMLElementProps['div'],
-  className?: string,
-  children?: React.ReactNode,
-}
+  divProps?: HTMLElementProps['div'];
+  className?: string;
+  children?: React.ReactNode;
+};
 
 const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
-
   const { divProps, className, children } = props;
 
   const [isDrawerOpened, setIsDrawerOpened] = useDrawerOpened();
@@ -219,19 +259,19 @@ const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
         {children}
       </div>
       {isDrawerOpened && (
-        <div className="modal-backdrop fade show" onClick={() => setIsDrawerOpened(false)} />
+        <button
+          type="button"
+          className="modal-backdrop fade show"
+          onClick={() => setIsDrawerOpened(false)}
+        />
       )}
     </>
   );
 });
 
-
 export const Sidebar = (): JSX.Element => {
-
-  const {
-    sidebarMode,
-    isDrawerMode, isCollapsedMode, isDockMode,
-  } = useSidebarMode();
+  const { sidebarMode, isDrawerMode, isCollapsedMode, isDockMode } =
+    useSidebarMode();
 
   const isSearchPage = useIsSearchPage();
   const { editorMode } = useEditorMode();
@@ -240,13 +280,14 @@ export const Sidebar = (): JSX.Element => {
 
   const isEditorMode = editorMode === EditorMode.Editor;
   const shouldHideSiteName = isEditorMode && isXlSize;
-  const shouldHideSubnavAppTitle = isEditorMode && isMdSize && (isDrawerMode() || isCollapsedMode());
+  const shouldHideSubnavAppTitle =
+    isEditorMode && isMdSize && (isDrawerMode() || isCollapsedMode());
   const shouldShowEditorSidebarHead = isEditorMode && isXlSize;
 
   // css styles
   const grwSidebarClass = styles['grw-sidebar'];
   // eslint-disable-next-line no-nested-ternary
-  let modeClass;
+  let modeClass = '';
   switch (sidebarMode) {
     case SidebarMode.DRAWER:
       modeClass = 'grw-sidebar-drawer';
@@ -266,15 +307,23 @@ export const Sidebar = (): JSX.Element => {
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
       )}
-      {sidebarMode != null && !isDockMode() && !isSearchPage && !shouldHideSubnavAppTitle && (
-        <AppTitleOnSubnavigation />
-      )}
-      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
+      {sidebarMode != null &&
+        !isDockMode() &&
+        !isSearchPage &&
+        !shouldHideSubnavAppTitle && <AppTitleOnSubnavigation />}
+      <DrawableContainer
+        className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`}
+        divProps={{ 'data-testid': 'grw-sidebar' }}
+      >
         <ResizableContainer>
           {sidebarMode != null && !isCollapsedMode() && (
             <AppTitleOnSidebarHead hideAppTitle={shouldHideSiteName} />
           )}
-          {shouldShowEditorSidebarHead ? <AppTitleOnEditorSidebarHead /> : <SidebarHead />}
+          {shouldShowEditorSidebarHead ? (
+            <AppTitleOnEditorSidebarHead />
+          ) : (
+            <SidebarHead />
+          )}
           <CollapsibleContainer Nav={SidebarNav} className="border-top">
             <SidebarContents />
           </CollapsibleContainer>

+ 15 - 5
apps/app/src/client/components/Sidebar/SidebarBrandLogo.tsx

@@ -3,16 +3,26 @@ import { memo } from 'react';
 import GrowiLogo from '../../../components/Common/GrowiLogo';
 
 type SidebarBrandLogoProps = {
-  isDefaultLogo?: boolean
-}
+  isDefaultLogo?: boolean;
+};
 
 export const SidebarBrandLogo = memo((props: SidebarBrandLogoProps) => {
   const { isDefaultLogo } = props;
 
-  return isDefaultLogo
-    ? <GrowiLogo />
+  return isDefaultLogo ? (
+    <GrowiLogo />
+  ) : (
     // eslint-disable-next-line @next/next/no-img-element
-    : (<div><img src="/attachment/brand-logo" alt="custom logo" width="48" className="p-1" id="settingBrandLogo" /></div>);
+    <div>
+      <img
+        src="/attachment/brand-logo"
+        alt="custom logo"
+        width="48"
+        className="p-1"
+        id="settingBrandLogo"
+      />
+    </div>
+  );
 });
 
 SidebarBrandLogo.displayName = 'SidebarBrandLogo';

+ 9 - 4
apps/app/src/client/components/Sidebar/SidebarContents.tsx

@@ -1,12 +1,15 @@
 import React, { memo, useMemo } from 'react';
-
 import { useAtomValue } from 'jotai';
 
 import { AiAssistant } from '~/features/openai/client/components/AiAssistant/Sidebar/AiAssistant';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/states/context';
 import { aiEnabledAtom } from '~/states/server-configurations';
-import { useSidebarMode, useCollapsedContentsOpened, useCurrentSidebarContents } from '~/states/ui/sidebar';
+import {
+  useCollapsedContentsOpened,
+  useCurrentSidebarContents,
+  useSidebarMode,
+} from '~/states/ui/sidebar';
 
 import { Bookmarks } from './Bookmarks';
 import { CustomSidebar } from './Custom';
@@ -17,7 +20,6 @@ import Tag from './Tag';
 
 import styles from './SidebarContents.module.scss';
 
-
 export const SidebarContents = memo(() => {
   const { isCollapsedMode } = useSidebarMode();
   const isGuestUser = useIsGuestUser();
@@ -57,7 +59,10 @@ export const SidebarContents = memo(() => {
   const classToHide = isHidden ? 'd-none' : '';
 
   return (
-    <div className={`grw-sidebar-contents ${styles['grw-sidebar-contents']} ${classToHide}`} data-testid="grw-sidebar-contents">
+    <div
+      className={`grw-sidebar-contents ${styles['grw-sidebar-contents']} ${classToHide}`}
+      data-testid="grw-sidebar-contents"
+    >
       <Contents />
     </div>
   );

+ 4 - 6
apps/app/src/client/components/Sidebar/SidebarHead/SidebarHead.tsx

@@ -1,17 +1,15 @@
-import React, {
-  type FC, memo,
-} from 'react';
+import React, { type FC, memo } from 'react';
 
 import { ToggleCollapseButton } from './ToggleCollapseButton';
 
 import styles from './SidebarHead.module.scss';
 
-
 export const SidebarHead: FC = memo(() => {
   return (
-    <div className={`${styles['grw-sidebar-head']} d-flex justify-content-end w-100`}>
+    <div
+      className={`${styles['grw-sidebar-head']} d-flex justify-content-end w-100`}
+    >
       <ToggleCollapseButton />
     </div>
   );
-
 });

+ 8 - 8
apps/app/src/client/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx

@@ -1,17 +1,15 @@
-import {
-  memo, useCallback, useMemo, type JSX,
-} from 'react';
+import { type JSX, memo, useCallback, useMemo } from 'react';
 
 import {
-  useDrawerOpened, useSetPreferCollapsedMode, useSidebarMode, useCollapsedContentsOpened,
+  useCollapsedContentsOpened,
+  useDrawerOpened,
+  useSetPreferCollapsedMode,
+  useSidebarMode,
 } from '~/states/ui/sidebar';
 
-
 import styles from './ToggleCollapseButton.module.scss';
 
-
 export const ToggleCollapseButton = memo((): JSX.Element => {
-
   const { isDrawerMode, isCollapsedMode } = useSidebarMode();
   const [isDrawerOpened, setIsDrawerOpened] = useDrawerOpened();
   const setPreferCollapsedMode = useSetPreferCollapsedMode();
@@ -41,7 +39,9 @@ export const ToggleCollapseButton = memo((): JSX.Element => {
       onClick={isDrawerMode() ? toggleDrawer : toggleCollapsed}
       data-testid="btn-toggle-collapse"
     >
-      <span className={`material-symbols-outlined fs-2 ${rotationClass}`}>{icon}</span>
+      <span className={`material-symbols-outlined fs-2 ${rotationClass}`}>
+        {icon}
+      </span>
     </button>
   );
 });

+ 6 - 3
apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -1,11 +1,14 @@
 type Props = {
-  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
+  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
 };
 
 export const SidebarHeaderReloadButton = ({ onClick }: Props): JSX.Element => {
-
   return (
-    <button type="button" className="btn btn-sm ms-auto py-0 grw-btn-reload" onClick={onClick}>
+    <button
+      type="button"
+      className="btn btn-sm ms-auto py-0 grw-btn-reload"
+      onClick={onClick}
+    >
       <span className="material-symbols-outlined">refresh</span>
     </button>
   );

+ 38 - 20
apps/app/src/client/components/Sidebar/SidebarNav/PersonalDropdown.tsx

@@ -1,11 +1,13 @@
-import { type JSX } from 'react';
-
+import type { JSX } from 'react';
+import Link from 'next/link';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
@@ -24,21 +26,18 @@ export const PersonalDropdown = (): JSX.Element => {
     return <SkeletonItem />;
   }
 
-  const logoutHandler = async() => {
+  const logoutHandler = async () => {
     try {
       await apiv3Post('/logout');
       window.location.reload();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   };
 
   return (
     <>
-      <UncontrolledDropdown
-        direction="end"
-      >
+      <UncontrolledDropdown direction="end">
         <DropdownToggle
           className={`btn btn-primary ${styles['btn-personal-dropdown']} opacity-100`}
           data-testid="personal-dropdown-button"
@@ -57,11 +56,15 @@ export const PersonalDropdown = (): JSX.Element => {
             </div>
             <div className="ms-1 fs-6">{currentUser.name}</div>
             <div className="d-flex align-items-center my-2">
-              <small className="material-symbols-outlined me-1 pb-0 fs-6">person</small>
+              <small className="material-symbols-outlined me-1 pb-0 fs-6">
+                person
+              </small>
               <span>{currentUser.username}</span>
             </div>
             <div className="d-flex align-items-center">
-              <span className="material-symbols-outlined me-1 pb-0 fs-6">mail</span>
+              <span className="material-symbols-outlined me-1 pb-0 fs-6">
+                mail
+              </span>
               <span className="item-text-email">{currentUser.email}</span>
             </div>
           </DropdownItem>
@@ -72,9 +75,13 @@ export const PersonalDropdown = (): JSX.Element => {
             href={pagePathUtils.userHomepagePath(currentUser)}
             data-testid="grw-personal-dropdown-menu-user-home"
           >
-            <DropdownItem className={`my-1 ${styles['personal-dropdown-item']}`}>
+            <DropdownItem
+              className={`my-1 ${styles['personal-dropdown-item']}`}
+            >
               <span className="d-flex align-items-center">
-                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">home</span>
+                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
+                  home
+                </span>
                 <span className="item-text">{t('personal_dropdown.home')}</span>
               </span>
             </DropdownItem>
@@ -84,17 +91,29 @@ export const PersonalDropdown = (): JSX.Element => {
             href="/me"
             data-testid="grw-personal-dropdown-menu-user-settings"
           >
-            <DropdownItem className={`my-1 ${styles['personal-dropdown-item']}`}>
+            <DropdownItem
+              className={`my-1 ${styles['personal-dropdown-item']}`}
+            >
               <span className="d-flex align-items-center">
-                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">discover_tune</span>
-                <span className="item-text">{t('personal_dropdown.settings')}</span>
+                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
+                  discover_tune
+                </span>
+                <span className="item-text">
+                  {t('personal_dropdown.settings')}
+                </span>
               </span>
             </DropdownItem>
           </Link>
 
-          <DropdownItem data-testid="logout-button" onClick={logoutHandler} className={`my-1 ${styles['personal-dropdown-item']}`}>
+          <DropdownItem
+            data-testid="logout-button"
+            onClick={logoutHandler}
+            className={`my-1 ${styles['personal-dropdown-item']}`}
+          >
             <span className="d-flex align-items-center">
-              <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">logout</span>
+              <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
+                logout
+              </span>
               <span className="item-text">{t('Sign out')}</span>
             </span>
           </DropdownItem>
@@ -102,5 +121,4 @@ export const PersonalDropdown = (): JSX.Element => {
       </UncontrolledDropdown>
     </>
   );
-
 };

+ 52 - 34
apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx

@@ -1,14 +1,19 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import type { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarMode } from '~/interfaces/ui';
 import { useIsMobile } from '~/states/ui/device';
-import { useCollapsedContentsOpened, useCurrentSidebarContents } from '~/states/ui/sidebar';
+import {
+  useCollapsedContentsOpened,
+  useCurrentSidebarContents,
+} from '~/states/ui/sidebar';
 
-const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
+const useIndicator = (
+  sidebarMode: SidebarMode,
+  isSelected: boolean,
+): string => {
   const [isCollapsedContentsOpened] = useCollapsedContentsOpened();
 
   if (sidebarMode === SidebarMode.COLLAPSED && !isCollapsedContentsOpened) {
@@ -19,25 +24,34 @@ const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string =>
 };
 
 export type PrimaryItemProps = {
-  contents: SidebarContentsType,
-  label: string,
-  iconName: string,
-  sidebarMode: SidebarMode,
-  isCustomIcon?: boolean,
-  badgeContents?: number,
-  onHover?: (contents: SidebarContentsType) => void,
-  onClick?: () => void,
-}
+  contents: SidebarContentsType;
+  label: string;
+  iconName: string;
+  sidebarMode: SidebarMode;
+  isCustomIcon?: boolean;
+  badgeContents?: number;
+  onHover?: (contents: SidebarContentsType) => void;
+  onClick?: () => void;
+};
 
 export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
   const {
-    contents, label, iconName, sidebarMode, badgeContents, isCustomIcon,
-    onClick, onHover,
+    contents,
+    label,
+    iconName,
+    sidebarMode,
+    badgeContents,
+    isCustomIcon,
+    onClick,
+    onHover,
   } = props;
 
   const [currentContents, setCurrentContents] = useCurrentSidebarContents();
 
-  const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
+  const indicatorClass = useIndicator(
+    sidebarMode,
+    contents === currentContents,
+  );
   const [isMobile] = useIsMobile();
   const { t } = useTranslation();
 
@@ -65,7 +79,6 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
     onHover?.(contents);
   }, [contents, onHover, selectThisItem, sidebarMode]);
 
-
   const labelForTestId = label.toLowerCase().replace(' ', '-');
 
   return (
@@ -80,26 +93,31 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
       >
         <div className="position-relative">
           {badgeContents != null && (
-            <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
+            <span className="position-absolute badge rounded-pill bg-primary">
+              {badgeContents}
+            </span>
+          )}
+          {isCustomIcon ? (
+            <span className="growi-custom-icons fs-4 align-middle">
+              {iconName}
+            </span>
+          ) : (
+            <span className="material-symbols-outlined">{iconName}</span>
           )}
-          {isCustomIcon
-            ? (<span className="growi-custom-icons fs-4 align-middle">{iconName}</span>)
-            : (<span className="material-symbols-outlined">{iconName}</span>)
-          }
         </div>
       </button>
-      {
-        isMobile === false ? (
-          <UncontrolledTooltip
-            autohide
-            placement="right"
-            target={labelForTestId}
-            fade={false}
-          >
-            {t(label)}
-          </UncontrolledTooltip>
-        ) : <></>
-      }
+      {isMobile === false ? (
+        <UncontrolledTooltip
+          autohide
+          placement="right"
+          target={labelForTestId}
+          fade={false}
+        >
+          {t(label)}
+        </UncontrolledTooltip>
+      ) : (
+        <></>
+      )}
     </>
   );
 };

+ 49 - 12
apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx

@@ -1,7 +1,6 @@
 import { memo } from 'react';
-
-import { useAtomValue } from 'jotai';
 import dynamic from 'next/dynamic';
+import { useAtomValue } from 'jotai';
 
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/states/context';
@@ -12,15 +11,18 @@ import { PrimaryItem } from './PrimaryItem';
 
 import styles from './PrimaryItems.module.scss';
 
-
 // Do not SSR Socket.io to make it work
 const PrimaryItemForNotification = dynamic(
-  () => import('../InAppNotification/PrimaryItemForNotification').then(mod => mod.PrimaryItemForNotification), { ssr: false },
+  () =>
+    import('../InAppNotification/PrimaryItemForNotification').then(
+      (mod) => mod.PrimaryItemForNotification,
+    ),
+  { ssr: false },
 );
 
 type Props = {
-  onItemHover?: (contents: SidebarContentsType) => void,
-}
+  onItemHover?: (contents: SidebarContentsType) => void;
+};
 
 export const PrimaryItems = memo((props: Props) => {
   const { onItemHover } = props;
@@ -35,12 +37,47 @@ export const PrimaryItems = memo((props: Props) => {
 
   return (
     <div className={styles['grw-primary-items']}>
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TREE} label="Page Tree" iconName="list" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmarks" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
-      {isGuestUser === false && <PrimaryItemForNotification sidebarMode={sidebarMode} onHover={onItemHover} />}
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.TREE}
+        label="Page Tree"
+        iconName="list"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.CUSTOM}
+        label="Custom Sidebar"
+        iconName="code"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.RECENT}
+        label="Recent Changes"
+        iconName="update"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.BOOKMARKS}
+        label="Bookmarks"
+        iconName="bookmarks"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.TAG}
+        label="Tags"
+        iconName="local_offer"
+        onHover={onItemHover}
+      />
+      {isGuestUser === false && (
+        <PrimaryItemForNotification
+          sidebarMode={sidebarMode}
+          onHover={onItemHover}
+        />
+      )}
       {isAiEnabled && (
         <PrimaryItem
           sidebarMode={sidebarMode}

+ 25 - 15
apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import { memo } from 'react';
-
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 
@@ -11,19 +10,20 @@ import { SkeletonItem } from './SkeletonItem';
 
 import styles from './SecondaryItems.module.scss';
 
-
-const PersonalDropdown = dynamic(() => import('./PersonalDropdown').then(mod => mod.PersonalDropdown), {
-  ssr: false,
-  loading: () => <SkeletonItem />,
-});
-
+const PersonalDropdown = dynamic(
+  () => import('./PersonalDropdown').then((mod) => mod.PersonalDropdown),
+  {
+    ssr: false,
+    loading: () => <SkeletonItem />,
+  },
+);
 
 type SecondaryItemProps = {
-  label: string,
-  href: string,
-  iconName: string,
-  isBlank?: boolean,
-}
+  label: string;
+  href: string;
+  iconName: string;
+  isBlank?: boolean;
+};
 
 const SecondaryItem: FC<SecondaryItemProps> = (props: SecondaryItemProps) => {
   const { iconName, href, isBlank } = props;
@@ -41,15 +41,25 @@ const SecondaryItem: FC<SecondaryItemProps> = (props: SecondaryItemProps) => {
 };
 
 export const SecondaryItems: FC = memo(() => {
-
   const isAdmin = useIsAdmin();
   const growiCloudUri = useGrowiCloudUri();
   const isGuestUser = useIsGuestUser();
 
   return (
     <div className={styles['grw-secondary-items']}>
-      <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
-      {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
+      <SecondaryItem
+        label="Help"
+        iconName="help"
+        href={
+          growiCloudUri != null
+            ? 'https://growi.cloud/help/'
+            : 'https://docs.growi.org'
+        }
+        isBlank
+      />
+      {isAdmin && (
+        <SecondaryItem label="Admin" iconName="settings" href="/admin" />
+      )}
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />
       {!isGuestUser && <PersonalDropdown />}
     </div>

+ 6 - 5
apps/app/src/client/components/Sidebar/SidebarNav/SidebarNav.tsx

@@ -5,15 +5,14 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 
 import { NotAvailableForReadOnlyUser } from '../../NotAvailableForReadOnlyUser';
 import { PageCreateButton } from '../PageCreateButton';
-
 import { PrimaryItems } from './PrimaryItems';
 import { SecondaryItems } from './SecondaryItems';
 
 import styles from './SidebarNav.module.scss';
 
 export type SidebarNavProps = {
-  onPrimaryItemHover?: (contents: SidebarContentsType) => void,
-}
+  onPrimaryItemHover?: (contents: SidebarContentsType) => void;
+};
 
 export const SidebarNav = memo((props: SidebarNavProps) => {
   const { onPrimaryItemHover } = props;
@@ -39,10 +38,12 @@ export const SidebarNav = memo((props: SidebarNavProps) => {
 
   return (
     <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`}>
-
       {renderedPageCreateButton}
 
-      <div className="grw-sidebar-nav-primary-container" data-vrt-blackout-sidebar-nav>
+      <div
+        className="grw-sidebar-nav-primary-container"
+        data-vrt-blackout-sidebar-nav
+      >
         <PrimaryItems onItemHover={onPrimaryItemHover} />
       </div>
 

+ 0 - 1
apps/app/src/client/components/Sidebar/SidebarNav/SkeletonItem.tsx

@@ -4,7 +4,6 @@ import { Skeleton } from '~/client/components/Skeleton';
 
 import styles from './SkeletonItem.module.scss';
 
-
 export const SkeletonItem = memo(() => {
   return <Skeleton additionalClass={styles['grw-skeleton-item']} roundedPill />;
 });

+ 12 - 5
apps/app/src/client/components/Sidebar/Skeleton/DefaultContentSkeleton.tsx

@@ -5,12 +5,19 @@ import { Skeleton } from '~/client/components/Skeleton';
 import styles from './DefaultContentSkelton.module.scss';
 
 const DefaultContentSkeleton = (): JSX.Element => {
-
   return (
-    <div className={`py-3 grw-default-content-skelton ${styles['grw-default-content-skelton']}`}>
-      <Skeleton additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`} />
-      <Skeleton additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`} />
-      <Skeleton additionalClass={`grw-skeleton-text ${styles['grw-skeleton-text']}`} />
+    <div
+      className={`py-3 grw-default-content-skelton ${styles['grw-default-content-skelton']}`}
+    >
+      <Skeleton
+        additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`}
+      />
+      <Skeleton
+        additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`}
+      />
+      <Skeleton
+        additionalClass={`grw-skeleton-text ${styles['grw-skeleton-text']}`}
+      />
     </div>
   );
 };

+ 3 - 2
apps/app/src/client/components/Sidebar/Skeleton/TagContentSkeleton.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { Skeleton } from '~/client/components/Skeleton';
@@ -8,7 +7,9 @@ import styles from '../Tag.module.scss';
 
 export const TagListSkeleton = (): JSX.Element => {
   return (
-    <Skeleton additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`} />
+    <Skeleton
+      additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`}
+    />
   );
 };
 

+ 29 - 27
apps/app/src/client/components/Sidebar/Tag.tsx

@@ -1,28 +1,28 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import React, { useCallback, useState } from 'react';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
 
 import type { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import TagCloudBox from '../TagCloudBox';
 import TagList from '../TagList';
-
 import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
 import { TagListSkeleton } from './Skeleton/TagContentSkeleton';
 
-
 const PAGING_LIMIT = 10;
 const TAG_CLOUD_LIMIT = 20;
 
 const Tag: FC = () => {
-
   const [activePage, setActivePage] = useState<number>(1);
   const [offset, setOffset] = useState<number>(0);
 
-  const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const {
+    data: tagDataList,
+    mutate: mutateTagDataList,
+    error,
+  } = useSWRxTagsList(PAGING_LIMIT, offset);
   const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
@@ -43,7 +43,10 @@ const Tag: FC = () => {
 
   // todo: adjust design by XD
   return (
-    <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+    <div
+      className="container-lg px-3 mb-5 pb-5"
+      data-testid="grw-sidebar-content-tags"
+    >
       <div className="grw-sidebar-content-header pt-4 pb-3 d-flex">
         <h3 className="fs-6 fw-bold mb-0">{t('Tags')}</h3>
         <SidebarHeaderReloadButton onClick={() => onReload()} />
@@ -51,24 +54,24 @@ const Tag: FC = () => {
 
       <h6 className="my-3 pb-1 border-bottom">{t('tag_list')}</h6>
 
-      { isLoading
-        ? (
-          <TagListSkeleton />
-        )
-        : (
-          <div data-testid="grw-tags-list">
-            <TagList
-              tagData={tagData}
-              totalTags={totalCount}
-              activePage={activePage}
-              onChangePage={setOffsetByPageNumber}
-              pagingLimit={PAGING_LIMIT}
-            />
-          </div>
-        )
-      }
-
-      <div className="d-flex justify-content-center my-5" data-testid="check-all-tags-button">
+      {isLoading ? (
+        <TagListSkeleton />
+      ) : (
+        <div data-testid="grw-tags-list">
+          <TagList
+            tagData={tagData}
+            totalTags={totalCount}
+            activePage={activePage}
+            onChangePage={setOffsetByPageNumber}
+            pagingLimit={PAGING_LIMIT}
+          />
+        </div>
+      )}
+
+      <div
+        className="d-flex justify-content-center my-5"
+        data-testid="check-all-tags-button"
+      >
         <Link
           href="/tags"
           className="btn btn-primary rounded px-4"
@@ -84,7 +87,6 @@ const Tag: FC = () => {
       <TagCloudBox tags={tagCloudData} />
     </div>
   );
-
 };
 
 export default Tag;

+ 7 - 9
apps/app/src/client/components/Skeleton.tsx

@@ -2,23 +2,21 @@ import type { JSX } from 'react';
 
 import styles from './Skeleton.module.scss';
 
-
 const moduleClass = styles['grw-skeleton'] ?? '';
 
-
 type SkeletonProps = {
-  additionalClass?: string,
-  roundedPill?: boolean,
-}
+  additionalClass?: string;
+  roundedPill?: boolean;
+};
 
 export const Skeleton = (props: SkeletonProps): JSX.Element => {
-  const {
-    additionalClass, roundedPill,
-  } = props;
+  const { additionalClass, roundedPill } = props;
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton ${moduleClass} h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
+      <div
+        className={`grw-skeleton ${moduleClass} h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}
+      ></div>
     </div>
   );
 };

+ 21 - 10
apps/app/src/client/components/SlackNotification.tsx

@@ -1,15 +1,18 @@
 /* eslint-disable react/prop-types */
 import type { FC } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import {
-  FormGroup, Input, InputGroup, InputGroupText,
-  PopoverBody, PopoverHeader, UncontrolledPopover,
+  FormGroup,
+  Input,
+  InputGroup,
+  InputGroupText,
+  PopoverBody,
+  PopoverHeader,
+  UncontrolledPopover,
 } from 'reactstrap';
 
 import styles from './SlackNotification.module.scss';
 
-
 type SlackNotificationProps = {
   id: string;
   isSlackEnabled: boolean;
@@ -19,13 +22,16 @@ type SlackNotificationProps = {
 };
 
 export const SlackNotification: FC<SlackNotificationProps> = ({
-  id, isSlackEnabled, slackChannels, onEnabledFlagChange, onChannelChange,
+  id,
+  isSlackEnabled,
+  slackChannels,
+  onEnabledFlagChange,
+  onChannelChange,
 }) => {
-
   const { t } = useTranslation();
   const idForSlackPopover = `${id}ForSlackPopover`;
 
-  const updateCheckboxHandler = (event: { target: { checked: boolean }; }) => {
+  const updateCheckboxHandler = (event: { target: { checked: boolean } }) => {
     const value = event.target.checked;
     if (onEnabledFlagChange != null) {
       onEnabledFlagChange(value);
@@ -39,9 +45,10 @@ export const SlackNotification: FC<SlackNotificationProps> = ({
     }
   };
 
-
   return (
-    <InputGroup className={`d-flex align-items-center ${styles['grw-slack-switch']}`}>
+    <InputGroup
+      className={`d-flex align-items-center ${styles['grw-slack-switch']}`}
+    >
       <InputGroupText className="rounded-pill rounded-end border-end-0 p-0 pe-1 grw-slack-switch">
         <FormGroup switch className="position-relative pe-4 py-3 m-0 me-2">
           <Input
@@ -62,7 +69,11 @@ export const SlackNotification: FC<SlackNotificationProps> = ({
         placeholder={`${t('slack_notification.input_channels')}`}
         onChange={updateSlackChannelsHandler}
       />
-      <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
+      <UncontrolledPopover
+        trigger="focus"
+        placement="top"
+        target={idForSlackPopover}
+      >
         <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
         <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
       </UncontrolledPopover>

+ 27 - 14
apps/app/src/client/components/StickyStretchableScroller.tsx

@@ -1,8 +1,12 @@
 import type { RefObject } from 'react';
 import React, {
-  useEffect, useCallback, useRef, useState, useMemo, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
 } from 'react';
-
 import SimpleBar from 'simplebar-react';
 import { debounce } from 'throttle-debounce';
 
@@ -11,13 +15,12 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 
-
 export type StickyStretchableScrollerProps = {
-  stickyElemSelector: string,
-  simplebarRef?: (ref: RefObject<SimpleBar | null>) => void,
-  calcViewHeight?: (scrollElement: HTMLElement) => number,
-  children?: JSX.Element,
-}
+  stickyElemSelector: string;
+  simplebarRef?: (ref: RefObject<SimpleBar | null>) => void;
+  calcViewHeight?: (scrollElement: HTMLElement) => number;
+  children?: JSX.Element;
+};
 
 /**
  * USAGE:
@@ -41,14 +44,20 @@ export type StickyStretchableScrollerProps = {
     </StickyStretchableScroller>
   );
  */
-export const StickyStretchableScroller = (props: StickyStretchableScrollerProps): JSX.Element => {
-
+export const StickyStretchableScroller = (
+  props: StickyStretchableScrollerProps,
+): JSX.Element => {
   const {
-    children, stickyElemSelector, calcViewHeight, simplebarRef: setSimplebarRef,
+    children,
+    stickyElemSelector,
+    calcViewHeight,
+    simplebarRef: setSimplebarRef,
   } = props;
 
   const simplebarRef = useRef<SimpleBar>(null);
-  const [simplebarMaxHeight, setSimplebarMaxHeight] = useState<number|undefined>();
+  const [simplebarMaxHeight, setSimplebarMaxHeight] = useState<
+    number | undefined
+  >();
 
   // Get sticky status
   const isSticky = useSticky(stickyElemSelector);
@@ -72,8 +81,12 @@ export const StickyStretchableScroller = (props: StickyStretchableScrollerProps)
     simplebarRef.current.recalculate();
   }, [calcViewHeight]);
 
-  const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
+  const resetScrollbarDebounced = useMemo(
+    () => debounce(100, resetScrollbar),
+    [resetScrollbar],
+  );
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: ignore
   useEffect(() => {
     resetScrollbarDebounced();
   }, [isSticky, resetScrollbarDebounced]);
@@ -106,7 +119,7 @@ export const StickyStretchableScroller = (props: StickyStretchableScrollerProps)
 
   return (
     <SimpleBar style={{ maxHeight: simplebarMaxHeight }} ref={simplebarRef}>
-      { children }
+      {children}
     </SimpleBar>
   );
 };

+ 15 - 10
apps/app/src/client/components/SystemVersion.tsx

@@ -5,10 +5,9 @@ import { useShortcutsModalActions } from '~/states/ui/modal/shortcuts';
 
 import styles from './SystemVersion.module.scss';
 
-
 type Props = {
-  showShortcutsButton?: boolean,
-}
+  showShortcutsButton?: boolean;
+};
 
 const SystemVersion = (props: Props): JSX.Element => {
   const { showShortcutsButton } = props;
@@ -18,22 +17,28 @@ const SystemVersion = (props: Props): JSX.Element => {
   const growiVersion = useGrowiVersion();
   // add classes to cmd-key by OS
   const platform = window.navigator.platform.toLowerCase();
-  const isMac = (platform.indexOf('mac') > -1);
+  const isMac = platform.indexOf('mac') > -1;
   const os = isMac ? 'mac' : 'win';
 
   return (
     <>
-      <div className={`${styles['system-version']} d-none d-md-flex d-edit-none d-print-none align-items-center`}>
+      <div
+        className={`${styles['system-version']} d-none d-md-flex d-edit-none d-print-none align-items-center`}
+      >
         <span>
           <a href="https://growi.org">GROWI</a> {growiVersion}
         </span>
-        { showShortcutsButton && (
-          <button type="button" className="btn btn-link ms-2 p-0" onClick={() => openShortcutsModal()}>
-            <span className="material-symbols-outlined">keyboard</span>&nbsp;<span className={`cmd-key ${os}`}></span>-/
+        {showShortcutsButton && (
+          <button
+            type="button"
+            className="btn btn-link ms-2 p-0"
+            onClick={() => openShortcutsModal()}
+          >
+            <span className="material-symbols-outlined">keyboard</span>&nbsp;
+            <span className={`cmd-key ${os}`}></span>-/
           </button>
-        ) }
+        )}
       </div>
-
     </>
   );
 };

+ 15 - 9
apps/app/src/client/components/TableOfContents.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import ReactMarkdown from 'react-markdown';
 
@@ -17,13 +16,14 @@ const { isUsersHomepage: _isUsersHomepage } = pagePathUtils;
 const logger = loggerFactory('growi:TableOfContents');
 
 type Props = {
-  tagsElementHeight?: number
-}
+  tagsElementHeight?: number;
+};
 
 const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
   const currentPagePath = useCurrentPagePath();
 
-  const isUsersHomePage = currentPagePath != null && _isUsersHomepage(currentPagePath);
+  const isUsersHomePage =
+    currentPagePath != null && _isUsersHomepage(currentPagePath);
 
   const { data: rendererOptions } = useTocOptions();
 
@@ -34,13 +34,20 @@ const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
 
     // rendererOptions for redo calcViewHeight()
     // see: https://github.com/growilabs/growi/pull/6791
-    if (parentElem == null || containerElem == null || rendererOptions == null || tagsElementHeight == null) {
+    if (
+      parentElem == null ||
+      containerElem == null ||
+      rendererOptions == null ||
+      tagsElementHeight == null
+    ) {
       return 0;
     }
     const parentBottom = parentElem.getBoundingClientRect().bottom;
     const containerTop = containerElem.getBoundingClientRect().top;
     const containerComputedStyle = getComputedStyle(containerElem);
-    const containerPaddingTop = parseFloat(containerComputedStyle['padding-top']);
+    const containerPaddingTop = parseFloat(
+      containerComputedStyle['padding-top'],
+    );
 
     // get smaller bottom line of window height - .system-version height - margin 5px) and containerTop
     let bottom = Math.min(window.innerHeight - 20 - 5, parentBottom);
@@ -65,12 +72,11 @@ const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
           className="revision-toc-content mb-3"
         >
           {/* parse blank to show toc (https://github.com/growilabs/growi/pull/6277) */}
-          <ReactMarkdown {...rendererOptions}>{' '}</ReactMarkdown>
+          <ReactMarkdown {...rendererOptions}> </ReactMarkdown>
         </div>
       </StickyStretchableScroller>
     </div>
   );
-
 };
 
 export default TableOfContents;

+ 16 - 18
apps/app/src/client/components/TagCloudBox.tsx

@@ -4,13 +4,12 @@ import React, { memo } from 'react';
 import type { IDataTagCount } from '~/interfaces/tag';
 import { useSetSearchKeyword } from '~/states/search';
 
-
 type Props = {
-  tags:IDataTagCount[],
-  minSize?: number,
-  maxSize?: number,
-  maxTagTextLength?: number,
-  isDisableRandomColor?: boolean,
+  tags: IDataTagCount[];
+  minSize?: number;
+  maxSize?: number;
+  maxTagTextLength?: number;
+  isDisableRandomColor?: boolean;
 };
 
 const defaultProps = {
@@ -19,33 +18,32 @@ const defaultProps = {
 
 const MAX_TAG_TEXT_LENGTH = 8;
 
-const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
+const TagCloudBox: FC<Props> = memo((props: Props & typeof defaultProps) => {
   const { tags } = props;
-  const maxTagTextLength: number = props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
+  const maxTagTextLength: number =
+    props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
 
   const setSearchKeyword = useSetSearchKeyword();
 
-  const tagElements = tags.map((tag:IDataTagCount) => {
-    const tagNameFormat = (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name;
+  const tagElements = tags.map((tag: IDataTagCount) => {
+    const tagNameFormat =
+      tag.name.length > maxTagTextLength
+        ? `${(tag.name).slice(0, maxTagTextLength)}...`
+        : tag.name;
 
     return (
-      <a
+      <button
         key={tag.name}
         type="button"
         className="grw-tag badge me-2"
         onClick={() => setSearchKeyword(`tag:${tag.name}`)}
       >
         {tagNameFormat}
-      </a>
+      </button>
     );
   });
 
-  return (
-    <div>
-      {tagElements}
-    </div>
-  );
-
+  return <div>{tagElements}</div>;
 });
 
 TagCloudBox.displayName = 'withLoadingSppiner';

+ 37 - 32
apps/app/src/client/components/TagList.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import type { IDataTagCount } from '~/interfaces/tag';
@@ -12,47 +11,56 @@ import styles from './TagList.module.scss';
 
 const moduleClass = styles['grw-tag-list'];
 
-
 type TagListProps = {
-  tagData: IDataTagCount[],
-  totalTags: number,
-  activePage: number,
-  onChangePage?: (selectedPageNumber: number) => void,
-  pagingLimit: number,
-  isPaginationShown?: boolean,
-}
+  tagData: IDataTagCount[];
+  totalTags: number;
+  activePage: number;
+  onChangePage?: (selectedPageNumber: number) => void;
+  pagingLimit: number;
+  isPaginationShown?: boolean;
+};
 
 const defaultProps = {
   isPaginationShown: true,
 };
 
-const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) => {
+const TagList: FC<TagListProps> = (
+  props: TagListProps & typeof defaultProps,
+) => {
   const {
-    tagData, totalTags, activePage, onChangePage, pagingLimit, isPaginationShown,
+    tagData,
+    totalTags,
+    activePage,
+    onChangePage,
+    pagingLimit,
+    isPaginationShown,
   } = props;
   const isTagExist: boolean = tagData.length > 0;
   const { t } = useTranslation('');
 
   const setSearchKeyword = useSetSearchKeyword();
 
-  const generateTagList = useCallback((tagData) => {
-    return tagData.map((tag:IDataTagCount) => {
-      return (
-        <button
-          key={tag._id}
-          type="button"
-          className="list-group-item list-group-item-action d-flex justify-content-between rounded-1"
-          onClick={() => setSearchKeyword(`tag:${tag.name}`)}
-        >
-          <div className="text-truncate grw-tag badge">{tag.name}</div>
-          <div className="grw-tag-count badge">{tag.count}</div>
-        </button>
-      );
-    });
-  }, [setSearchKeyword]);
+  const generateTagList = useCallback(
+    (tagData) => {
+      return tagData.map((tag: IDataTagCount) => {
+        return (
+          <button
+            key={tag._id}
+            type="button"
+            className="list-group-item list-group-item-action d-flex justify-content-between rounded-1"
+            onClick={() => setSearchKeyword(`tag:${tag.name}`)}
+          >
+            <div className="text-truncate grw-tag badge">{tag.name}</div>
+            <div className="grw-tag-count badge">{tag.count}</div>
+          </button>
+        );
+      });
+    },
+    [setSearchKeyword],
+  );
 
   if (!isTagExist) {
-    return <h6>{ t('You have no tag, You can set tags on pages') }</h6>;
+    return <h6>{t('You have no tag, You can set tags on pages')}</h6>;
   }
 
   return (
@@ -60,8 +68,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
       <div className="list-group list-group-flush mb-5">
         {generateTagList(tagData)}
       </div>
-      {isPaginationShown
-      && (
+      {isPaginationShown && (
         <PaginationWrapper
           activePage={activePage}
           changePage={onChangePage}
@@ -70,11 +77,9 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
           align="center"
           size="md"
         />
-      )
-      }
+      )}
     </div>
   );
-
 };
 
 TagList.defaultProps = defaultProps;

+ 3 - 3
apps/app/src/client/components/TemplateTab.tsx

@@ -1,9 +1,9 @@
 import React, { type JSX } from 'react';
 
 type Props = {
-  template: any,
-  onChangeHandler: any,
-}
+  template: any;
+  onChangeHandler: any;
+};
 
 // const onChangeHandler = () => {
 

+ 57 - 22
apps/app/src/client/components/TrashPageList.tsx

@@ -1,9 +1,8 @@
-import React, { useMemo, useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useMemo } from 'react';
+import dynamic from 'next/dynamic';
 import type { IPageHasId } from '@growi/core';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
 
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPagingResult } from '~/interfaces/paging-result';
@@ -17,32 +16,46 @@ import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import type { DescendantsPageListProps } from './DescendantsPageList';
 import EmptyTrashButton from './EmptyTrashButton';
 
-const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
-
+const DescendantsPageList = dynamic<DescendantsPageListProps>(
+  () => import('./DescendantsPageList').then((mod) => mod.DescendantsPageList),
+  { ssr: false },
+);
 
 const convertToIDataWithMeta = (page) => {
   return { data: page };
 };
 
 const useEmptyTrashButton = () => {
-
   const { t } = useTranslation();
   const limit = useAtomValue(showPageLimitationXLAtom);
   const isReadOnlyUser = useIsReadOnlyUser();
-  const { data: pagingResult, mutate: mutatePageLists } = useSWRxPageList('/trash', 1, limit);
+  const { data: pagingResult, mutate: mutatePageLists } = useSWRxPageList(
+    '/trash',
+    1,
+    limit,
+  );
   const { open: openEmptyTrashModal } = useEmptyTrashModalActions();
 
-  const pageIds = pagingResult?.items?.map(page => page._id);
+  const pageIds = pagingResult?.items?.map((page) => page._id);
   const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
-  const calculateDeletablePages = useCallback((pagingResult?: IPagingResult<IPageHasId>) => {
-    if (pagingResult == null) { return undefined }
-
-    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
-    const pageWithMetas = injectTo(dataWithMetas);
-
-    return pageWithMetas.filter(page => page.meta?.isAbleToDeleteCompletely);
-  }, [injectTo]);
+  const calculateDeletablePages = useCallback(
+    (pagingResult?: IPagingResult<IPageHasId>) => {
+      if (pagingResult == null) {
+        return undefined;
+      }
+
+      const dataWithMetas = pagingResult.items.map((page) =>
+        convertToIDataWithMeta(page),
+      );
+      const pageWithMetas = injectTo(dataWithMetas);
+
+      return pageWithMetas.filter(
+        (page) => page.meta?.isAbleToDeleteCompletely,
+      );
+    },
+    [injectTo],
+  );
 
   const deletablePages = calculateDeletablePages(pagingResult);
 
@@ -53,12 +66,27 @@ const useEmptyTrashButton = () => {
   }, [t, mutatePageLists]);
 
   const emptyTrashClickHandler = useCallback(() => {
-    if (deletablePages == null) { return }
-    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDeleteAllPages: pagingResult?.totalCount === deletablePages.length });
-  }, [deletablePages, onEmptiedTrashHandler, openEmptyTrashModal, pagingResult?.totalCount]);
+    if (deletablePages == null) {
+      return;
+    }
+    openEmptyTrashModal(deletablePages, {
+      onEmptiedTrash: onEmptiedTrashHandler,
+      canDeleteAllPages: pagingResult?.totalCount === deletablePages.length,
+    });
+  }, [
+    deletablePages,
+    onEmptiedTrashHandler,
+    openEmptyTrashModal,
+    pagingResult?.totalCount,
+  ]);
 
   const emptyTrashButton = useMemo(() => {
-    return <EmptyTrashButton onEmptyTrashButtonClick={emptyTrashClickHandler} disableEmptyButton={deletablePages?.length === 0 || !!isReadOnlyUser} />;
+    return (
+      <EmptyTrashButton
+        onEmptyTrashButtonClick={emptyTrashClickHandler}
+        disableEmptyButton={deletablePages?.length === 0 || !!isReadOnlyUser}
+      />
+    );
   }, [emptyTrashClickHandler, deletablePages?.length, isReadOnlyUser]);
 
   return emptyTrashButton;
@@ -76,6 +104,10 @@ const DescendantsPageListForTrash = (): JSX.Element => {
   );
 };
 
+const PageListIcon = () => (
+  <span className="material-symbols-outlined">subject</span>
+);
+
 export const TrashPageList = (): JSX.Element => {
   const { t } = useTranslation();
   const emptyTrashButton = useEmptyTrashButton();
@@ -83,7 +115,7 @@ export const TrashPageList = (): JSX.Element => {
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
+        Icon: PageListIcon,
         Content: DescendantsPageListForTrash,
         i18n: t('page_list'),
       },
@@ -92,7 +124,10 @@ export const TrashPageList = (): JSX.Element => {
 
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} navRightElement={emptyTrashButton} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        navRightElement={emptyTrashButton}
+      />
     </div>
   );
 };

+ 21 - 24
apps/app/src/client/components/UnsavedAlertDialog.tsx

@@ -1,9 +1,6 @@
-import React, {
-  useCallback, useEffect, memo, type JSX,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
+import React, { type JSX, memo, useCallback, useEffect } from 'react';
 import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
 
 import { useUnsavedWarning } from '~/states/ui/unsaved-warning';
 
@@ -12,16 +9,19 @@ const UnsavedAlertDialog = (): JSX.Element => {
   const router = useRouter();
   const { isEnabled: isEnabledUnsavedWarning, reset } = useUnsavedWarning();
 
-  const alertUnsavedWarningByBrowser = useCallback((e) => {
-    if (isEnabledUnsavedWarning) {
-      e.preventDefault();
-      // returnValue should be set to show alert dialog
-      // default alert message cannot be changed.
-      // See -> https://developer.mozilla.org/ja/docs/Web/API/Window/beforeunload_event
-      e.returnValue = '';
-      return;
-    }
-  }, [isEnabledUnsavedWarning]);
+  const alertUnsavedWarningByBrowser = useCallback(
+    (e) => {
+      if (isEnabledUnsavedWarning) {
+        e.preventDefault();
+        // returnValue should be set to show alert dialog
+        // default alert message cannot be changed.
+        // See -> https://developer.mozilla.org/ja/docs/Web/API/Window/beforeunload_event
+        e.returnValue = '';
+        return;
+      }
+    },
+    [isEnabledUnsavedWarning],
+  );
 
   const alertUnsavedWarningByNextRouter = useCallback(() => {
     if (isEnabledUnsavedWarning) {
@@ -40,9 +40,9 @@ const UnsavedAlertDialog = (): JSX.Element => {
   }, [reset]);
 
   /*
-  * Route changes by Browser
-  * Example: window.location.href, F5
-  */
+   * Route changes by Browser
+   * Example: window.location.href, F5
+   */
   useEffect(() => {
     window.addEventListener('beforeunload', alertUnsavedWarningByBrowser);
     return () => {
@@ -50,11 +50,10 @@ const UnsavedAlertDialog = (): JSX.Element => {
     };
   }, [alertUnsavedWarningByBrowser]);
 
-
   /*
-  * Route changes by Next Router
-  * https://nextjs.org/docs/api-reference/next/router
-  */
+   * Route changes by Next Router
+   * https://nextjs.org/docs/api-reference/next/router
+   */
   useEffect(() => {
     router.events.on('routeChangeStart', alertUnsavedWarningByNextRouter);
     return () => {
@@ -62,7 +61,6 @@ const UnsavedAlertDialog = (): JSX.Element => {
     };
   }, [alertUnsavedWarningByNextRouter, router.events]);
 
-
   useEffect(() => {
     router.events.on('routeChangeComplete', onRouterChangeComplete);
     return () => {
@@ -70,7 +68,6 @@ const UnsavedAlertDialog = (): JSX.Element => {
     };
   }, [onRouterChangeComplete, router.events]);
 
-
   return <></>;
 };
 

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