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

Merge branch 'dev/5.0.x' into rc/5.0.x

yohei0125 4 лет назад
Родитель
Сommit
fcbb77439c
26 измененных файлов с 219 добавлено и 137 удалено
  1. 5 1
      packages/app/src/client/services/ContextExtractor.tsx
  2. 2 2
      packages/app/src/components/Page/PageManagement.jsx
  3. 12 4
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  4. 10 8
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  5. 26 43
      packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx
  6. 3 3
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  7. 4 13
      packages/app/src/components/SearchPage/SortControl.tsx
  8. 5 2
      packages/app/src/components/Sidebar/PageTree.tsx
  9. 9 9
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  10. 7 6
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  11. 3 0
      packages/app/src/interfaces/indeterminate-input-elm.ts
  12. 5 0
      packages/app/src/interfaces/page-listing-results.ts
  13. 1 1
      packages/app/src/server/routes/apiv3/pages.js
  14. 9 0
      packages/app/src/server/routes/page.js
  15. 51 19
      packages/app/src/server/service/page.js
  16. 5 0
      packages/app/src/server/views/layout-growi/not_found.html
  17. 9 10
      packages/app/src/stores/bookmark.ts
  18. 5 1
      packages/app/src/stores/context.tsx
  19. 2 2
      packages/app/src/stores/page.tsx
  20. 24 0
      packages/app/src/stores/ui.tsx
  21. 0 3
      packages/app/src/styles/_page-tree.scss
  22. 17 8
      packages/app/src/styles/_search.scss
  23. 1 0
      packages/app/src/styles/_sidebar.scss
  24. 2 0
      packages/app/src/styles/_variables.scss
  25. 1 1
      packages/app/src/styles/theme/_apply-colors-dark.scss
  26. 1 1
      packages/app/src/styles/theme/_apply-colors-light.scss

+ 5 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -5,7 +5,8 @@ import {
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
-  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors, useSlackChannels,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
+  useSlackChannels, useNotFoundTargetPathOrId,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd,
@@ -20,6 +21,7 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
+  const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
 
   /*
    * App Context from DOM
@@ -61,6 +63,7 @@ const ContextExtractorOnce: FC = () => {
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
+  const notFoundTargetPathOrId = JSON.parse(notFoundContent?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
 
   /*
@@ -104,6 +107,7 @@ const ContextExtractorOnce: FC = () => {
   useCreator(creator);
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
+  useNotFoundTargetPathOrId(notFoundTargetPathOrId);
 
   // Navigation
   usePreferDrawerModeByUser();

+ 2 - 2
packages/app/src/components/Page/PageManagement.jsx

@@ -206,7 +206,7 @@ const LegacyPageManagemenet = (props) => {
           className={`btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management ${isCompactMode && 'py-0'}`}
           data-toggle="dropdown"
         >
-          <i className="icon-options"></i>
+          <i className="text-muted icon-options"></i>
         </button>
       </>
     );
@@ -220,7 +220,7 @@ const LegacyPageManagemenet = (props) => {
           className={`btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled ${isCompactMode && 'py-0'}`}
           id="icon-options-guest-tltips"
         >
-          <i className="icon-options"></i>
+          <i className="text-muted icon-options"></i>
         </button>
         <UncontrolledTooltip placement="top" target="icon-options-guest-tltips" fade={false}>
           {t('Not available for guest')}

+ 12 - 4
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -1,5 +1,6 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
+import { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
 import { CheckboxType } from '../../interfaces/search';
 
 type Props = {
@@ -26,18 +27,25 @@ const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
     if (onClickDeleteAllButton != null) { onClickDeleteAllButton() }
   };
 
+  const selectAllCheckboxElm = useRef<IndeterminateInputElement>(null);
+  useEffect(() => {
+    if (selectAllCheckboxElm.current != null) {
+      selectAllCheckboxElm.current.indeterminate = selectAllCheckboxType === CheckboxType.INDETERMINATE;
+    }
+  }, [selectAllCheckboxType]);
+
   return (
 
     <div className="d-flex align-items-center">
-      {/** todo: implement the design for CheckboxType = INDETERMINATE */}
       <input
         id="check-all-pages"
         type="checkbox"
         name="check-all-pages"
-        className="custom-control custom-checkbox align-self-center"
+        className="grw-indeterminate-checkbox"
+        ref={selectAllCheckboxElm}
         disabled={props.isSelectAllCheckboxDisabled}
         onClick={onClickCheckbox}
-        checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}
+        checked={selectAllCheckboxType === CheckboxType.ALL_CHECKED}
       />
       <button
         type="button"

+ 10 - 8
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -22,20 +22,22 @@ const SearchResultContent: FC<Props> = (props: Props) => {
   if (page == null) return <></>;
   const growiRenderer = props.appContainer.getRenderer('searchresult');
   return (
-    <div key={page._id} className="search-result-page mb-5">
+    <div key={page._id} className="search-result-page grw-page-path-text-muted-container d-flex flex-column">
       <SearchResultContentSubNavigation
         pageId={page._id}
         revisionId={page.revision}
         path={page.path}
       >
       </SearchResultContentSubNavigation>
-      <RevisionLoader
-        growiRenderer={growiRenderer}
-        pageId={page._id}
-        pagePath={page.path}
-        revisionId={page.revision}
-        highlightKeywords={props.searchingKeyword}
-      />
+      <div className="search-result-page-content">
+        <RevisionLoader
+          growiRenderer={growiRenderer}
+          pageId={page._id}
+          pagePath={page.path}
+          revisionId={page.revision}
+          highlightKeywords={props.searchingKeyword}
+        />
+      </div>
     </div>
   );
 };

+ 26 - 43
packages/app/src/components/SearchPage/SearchResultContentSubNavigation.tsx

@@ -1,11 +1,8 @@
-import React, { FC, useCallback } from 'react';
+import React, { FC } from 'react';
 import { pagePathUtils } from '@growi/core';
 import PagePathNav from '../PagePathNav';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../client/services/AppContainer';
-import TagLabels from '../Page/TagLabels';
-import { toastSuccess, toastError } from '../../client/util/apiNotification';
-import { apiPost } from '../../client/util/apiv1-client';
 import { useSWRTagsInfo } from '../../stores/page';
 import SubNavButtons from '../Navbar/SubNavButtons';
 
@@ -26,18 +23,7 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
 
   const { isTrashPage, isDeletablePage } = pagePathUtils;
 
-  const { data: tagInfoData, error: tagInfoError, mutate: mutateTagInfo } = useSWRTagsInfo(pageId);
-
-  const tagsUpdatedHandler = useCallback(async(newTags) => {
-    try {
-      await apiPost('/tags.update', { pageId, tags: newTags });
-      toastSuccess('updated tags successfully');
-      mutateTagInfo();
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  }, [pageId, mutateTagInfo]);
+  const { data: tagInfoData, error: tagInfoError } = useSWRTagsInfo(pageId);
 
   if (tagInfoError != null || tagInfoData == null) {
     return <></>;
@@ -46,33 +32,30 @@ const SearchResultContentSubNavigation: FC<Props> = (props : Props) => {
   const { isSharedUser } = appContainer;
   const isAbleToShowPageManagement = !(isTrashPage(path)) && !isSharedUser;
   return (
-    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
-      {/* Left side */}
-      <div className="grw-path-nav-container">
-        {!isSharedUser && !isCompactMode && (
-          <div className="grw-taglabels-container">
-            <TagLabels tags={tagInfoData.tags} tagsUpdateInvoked={tagsUpdatedHandler} />
-          </div>
-        )}
-        <PagePathNav pageId={pageId} pagePath={path} isCompactMode={isCompactMode} isSingleLineMode={isSignleLineMode} />
-      </div>
-      {/* Right side */}
-      {/*
-        DeleteCompletely is currently disabled
-        TODO : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
-        story: https://redmine.weseek.co.jp/issues/82222
-      */}
-      <div className="d-flex">
-        <SubNavButtons
-          isCompactMode={isCompactMode}
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-          isDeletable={isPageDeletable}
-          // isAbleToDeleteCompletely={}
-          willShowPageManagement={isAbleToShowPageManagement}
-        >
-        </SubNavButtons>
+    <div className="position-sticky fixed-top shadow-sm search-result-content-nav">
+      <div className={`grw-subnav container-fluid d-flex align-items-start justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+        {/* Left side */}
+        <div className="grw-path-nav-container">
+          <PagePathNav pageId={pageId} pagePath={path} isCompactMode={isCompactMode} isSingleLineMode={isSignleLineMode} />
+        </div>
+        {/* Right side */}
+        {/*
+          DeleteCompletely is currently disabled
+          TODO : Retrive isAbleToDeleteCompleltly state everywhere in the system via swr.
+          story: https://redmine.weseek.co.jp/issues/82222
+        */}
+        <div className="d-flex">
+          <SubNavButtons
+            isCompactMode={isCompactMode}
+            pageId={pageId}
+            revisionId={revisionId}
+            path={path}
+            isDeletable={isPageDeletable}
+            // isAbleToDeleteCompletely={}
+            willShowPageManagement={isAbleToShowPageManagement}
+          >
+          </SubNavButtons>
+        </div>
       </div>
     </div>
   );

+ 3 - 3
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -4,7 +4,7 @@ import Clamp from 'react-multiline-clamp';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { pagePathUtils, DevidedPagePath } from '@growi/core';
-import { useIsDeviceSmallerThanMd } from '~/stores/ui';
+import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 
 import { IPageSearchResultData } from '../../interfaces/search';
 import PageItemControl from '../Common/Dropdown/PageItemControl';
@@ -28,7 +28,7 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
     page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
   } = props;
 
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
 
   const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
 
@@ -48,7 +48,7 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
     />
   );
 
-  const responsiveListStyleClass = `${isDeviceSmallerThanMd ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
+  const responsiveListStyleClass = `${isDeviceSmallerThanLg ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
 
   return (
     <li

+ 4 - 13
packages/app/src/components/SearchPage/SortControl.tsx

@@ -25,15 +25,6 @@ const SortControl: FC <Props> = (props: Props) => {
     return <i className={iconClassName} aria-hidden="true" />;
   };
 
-  const renderSortItem = (sort, order) => {
-    return (
-      <div className="d-flex align-items-center justify-content-between w-100">
-        <span className="mr-3">{t(`search_result.sort_axis.${sort}`)}</span>
-        {renderOrderIcon(order)}
-      </div>
-    );
-  };
-
   return (
     <>
       <div className="input-group">
@@ -42,10 +33,10 @@ const SortControl: FC <Props> = (props: Props) => {
             {renderOrderIcon(props.order)}
           </div>
         </div>
-        <div className="btn-group" role="group">
+        <div className="border rounded-right">
           <button
             type="button"
-            className="btn border dropdown-toggle"
+            className="btn dropdown-toggle"
             data-toggle="dropdown"
           >
             <span className="mr-4 text-secondary">{t(`search_result.sort_axis.${props.sort}`)}</span>
@@ -56,11 +47,11 @@ const SortControl: FC <Props> = (props: Props) => {
               return (
                 <button
                   key={sortAxis}
-                  className="dropdown-item d-flex justify-content-between"
+                  className="dropdown-item"
                   type="button"
                   onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}
                 >
-                  {renderSortItem(sortAxis, nextOrder)}
+                  <span>{t(`search_result.sort_axis.${sortAxis}`)}</span>
                 </button>
               );
             })}

+ 5 - 2
packages/app/src/components/Sidebar/PageTree.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import {
-  useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser,
+  useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser, useNotFoundTargetPathOrId,
 } from '~/stores/context';
 
 import ItemsTree from './PageTree/ItemsTree';
@@ -18,6 +18,7 @@ const PageTree: FC = memo(() => {
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
+  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
 
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
@@ -25,6 +26,8 @@ const PageTree: FC = memo(() => {
   const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
   const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
 
+  const targetPathOrId = targetId || notFoundTargetPathOrId;
+
   if (migrationStatus == null) {
     return (
       <>
@@ -81,7 +84,7 @@ const PageTree: FC = memo(() => {
         <ItemsTree
           isEnableActions={!isGuestUser}
           targetPath={path}
-          targetId={targetId}
+          targetPathOrId={targetPathOrId}
           targetAndAncestorsData={targetAndAncestorsData}
           isDeleteModalOpen={isDeleteModalOpen}
           pagesToDelete={pagesToDelete}

+ 9 - 9
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -21,19 +21,19 @@ const { isTopPage } = pagePathUtils;
 interface ItemProps {
   isEnableActions: boolean
   itemNode: ItemNode
-  targetId?: string
+  targetPathOrId?: string
   isOpen?: boolean
   onClickDeleteByPage?(page: IPageForPageDeleteModal): void
 }
 
 // Utility to mark target
-const markTarget = (children: ItemNode[], targetId?: string): void => {
-  if (targetId == null) {
+const markTarget = (children: ItemNode[], targetPathOrId?: string): void => {
+  if (targetPathOrId == null) {
     return;
   }
 
   children.forEach((node) => {
-    if (node.page._id === targetId) {
+    if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
       node.page.isTarget = true;
     }
     return node;
@@ -96,7 +96,7 @@ const ItemCount: FC = () => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
-    itemNode, targetId, isOpen: _isOpen = false, onClickDeleteByPage, isEnableActions,
+    itemNode, targetPathOrId, isOpen: _isOpen = false, onClickDeleteByPage, isEnableActions,
   } = props;
 
   const { page, children } = itemNode;
@@ -162,7 +162,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
    */
   useEffect(() => {
     if (children.length > currentChildren.length) {
-      markTarget(children, targetId);
+      markTarget(children, targetPathOrId);
       setCurrentChildren(children);
     }
   }, []);
@@ -173,7 +173,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   useEffect(() => {
     if (isOpen && error == null && data != null) {
       const newChildren = ItemNode.generateNodesFromPages(data.children);
-      markTarget(newChildren, targetId);
+      markTarget(newChildren, targetPathOrId);
       setCurrentChildren(newChildren);
     }
   }, [data, isOpen]);
@@ -218,12 +218,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       )}
       {
         isOpen && hasChildren() && currentChildren.map(node => (
-          <div key={node.page._id} className="grw-pagetree-item-container mt-2">
+          <div key={node.page._id} className="grw-pagetree-item-container">
             <Item
               isEnableActions={isEnableActions}
               itemNode={node}
               isOpen={false}
-              targetId={targetId}
+              targetPathOrId={targetPathOrId}
               onClickDeleteByPage={onClickDeleteByPage}
             />
           </div>

+ 7 - 6
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -50,7 +50,7 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
 type ItemsTreeProps = {
   isEnableActions: boolean
   targetPath: string
-  targetId?: string
+  targetPathOrId?: string
   targetAndAncestorsData?: TargetAndAncestors
 
   // for deleteModal
@@ -63,13 +63,14 @@ type ItemsTreeProps = {
 }
 
 const renderByInitialNode = (
-    initialNode: ItemNode, DeleteModal: JSX.Element, isEnableActions: boolean, targetId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
+    // eslint-disable-next-line max-len
+    initialNode: ItemNode, DeleteModal: JSX.Element, isEnableActions: boolean, targetPathOrId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
 ): JSX.Element => {
   return (
     <div className="grw-pagetree p-3">
       <Item
         key={initialNode.page.path}
-        targetId={targetId}
+        targetPathOrId={targetPathOrId}
         itemNode={initialNode}
         isOpen
         isEnableActions={isEnableActions}
@@ -86,7 +87,7 @@ const renderByInitialNode = (
  */
 const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const {
-    targetPath, targetId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
+    targetPath, targetPathOrId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
     onClickDeleteByPage, isEnableActions,
   } = props;
 
@@ -114,7 +115,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetPathOrId, onClickDeleteByPage);
   }
 
   /*
@@ -122,7 +123,7 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
    */
   if (targetAndAncestorsData != null) {
     const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetId, onClickDeleteByPage);
+    return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetPathOrId, onClickDeleteByPage);
   }
 
   return null;

+ 3 - 0
packages/app/src/interfaces/indeterminate-input-elm.ts

@@ -0,0 +1,3 @@
+export interface IndeterminateInputElement extends HTMLInputElement {
+  indeterminate:boolean
+}

+ 5 - 0
packages/app/src/interfaces/page-listing-results.ts

@@ -23,6 +23,11 @@ export interface TargetAndAncestors {
 }
 
 
+export interface NotFoundTargetPathOrId {
+  notFoundTargetPathOrId: string
+}
+
+
 export interface V5MigrationStatus {
   isV5Compatible : boolean,
   migratablePagesCount: number

+ 1 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -716,7 +716,7 @@ module.exports = (crowi) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
       const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-      const migratablePagesCount = await crowi.pageService.v5MigratablePrivatePagesCount(req.user);
+      const migratablePagesCount = req.user != null ? await crowi.pageService.v5MigratablePrivatePagesCount(req.user) : null;
       return res.apiv3({ isV5Compatible, migratablePagesCount });
     }
     catch (err) {

+ 9 - 0
packages/app/src/server/routes/page.js

@@ -274,6 +274,14 @@ module.exports = function(crowi, app) {
     renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
   }
 
+  function addRenderVarsWhenNotFound(renderVars, pathOrId) {
+    if (pathOrId == null) {
+      return;
+    }
+
+    renderVars.notFoundTargetPathOrId = pathOrId;
+  }
+
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
       return '';
@@ -328,6 +336,7 @@ module.exports = function(crowi, app) {
     const offset = parseInt(req.query.offset) || 0;
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
+    addRenderVarsWhenNotFound(renderVars, pathOrId);
 
     return res.render(view, renderVars);
   }

+ 51 - 19
packages/app/src/server/service/page.js

@@ -865,23 +865,57 @@ class PageService {
     }
   }
 
+  async _isPagePathIndexUnique() {
+    const Page = this.crowi.model('Page');
+    const now = (new Date()).toString();
+    const path = `growi_check_is_path_index_unique_${now}`;
+
+    let isUnique = false;
+
+    try {
+      await Page.insertMany([
+        { path },
+        { path },
+      ]);
+    }
+    catch (err) {
+      if (err?.code === 11000) { // Error code 11000 indicates the index is unique
+        isUnique = true;
+        logger.info('Page path index is unique.');
+      }
+      else {
+        throw err;
+      }
+    }
+    finally {
+      await Page.deleteMany({ path: { $regex: new RegExp('growi_check_is_path_index_unique', 'g') } });
+    }
+
+
+    return isUnique;
+  }
+
+  // TODO: use socket to send status to the client
   async v5InitialMigration(grant) {
     // const socket = this.crowi.socketIoService.getAdminSocket();
-    const Page = this.crowi.model('Page');
-    const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
-    const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
-    const isPathIndexExists = pathIndexStatus != null;
-    const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
+
+    let isUnique;
+    try {
+      isUnique = await this._isPagePathIndexUnique();
+    }
+    catch (err) {
+      logger.error('Failed to check path index status', err);
+      throw err;
+    }
 
     // drop unique index first
-    if (isUnique || !isPathIndexExists) {
+    if (isUnique) {
       try {
-        await this._v5NormalizeIndex(isPathIndexExists);
+        await this._v5NormalizeIndex();
       }
       catch (err) {
         logger.error('V5 index normalization failed.', err);
         // socket.emit('v5IndexNormalizationFailed', { error: err.message });
-
         throw err;
       }
     }
@@ -1078,19 +1112,17 @@ class PageService {
 
   }
 
-  async _v5NormalizeIndex(isPathIndexExists) {
+  async _v5NormalizeIndex() {
     const collection = mongoose.connection.collection('pages');
 
-    if (isPathIndexExists) {
-      try {
-        // drop pages.path_1 indexes
-        await collection.dropIndex('path_1');
-        logger.info('Succeeded to drop unique indexes from pages.path.');
-      }
-      catch (err) {
-        logger.warn('Failed to drop unique indexes from pages.path.', err);
-        throw err;
-      }
+    try {
+      // drop pages.path_1 indexes
+      await collection.dropIndex('path_1');
+      logger.info('Succeeded to drop unique indexes from pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to drop unique indexes from pages.path.', err);
+      throw err;
     }
 
     try {

+ 5 - 0
packages/app/src/server/views/layout-growi/not_found.html

@@ -3,6 +3,11 @@
 {% block html_base_css %}not-found-page{% endblock %}
 
 {% block content_main_before %}
+  <div
+    id="growi-pagetree-not-found-context"
+    data-not-found-target-path-or-id="{% if notFoundTargetPathOrId %}{{notFoundTargetPathOrId|json}}{% endif %}"
+  >
+  </div>
   <div class="grw-container-convertible">
     {% include '../widget/page_alerts.html' %}
   </div>

+ 9 - 10
packages/app/src/stores/bookmark.ts

@@ -3,14 +3,13 @@ import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
-export const useSWRBookmarkInfo = (pageId: string): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWR(
-    `/bookmarks/info?pageId=${pageId}`,
-    endpoint => apiv3Get(endpoint).then((response) => {
-      return {
-        sumOfBookmarks: response.data.sumOfBookmarks,
-        isBookmarked: response.data.isBookmarked,
-      };
-    }),
-  );
+export const useSWRBookmarkInfo = (pageId: string | null): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWR(pageId != null
+    ? `/bookmarks/info?pageId=${pageId}` : null,
+  endpoint => apiv3Get(endpoint).then((response) => {
+    return {
+      sumOfBookmarks: response.data.sumOfBookmarks,
+      isBookmarked: response.data.isBookmarked,
+    };
+  }));
 };

+ 5 - 1
packages/app/src/stores/context.tsx

@@ -7,7 +7,7 @@ import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
-import { TargetAndAncestors } from '../interfaces/page-listing-results';
+import { TargetAndAncestors, NotFoundTargetPathOrId } from '../interfaces/page-listing-results';
 
 type Nullable<T> = T | null;
 
@@ -165,3 +165,7 @@ export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
 export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {
   return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData || null);
 };
+
+export const useNotFoundTargetPathOrId = (initialData?: NotFoundTargetPathOrId): SWRResponse<NotFoundTargetPathOrId, Error> => {
+  return useStaticSWR<NotFoundTargetPathOrId, Error>('notFoundTargetPathOrId', initialData || null);
+};

+ 2 - 2
packages/app/src/stores/page.tsx

@@ -46,8 +46,8 @@ export const useSWRxPageList = (
   );
 };
 
-export const useSWRPageInfo = (pageId: string): SWRResponse<IPageInfo, Error> => {
-  return useSWR(`/page/info?pageId=${pageId}`, endpoint => apiv3Get(endpoint).then((response) => {
+export const useSWRPageInfo = (pageId: string | null): SWRResponse<IPageInfo, Error> => {
+  return useSWR(pageId != null ? `/page/info?pageId=${pageId}` : null, endpoint => apiv3Get(endpoint).then((response) => {
     return {
       sumOfLikers: response.data.sumOfLikers,
       likerIds: response.data.likerIds,

+ 24 - 0
packages/app/src/stores/ui.tsx

@@ -154,6 +154,30 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
   return useStaticSWR(key);
 };
 
+export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean|null, Error> => {
+  const key: Key = isServer ? null : 'isDeviceSmallerThanLg';
+
+  const { cache, mutate } = useSWRConfig();
+
+  if (!isServer) {
+    const lgOrAvobeHandler = function(this: MediaQueryList): void {
+      // md -> lg: matches will be true
+      // lg -> md: matches will be false
+      mutate(key, !this.matches);
+    };
+    const mql = addBreakpointListener(Breakpoint.LG, lgOrAvobeHandler);
+
+    // initialize
+    if (cache.get(key) == null) {
+      document.addEventListener('DOMContentLoaded', () => {
+        mutate(key, !mql.matches);
+      });
+    }
+  }
+
+  return useStaticSWR(key);
+};
+
 export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR('preferDrawerModeByUser', initialData ?? null, { fallbackData: false });
 };

+ 0 - 3
packages/app/src/styles/_page-tree.scss

@@ -7,8 +7,6 @@ $grw-pagetree-item-padding-left: 10px;
 
   .grw-pagetree-item {
     &:hover {
-      opacity: 0.7;
-
       .grw-pagetree-control {
         display: flex !important;
       }
@@ -39,7 +37,6 @@ $grw-pagetree-item-padding-left: 10px;
 
       .grw-pagetree-title {
         overflow: hidden;
-        font-size: medium;
         text-overflow: ellipsis;
       }
     }

+ 17 - 8
packages/app/src/styles/_search.scss

@@ -250,12 +250,17 @@
     }
   }
   .search-result-content {
-    padding-bottom: 36px;
+    .search-result-content-nav {
+      min-height: $grw-subnav-search-preview-min-height;
+      overflow: auto;
+
+      .grw-subnav {
+        min-height: inherit;
+      }
+    }
 
     .search-result-page {
-      padding-top: 64px;
-      // adjust for anchor links by the height of fixed .search-page-input
-      margin-top: -64px;
+      height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width));
 
       > h2 {
         margin-right: 10px;
@@ -267,10 +272,14 @@
         margin-top: 0;
       }
 
-      .wiki {
-        padding: 16px;
-        font-size: 13px;
-        border: solid 1px $gray-300;
+      .search-result-page-content {
+        overflow-y: auto;
+
+        .wiki {
+          padding: 16px;
+          font-size: 13px;
+          border: solid 1px $gray-300;
+        }
       }
     }
   }

+ 1 - 0
packages/app/src/styles/_sidebar.scss

@@ -185,6 +185,7 @@
       width: $grw-sidebar-nav-width;
       line-height: 1em;
       border-radius: 0;
+      box-shadow: none !important;
 
       // icon opacity
       &:not(.active) {

+ 2 - 0
packages/app/src/styles/_variables.scss

@@ -21,6 +21,8 @@ $grw-subnav-min-height-md: 115px;
 $grw-subnav-height-on-edit: 95px;
 $grw-subnav-height-lg-on-edit: 50px;
 
+$grw-subnav-search-preview-min-height: 90px;
+
 $grw-navbar-bottom-height: 48px;
 $grw-editor-navbar-bottom-height: 48px;
 

+ 1 - 1
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -259,7 +259,7 @@ ul.pagination {
         background: $bgcolor-list-hover;
       }
 
-      .grw-triangle-icon {
+      .grw-pagetree-button {
         &:not(:hover) {
           svg {
             fill: $gray-500;

+ 1 - 1
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -176,7 +176,7 @@ $border-color: $border-color-global;
         background: $bgcolor-list-hover;
       }
 
-      .grw-triangle-icon {
+      .grw-pagetree-button {
         &:not(:hover) {
           svg {
             fill: $gray-400;