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

Merge pull request #10630 from growilabs/support/156162-176212/app-client-sidebar-components

support: Configure biome for app client sidebar components
Yuki Takei 3 месяцев назад
Родитель
Сommit
4cef808ebd
47 измененных файлов с 1390 добавлено и 1033 удалено
  1. 1 0
      apps/app/.eslintrc.js
  2. 55 49
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx
  3. 2 6
      apps/app/src/client/components/Sidebar/Bookmarks.tsx
  4. 20 22
      apps/app/src/client/components/Sidebar/Bookmarks/BookmarkContents.tsx
  5. 14 6
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  6. 15 7
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  7. 11 12
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.tsx
  8. 18 10
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  9. 38 28
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  10. 51 39
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  11. 7 3
      apps/app/src/client/components/Sidebar/PageCreateButton/CreateButton.tsx
  12. 63 64
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendMenu.tsx
  13. 3 4
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.tsx
  14. 20 14
      apps/app/src/client/components/Sidebar/PageCreateButton/Hexagon.tsx
  15. 22 12
      apps/app/src/client/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  16. 4 6
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  17. 12 15
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  18. 5 6
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  19. 133 115
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  20. 3 4
      apps/app/src/client/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  21. 4 9
      apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx
  22. 5 3
      apps/app/src/client/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx
  23. 92 62
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  24. 61 41
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  25. 15 7
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx
  26. 12 6
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesContentSkeleton.tsx
  27. 169 110
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  28. 4 0
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.module.scss
  29. 63 54
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.tsx
  30. 7 15
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableAreaFallback.tsx
  31. 9 9
      apps/app/src/client/components/Sidebar/ResizableArea/props.d.ts
  32. 196 147
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  33. 15 5
      apps/app/src/client/components/Sidebar/SidebarBrandLogo.tsx
  34. 9 4
      apps/app/src/client/components/Sidebar/SidebarContents.tsx
  35. 4 6
      apps/app/src/client/components/Sidebar/SidebarHead/SidebarHead.tsx
  36. 8 8
      apps/app/src/client/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx
  37. 6 3
      apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx
  38. 38 20
      apps/app/src/client/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  39. 52 34
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx
  40. 49 12
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx
  41. 25 15
      apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx
  42. 6 5
      apps/app/src/client/components/Sidebar/SidebarNav/SidebarNav.tsx
  43. 0 1
      apps/app/src/client/components/Sidebar/SidebarNav/SkeletonItem.tsx
  44. 12 5
      apps/app/src/client/components/Sidebar/Skeleton/DefaultContentSkeleton.tsx
  45. 3 2
      apps/app/src/client/components/Sidebar/Skeleton/TagContentSkeleton.tsx
  46. 29 27
      apps/app/src/client/components/Sidebar/Tag.tsx
  47. 0 1
      biome.json

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

@@ -41,6 +41,7 @@ module.exports = {
     'src/client/components/*.jsx',
     'src/client/components/*.ts',
     'src/client/components/*.js',
+    'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',
     'src/stores/**',

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 0 - 1
biome.json

@@ -71,7 +71,6 @@
       "!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"
     ]