Browse Source

configure biome for app client components without parent dir

Futa Arai 3 tháng trước cách đây
mục cha
commit
800b24325d
48 tập tin đã thay đổi với 1680 bổ sung1150 xóa
  1. 4 0
      apps/app/.eslintrc.js
  2. 7 6
      apps/app/src/client/components/AlertSiteUrlUndefined.tsx
  3. 27 21
      apps/app/src/client/components/Comments.tsx
  4. 2 2
      apps/app/src/client/components/CompleteUserRegistration.tsx
  5. 78 58
      apps/app/src/client/components/CompleteUserRegistrationForm.tsx
  6. 6 6
      apps/app/src/client/components/ContentLinkButtons.tsx
  7. 19 6
      apps/app/src/client/components/DataTransferForm.tsx
  8. 77 51
      apps/app/src/client/components/DescendantsPageList.tsx
  9. 16 19
      apps/app/src/client/components/DuplicatedPathsTable.tsx
  10. 3 5
      apps/app/src/client/components/EmptyTrashButton.tsx
  11. 4 5
      apps/app/src/client/components/ErrorBoudary.jsx
  12. 6 6
      apps/app/src/client/components/ExpandOrContractButton.tsx
  13. 11 6
      apps/app/src/client/components/ForbiddenPage.tsx
  14. 10 6
      apps/app/src/client/components/FormattedDistanceDate.jsx
  15. 24 15
      apps/app/src/client/components/IdenticalPathPage.tsx
  16. 20 19
      apps/app/src/client/components/InfiniteScroll.tsx
  17. 148 112
      apps/app/src/client/components/InstallerForm.tsx
  18. 54 38
      apps/app/src/client/components/InvitedForm.tsx
  19. 15 13
      apps/app/src/client/components/NotAvailable.tsx
  20. 19 18
      apps/app/src/client/components/NotAvailableForGuest.tsx
  21. 17 17
      apps/app/src/client/components/NotAvailableForNow.tsx
  22. 19 16
      apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx
  23. 20 7
      apps/app/src/client/components/NotAvailableForReadOnlyUser.tsx
  24. 4 3
      apps/app/src/client/components/NotCreatablePage.tsx
  25. 7 5
      apps/app/src/client/components/NotFoundPage.tsx
  26. 243 203
      apps/app/src/client/components/PageComment.tsx
  27. 139 76
      apps/app/src/client/components/PageCreateModal.tsx
  28. 8 13
      apps/app/src/client/components/PagePathAutoComplete.jsx
  29. 36 13
      apps/app/src/client/components/PageStatusAlert.tsx
  30. 15 16
      apps/app/src/client/components/PageTimeline.tsx
  31. 46 27
      apps/app/src/client/components/PaginationWrapper.tsx
  32. 23 16
      apps/app/src/client/components/PasswordResetExecutionForm.tsx
  33. 28 22
      apps/app/src/client/components/PasswordResetRequestForm.tsx
  34. 45 24
      apps/app/src/client/components/PrivateLegacyPagesMigrationModal.tsx
  35. 165 113
      apps/app/src/client/components/SearchTypeahead.tsx
  36. 7 9
      apps/app/src/client/components/Skeleton.tsx
  37. 21 10
      apps/app/src/client/components/SlackNotification.tsx
  38. 26 14
      apps/app/src/client/components/StickyStretchableScroller.tsx
  39. 15 10
      apps/app/src/client/components/SystemVersion.tsx
  40. 15 9
      apps/app/src/client/components/TableOfContents.tsx
  41. 14 16
      apps/app/src/client/components/TagCloudBox.tsx
  42. 37 32
      apps/app/src/client/components/TagList.tsx
  43. 3 3
      apps/app/src/client/components/TemplateTab.tsx
  44. 52 21
      apps/app/src/client/components/TrashPageList.tsx
  45. 21 24
      apps/app/src/client/components/UnsavedAlertDialog.tsx
  46. 7 4
      apps/app/src/client/components/UnstatedUtils.tsx
  47. 51 14
      apps/app/src/client/components/UsersHomepageFooter.tsx
  48. 46 1
      biome.json

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

@@ -37,6 +37,10 @@ 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/services/**',
     'src/states/**',
     'src/stores/**',

+ 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>

+ 78 - 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,87 @@ 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
+              role="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 +150,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 +176,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 +192,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 +205,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 +215,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 +225,4 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
       </div>
     </>
   );
-
 };

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

@@ -1,6 +1,5 @@
 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';
 
@@ -38,12 +37,13 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
-
 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) {

+ 19 - 6
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>
@@ -39,7 +50,9 @@ const DataTransferForm = (): JSX.Element => {
           className="mb-0"
           // eslint-disable-next-line react/no-danger
           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,

+ 24 - 15
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') }) }}
+          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>
     </>
   );

+ 148 - 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,106 @@ 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>
+                  {tWithOpt(err.message, err.args)}
+                  <br />
+                </span>
+              ))}
             </div>
-          )
-        }
-
-        <form role="form" id="register-form" className="ps-1" onSubmit={submitHandler}>
+          </div>
+        )}
+
+        <form
+          role="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 +146,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]"
-              />
+              <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>
-                    );
-                  })
-                }
+                {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>
+                  );
+                })}
               </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 +196,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 +216,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 +236,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 +269,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>

+ 54 - 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>
+                  {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,12 @@ 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
+        role="form"
+        onSubmit={handleSubmit(submitHandler)}
+        id="invited-form"
+      >
         {/* Email Form */}
         <div className="input-group">
           <span className="input-group-text">
@@ -144,7 +154,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 +173,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>

+ 7 - 5
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,8 +6,8 @@ import { DescendantsPageList } from './DescendantsPageList';
 import { PageTimeline } from './PageTimeline';
 
 type NotFoundPageProps = {
-  path: string,
-}
+  path: string;
+};
 
 const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
   const { t } = useTranslation();
@@ -32,7 +31,10 @@ const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
 
   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';

+ 23 - 16
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,24 +29,28 @@ 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 (
@@ -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 />
+          <label>{t('private_legacy_pages.modal.converting_pages')}:</label>
+          <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>

+ 165 - 113
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,37 +43,53 @@ 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);
 
@@ -85,35 +112,44 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     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],
+  );
 
-  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);
-      }
-    }
-  }, [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 (onChange != null) {
+          onChange(selectedItems);
+        }
       }
-      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) {
+          return <></>;
+        }
+
+        return (
+          <Menu {...menuProps}>
+            <div className="p-3">{helpElement}</div>
+          </Menu>
+        );
+      }
 
-    const isEmptyInput = input == null || input.length === 0;
-    if (isEmptyInput) {
-      if (helpElement == null) {
+      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>
   );
 };

+ 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>

+ 26 - 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,7 +81,10 @@ export const StickyStretchableScroller = (props: StickyStretchableScrollerProps)
     simplebarRef.current.recalculate();
   }, [calcViewHeight]);
 
-  const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
+  const resetScrollbarDebounced = useMemo(
+    () => debounce(100, resetScrollbar),
+    [resetScrollbar],
+  );
 
   useEffect(() => {
     resetScrollbarDebounced();
@@ -106,7 +118,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;

+ 14 - 16
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,14 +18,18 @@ 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
@@ -40,12 +43,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
     );
   });
 
-  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 = () => {
 

+ 52 - 21
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;
@@ -92,7 +120,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 <></>;
 };
 

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

@@ -1,10 +1,8 @@
 /* eslint-disable import/prefer-default-export */
 
 import React from 'react';
-
 import { Provider, Subscribe } from 'unstated';
 
-
 /**
  * generate K/V object by specified instances
  *
@@ -45,11 +43,16 @@ function generateAutoNamedProps(instances) {
  *    )}
  *  </Subscribe>
  */
-export function withUnstatedContainers<T, P>(Component, containerClasses): React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<T>> {
+export function withUnstatedContainers<T, P>(
+  Component,
+  containerClasses,
+): React.ForwardRefExoticComponent<
+  React.PropsWithoutRef<P> & React.RefAttributes<T>
+> {
   const unstatedContainer = React.forwardRef<T, P>((props, ref) => (
     // wrap with <Subscribe></Subscribe>
     <Subscribe to={containerClasses}>
-      { (...containers) => {
+      {(...containers) => {
         const propsForContainers = generateAutoNamedProps(containers);
         return <Component {...props} {...propsForContainers} ref={ref} />;
       }}

+ 51 - 14
apps/app/src/client/components/UsersHomepageFooter.tsx

@@ -1,5 +1,4 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { RecentActivity } from '~/client/components/RecentActivity/RecentActivity';
@@ -14,7 +13,9 @@ type UsersHomepageFooterProps = {
   creatorId: string;
 };
 
-export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Element => {
+export const UsersHomepageFooter = (
+  props: UsersHomepageFooterProps,
+): JSX.Element => {
   const { t } = useTranslation();
   const { creatorId } = props;
   const [isExpanded, setIsExpanded] = useState<boolean>(false);
@@ -22,36 +23,72 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
   const isOperable = currentUser?._id === creatorId;
 
   return (
-    <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
+    <div
+      className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}
+    >
       <div className="grw-user-page-list-m d-edit-none">
-        <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
-          <span style={{ fontSize: '1.3em' }} className="material-symbols-outlined">bookmark</span>
+        <h2
+          id="bookmarks-list"
+          className="grw-user-page-header border-bottom pb-2 mb-3 d-flex"
+        >
+          <span
+            style={{ fontSize: '1.3em' }}
+            className="material-symbols-outlined"
+          >
+            bookmark
+          </span>
           {t('user_home_page.bookmarks')}
           <span className="ms-auto ps-2 ">
-            <button type="button" className={`btn btn-sm grw-expand-compress-btn ${isExpanded ? 'active' : ''}`} onClick={() => setIsExpanded(!isExpanded)}>
-              {isExpanded ? <span className="material-symbols-outlined">expand</span> : <span className="material-symbols-outlined">compress</span>}
+            <button
+              type="button"
+              className={`btn btn-sm grw-expand-compress-btn ${isExpanded ? 'active' : ''}`}
+              onClick={() => setIsExpanded(!isExpanded)}
+            >
+              {isExpanded ? (
+                <span className="material-symbols-outlined">expand</span>
+              ) : (
+                <span className="material-symbols-outlined">compress</span>
+              )}
             </button>
           </span>
         </h2>
         {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
-        <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
-          <BookmarkFolderTree isUserHomepage isOperable={isOperable} userId={creatorId} />
+        <div
+          className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}
+        >
+          <BookmarkFolderTree
+            isUserHomepage
+            isOperable={isOperable}
+            userId={creatorId}
+          />
         </div>
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">
-        <h2 id="recently-created-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
+        <h2
+          id="recently-created-list"
+          className="grw-user-page-header border-bottom pb-2 mb-3 d-flex"
+        >
           <span className="growi-custom-icons me-1">recently_created</span>
           {t('user_home_page.recently_created')}
         </h2>
-        <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
+        <div
+          id="user-created-list"
+          className={`page-list ${styles['page-list']}`}
+        >
           <RecentCreated userId={creatorId} />
         </div>
 
-        <h2 id="user-created-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
+        <h2
+          id="user-created-list"
+          className="grw-user-page-header border-bottom pb-2 mb-3 d-flex"
+        >
           <span className="growi-custom-icons me-1">recently_created</span>
           {t('user_home_page.recent_activity')}
         </h2>
-        <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
+        <div
+          id="user-created-list"
+          className={`page-list ${styles['page-list']}`}
+        >
           <RecentActivity userId={creatorId} />
         </div>
       </div>

+ 46 - 1
biome.json

@@ -28,7 +28,52 @@
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
-      "!apps/app/src/client/components"
+      "!apps/app/src/client/components/Admin",
+      "!apps/app/src/client/components/AuthorInfo",
+      "!apps/app/src/client/components/Bookmarks",
+      "!apps/app/src/client/components/Common",
+      "!apps/app/src/client/components/CreateTemplateModal",
+      "!apps/app/src/client/components/CustomNavigation",
+      "!apps/app/src/client/components/DeleteBookmarkFolderModal",
+      "!apps/app/src/client/components/DescendantsPageListModal",
+      "!apps/app/src/client/components/EmptyTrashModal",
+      "!apps/app/src/client/components/GrantedGroupsInheritanceSelectModal",
+      "!apps/app/src/client/components/Hotkeys",
+      "!apps/app/src/client/components/Icons",
+      "!apps/app/src/client/components/InAppNotification",
+      "!apps/app/src/client/components/ItemsTree",
+      "!apps/app/src/client/components/LoginForm",
+      "!apps/app/src/client/components/Maintenance",
+      "!apps/app/src/client/components/Me",
+      "!apps/app/src/client/components/Navbar",
+      "!apps/app/src/client/components/Page",
+      "!apps/app/src/client/components/PageAccessoriesModal",
+      "!apps/app/src/client/components/PageAttachment",
+      "!apps/app/src/client/components/PageComment",
+      "!apps/app/src/client/components/PageControls",
+      "!apps/app/src/client/components/PageDeleteModal",
+      "!apps/app/src/client/components/PageDuplicateModal",
+      "!apps/app/src/client/components/PageEditor",
+      "!apps/app/src/client/components/PageHeader",
+      "!apps/app/src/client/components/PageHistory",
+      "!apps/app/src/client/components/PageList",
+      "!apps/app/src/client/components/PageManagement",
+      "!apps/app/src/client/components/PagePathNavSticky",
+      "!apps/app/src/client/components/PagePresentationModal",
+      "!apps/app/src/client/components/PageRenameModal",
+      "!apps/app/src/client/components/PageSelectModal",
+      "!apps/app/src/client/components/PageSideContents",
+      "!apps/app/src/client/components/PageTags",
+      "!apps/app/src/client/components/Presentation",
+      "!apps/app/src/client/components/PutbackPageModal",
+      "!apps/app/src/client/components/ReactMarkdownComponents",
+      "!apps/app/src/client/components/RecentActivity",
+      "!apps/app/src/client/components/RecentCreated",
+      "!apps/app/src/client/components/RevisionComparer",
+      "!apps/app/src/client/components/ShortcutsModal",
+      "!apps/app/src/client/components/Sidebar",
+      "!apps/app/src/client/components/StaffCredit",
+      "!apps/app/src/client/components/TemplateModal"
     ]
   },
   "formatter": {