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

Merge pull request #6218 from weseek/feat/integrate-implement-page-alert-component

feat Integrate implement page alert component
Yohei Shiina 3 лет назад
Родитель
Сommit
d1e5957dd4
23 измененных файлов с 290 добавлено и 189 удалено
  1. 0 9
      packages/app/src/client/app.jsx
  2. 3 19
      packages/app/src/client/services/ContextExtractor.tsx
  3. 0 3
      packages/app/src/client/services/PageContainer.js
  4. 3 4
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  5. 2 4
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  6. 3 5
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  7. 7 8
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  8. 12 11
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  9. 26 34
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  10. 11 8
      packages/app/src/components/PageAlert/FixPageGrantAlert.tsx
  11. 27 0
      packages/app/src/components/PageAlert/OldRevisionAlert.tsx
  12. 28 0
      packages/app/src/components/PageAlert/PageAlerts.tsx
  13. 53 0
      packages/app/src/components/PageAlert/PageGrantAlert.tsx
  14. 41 0
      packages/app/src/components/PageAlert/PageStaleAlert.tsx
  15. 34 44
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  16. 0 1
      packages/app/src/interfaces/global.ts
  17. 2 0
      packages/app/src/interfaces/page.ts
  18. 18 12
      packages/app/src/pages/[[...path]].page.tsx
  19. 2 1
      packages/app/src/server/service/page.ts
  20. 0 7
      packages/app/src/server/views/widget/page_alerts.html
  21. 8 16
      packages/app/src/stores/context.tsx
  22. 2 3
      packages/app/src/stores/ui.tsx
  23. 8 0
      packages/app/src/stores/xss.ts

+ 0 - 9
packages/app/src/client/app.jsx

@@ -28,10 +28,8 @@ import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationS
 import NotFoundPage from '../components/NotFoundPage';
 import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import TrashPageAlert from '../components/Page/TrashPageAlert';
 import PageComment from '../components/PageComment';
 import PageComment from '../components/PageComment';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import PageContentFooter from '../components/PageContentFooter';
 import PageContentFooter from '../components/PageContentFooter';
@@ -83,8 +81,6 @@ Object.assign(componentMappings, {
 
 
   'maintenance-mode-content': <MaintenanceModeContent />,
   'maintenance-mode-content': <MaintenanceModeContent />,
 
 
-  'trash-page-alert': <TrashPageAlert />,
-
   'trash-page-list-container': <TrashPageList />,
   'trash-page-list-container': <TrashPageList />,
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
@@ -116,11 +112,6 @@ if (pageContainer.state.pageId != null) {
 
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
   });
-  if (!pageContainer.state.isEmpty) {
-    Object.assign(componentMappings, {
-      'fix-page-grant-alert': <FixPageGrantAlert />,
-    });
-  }
 }
 }
 if (pageContainer.state.creator != null) {
 if (pageContainer.state.creator != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {

+ 3 - 19
packages/app/src/client/services/ContextExtractor.tsx

@@ -13,13 +13,13 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 
 
 import {
 import {
   useSiteUrl,
   useSiteUrl,
-  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
+  useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
-  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUser, useTargetAndAncestors,
   useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
-  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useGrowiVersion,
 } from '../../stores/context';
 } from '../../stores/context';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -60,17 +60,9 @@ const ContextExtractorOnce: FC = () => {
   const path = decodeURI(mainContent?.getAttribute('data-path') || '');
   const path = decodeURI(mainContent?.getAttribute('data-path') || '');
   // assign `null` to avoid returning empty string
   // assign `null` to avoid returning empty string
   const pageId = mainContent?.getAttribute('data-page-id') || null;
   const pageId = mainContent?.getAttribute('data-page-id') || null;
-  const emptyPageId = notFoundContext?.getAttribute('data-page-id') || null;
 
 
   const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
   const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
 
 
-  // createdAt
-  const createdAtAttribute = mainContent?.getAttribute('data-page-created-at');
-  const createdAt: Date | null = (createdAtAttribute != null) ? new Date(createdAtAttribute) : null;
-  // updatedAt
-  const updatedAtAttribute = mainContent?.getAttribute('data-page-updated-at');
-  const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
-
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
@@ -88,8 +80,6 @@ const ContextExtractorOnce: FC = () => {
   const deleteUsername = mainContent?.getAttribute('data-page-delete-username') || null;
   const deleteUsername = mainContent?.getAttribute('data-page-delete-username') || null;
   const pageIdOnHackmd = mainContent?.getAttribute('data-page-id-on-hackmd') || null;
   const pageIdOnHackmd = mainContent?.getAttribute('data-page-id-on-hackmd') || null;
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
-  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 targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
   const isSearchPage = document.getElementById('search-page') != null;
@@ -125,7 +115,6 @@ const ContextExtractorOnce: FC = () => {
   useGrowiVersion(configByContextHydrate.crowi.version);
   useGrowiVersion(configByContextHydrate.crowi.version);
 
 
   // Page
   // Page
-  useCurrentCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
   useDeleteUsername(deleteUsername);
   useDeletedAt(deletedAt);
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
   useHasChildren(hasChildren);
@@ -137,7 +126,6 @@ const ContextExtractorOnce: FC = () => {
   useIsUserPage(isUserPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useLastUpdateUsername(lastUpdateUsername);
   useCurrentPageId(pageId);
   useCurrentPageId(pageId);
-  useEmptyPageId(emptyPageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   usePageUser(pageUser);
   useCurrentPagePath(path);
   useCurrentPagePath(path);
@@ -147,12 +135,8 @@ const ContextExtractorOnce: FC = () => {
   useShareLinkId(shareLinkId);
   useShareLinkId(shareLinkId);
   useShareLinksNumber(shareLinksNumber);
   useShareLinksNumber(shareLinksNumber);
   useTemplateTagData(templateTagData);
   useTemplateTagData(templateTagData);
-  useCurrentUpdatedAt(updatedAt);
-  useCreator(creator);
-  useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useTargetAndAncestors(targetAndAncestors);
   useIsSearchPage(isSearchPage);
   useIsSearchPage(isSearchPage);
-  useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);
   useHasParent(hasParent);
 
 
   // Navigation
   // Navigation

+ 0 - 3
packages/app/src/client/services/PageContainer.js

@@ -54,9 +54,6 @@ export default class PageContainer extends Container {
       path,
       path,
       isEmpty: mainContent.getAttribute('data-page-is-empty'),
       isEmpty: mainContent.getAttribute('data-page-is-empty'),
 
 
-      createdAt: mainContent.getAttribute('data-page-created-at'),
-      // please use useCurrentUpdatedAt instead
-      updatedAt: mainContent.getAttribute('data-page-updated-at'),
       deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
       deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
 
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,

+ 3 - 4
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   FC, useCallback, useState, useMemo,
   FC, useCallback, useState, useMemo,
 } from 'react';
 } from 'react';
+
 import { TFunctionResult } from 'i18next';
 import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
@@ -8,9 +9,7 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
-
+import { useXss } from '~/stores/xss';
 /**
 /**
  * Delete User Group Select component
  * Delete User Group Select component
  *
  *
@@ -42,7 +41,7 @@ const actionForPages = {
 };
 };
 
 
 const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 const UserGroupDeleteModal: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
+  const { data: xss } = useXss();
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 

+ 2 - 4
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,11 +1,10 @@
 import React, { FC, useCallback, useState } from 'react';
 import React, { FC, useCallback, useState } from 'react';
-import { useTranslation } from 'next-i18next';
+
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'next-i18next';
 
 
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
 
 
 type Props = {
 type Props = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
@@ -15,7 +14,6 @@ type Props = {
 };
 };
 
 
 const UserGroupForm: FC<Props> = (props: Props) => {
 const UserGroupForm: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 

+ 3 - 5
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,16 +1,15 @@
 import React, {
 import React, {
   FC, useState, useEffect, useCallback,
   FC, useState, useEffect, useCallback,
 } from 'react';
 } from 'react';
+
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { useTranslation } from 'next-i18next';
-import { TFunctionResult } from 'i18next';
 
 
 import { Ref } from '~/interfaces/common';
 import { Ref } from '~/interfaces/common';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
 
 
 type Props = {
 type Props = {
   userGroup?: IUserGroupHasId,
   userGroup?: IUserGroupHasId,
@@ -21,7 +20,6 @@ type Props = {
 };
 };
 
 
 const UserGroupModal: FC<Props> = (props: Props) => {
 const UserGroupModal: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 

+ 7 - 8
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,20 +1,19 @@
 import React, { FC, useState, useCallback } from 'react';
 import React, { FC, useState, useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import UserGroupTable from './UserGroupTable';
-import UserGroupModal from './UserGroupModal';
-import UserGroupDeleteModal from './UserGroupDeleteModal';
 
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
-import Xss from '~/services/xss';
-import { CustomWindow } from '~/interfaces/global';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
-import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
+import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+import UserGroupModal from './UserGroupModal';
+import UserGroupTable from './UserGroupTable';
 
 
 const UserGroupPage: FC = () => {
 const UserGroupPage: FC = () => {
-  const xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();

+ 12 - 11
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -2,13 +2,14 @@ import React, {
   FC, useState, useCallback, useEffect,
   FC, useState, useCallback, useEffect,
 } from 'react';
 } from 'react';
 
 
-import { useTranslation } from 'next-i18next';
-import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'next-i18next';
 
 
-import Xss from '~/services/xss';
-import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import { CustomWindow } from '~/interfaces/global';
+import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
+import Xss from '~/services/xss';
+import { useXss } from '~/stores/xss';
 
 
 type Props = {
 type Props = {
   headerLabel?: TFunctionResult,
   headerLabel?: TFunctionResult,
@@ -56,7 +57,7 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 
 
 
 
 const UserGroupTable: FC<Props> = (props: Props) => {
 const UserGroupTable: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
+  const { data: xss } = useXss();
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   /*
   /*
@@ -151,17 +152,17 @@ const UserGroupTable: FC<Props> = (props: Props) => {
               <tr key={group._id}>
               <tr key={group._id}>
                 {props.isAclEnabled
                 {props.isAclEnabled
                   ? (
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a></td>
+                    <td><a href={`/admin/user-group-detail/${group._id}`}>{xss?.process(group.name)}</a></td>
                   )
                   )
                   : (
                   : (
-                    <td>{xss.process(group.name)}</td>
+                    <td>{xss?.process(group.name)}</td>
                   )
                   )
                 }
                 }
-                <td>{xss.process(group.description)}</td>
+                <td>{xss?.process(group.description)}</td>
                 <td>
                 <td>
                   <ul className="list-inline">
                   <ul className="list-inline">
                     {users != null && users.map((user) => {
                     {users != null && users.map((user) => {
-                      return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{xss.process(user.username)}</li>;
+                      return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{xss?.process(user.username)}</li>;
                     })}
                     })}
                   </ul>
                   </ul>
                 </td>
                 </td>
@@ -172,10 +173,10 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                         <li key={group._id} className="list-inline-item badge badge-success">
                         <li key={group._id} className="list-inline-item badge badge-success">
                           {props.isAclEnabled
                           {props.isAclEnabled
                             ? (
                             ? (
-                              <a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a>
+                              <a href={`/admin/user-group-detail/${group._id}`}>{xss?.process(group.name)}</a>
                             )
                             )
                             : (
                             : (
-                              <p>{xss.process(group.name)}</p>
+                              <p>{xss?.process(group.name)}</p>
                             )
                             )
                           }
                           }
                         </li>
                         </li>

+ 26 - 34
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -11,20 +11,19 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getIdForRef } from '~/interfaces/common';
 import { getIdForRef } from '~/interfaces/common';
 import {
 import {
-  IPageHasId, IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
+  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
-  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useEmptyPageId,
+  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
-import { useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
   useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
@@ -154,14 +153,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-  const { data: pageId } = useCurrentPageId();
-  const { data: emptyPageId } = useEmptyPageId();
-  const { data: revisionId } = useRevisionId();
-  const { data: path } = useCurrentPagePath();
-  const { data: creator } = useCreator();
-  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
@@ -172,8 +164,8 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(pageId);
-  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(pageId);
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(currentPage?._id);
+  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(currentPage?._id);
 
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openRenameModal } = usePageRenameModal();
@@ -195,6 +187,11 @@ const GrowiContextualSubNavigation = (props) => {
 
 
 
 
   const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
   const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
+    if (currentPage == null) {
+      return;
+    }
+
+    const { _id: pageId, revision: revisionId } = currentPage;
     try {
     try {
       const res: IResTagsUpdateApiv1 = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
       const res: IResTagsUpdateApiv1 = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
       const updatedRevisionId = getIdForRef(res.savedPage.revision);
       const updatedRevisionId = getIdForRef(res.savedPage.revision);
@@ -210,7 +207,7 @@ const GrowiContextualSubNavigation = (props) => {
       toastError(err, 'fail to update tags');
       toastError(err, 'fail to update tags');
     }
     }
 
 
-  }, [pageId, revisionId, mutateSWRTagsInfo, mutatePageTagsForEditors, pageContainer]);
+  }, [currentPage, mutateSWRTagsInfo, mutatePageTagsForEditors, pageContainer]);
 
 
   const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
   const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
     // It will not be reflected in the DB until the page is refreshed
     // It will not be reflected in the DB until the page is refreshed
@@ -262,19 +259,23 @@ const GrowiContextualSubNavigation = (props) => {
 
 
 
 
   const ControlComponents = useCallback(() => {
   const ControlComponents = useCallback(() => {
-    const pageIdForSubNavButtons = pageId ?? emptyPageId; // for SubNavButtons
+    if (currentPage == null) {
+      return <></>;
+    }
 
 
     function onPageEditorModeButtonClicked(viewType) {
     function onPageEditorModeButtonClicked(viewType) {
       mutateEditorMode(viewType);
       mutateEditorMode(viewType);
     }
     }
 
 
+    const { _id: pageId, revision, path } = currentPage;
+
     let additionalMenuItemsRenderer;
     let additionalMenuItemsRenderer;
-    if (revisionId != null) {
+    if (revision != null) {
       additionalMenuItemsRenderer = props => function additionalMenuItemsRenderer() {
       additionalMenuItemsRenderer = props => function additionalMenuItemsRenderer() {
         return (<AdditionalMenuItems
         return (<AdditionalMenuItems
           {...props}
           {...props}
           pageId={pageId}
           pageId={pageId}
-          revisionId={revisionId}
+          revisionId={revision}
           isLinkSharingDisabled={isLinkSharingDisabled}
           isLinkSharingDisabled={isLinkSharingDisabled}
           onClickTemplateMenuItem={templateMenuItemClickHandler}
           onClickTemplateMenuItem={templateMenuItemClickHandler}
         />);
         />);
@@ -283,13 +284,14 @@ const GrowiContextualSubNavigation = (props) => {
     return (
     return (
       <>
       <>
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-          { pageIdForSubNavButtons != null && isViewMode && (
+
+          { isViewMode && (
             <div className="h-50">
             <div className="h-50">
               <SubNavButtons
               <SubNavButtons
                 isCompactMode={isCompactMode}
                 isCompactMode={isCompactMode}
-                pageId={pageIdForSubNavButtons}
+                pageId={pageId}
                 shareLinkId={shareLinkId}
                 shareLinkId={shareLinkId}
-                revisionId={revisionId}
+                revisionId={revision.toString()}
                 path={path}
                 path={path}
                 disableSeenUserInfoPopover={isSharedUser}
                 disableSeenUserInfoPopover={isSharedUser}
                 showPageControlDropdown={isAbleToShowPageManagement}
                 showPageControlDropdown={isAbleToShowPageManagement}
@@ -319,27 +321,17 @@ const GrowiContextualSubNavigation = (props) => {
       </>
       </>
     );
     );
   }, [
   }, [
-    pageId, emptyPageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
+    currentPage, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
     isLinkSharingDisabled, isDeviceSmallerThanMd, isGuestUser, isSharedUser, currentUser,
     isLinkSharingDisabled, isDeviceSmallerThanMd, isGuestUser, isSharedUser, currentUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
     duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
     duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
-    path, templateMenuItemClickHandler, isPageTemplateModalShown,
+    templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
   ]);
 
 
-  if (path == null) {
+  if (currentPage == null) {
     return <></>;
     return <></>;
   }
   }
 
 
-  const currentPage: Partial<IPageHasId> = {
-    _id: pageId ?? undefined,
-    path,
-    revision: revisionId ?? undefined,
-    creator: creator ?? undefined,
-    lastUpdateUser: revisionAuthor,
-    createdAt: createdAt ?? undefined,
-    updatedAt: updatedAt ?? undefined,
-  };
-
   return (
   return (
     <GrowiSubNavigation
     <GrowiSubNavigation
       page={currentPage}
       page={currentPage}

+ 11 - 8
packages/app/src/components/Page/FixPageGrantAlert.tsx → packages/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 import React, { useEffect, useState, useCallback } from 'react';
 
 
-import { useTranslation } from 'next-i18next';
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -9,8 +9,8 @@ import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { PageGrant, IPageGrantData } from '~/interfaces/page';
 import { PageGrant, IPageGrantData } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
-import { useCurrentPageId, useCurrentUser, useHasParent } from '~/stores/context';
-import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+import { useCurrentUser } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
 
 type ModalProps = {
 type ModalProps = {
   isOpen: boolean
   isOpen: boolean
@@ -229,12 +229,13 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   );
   );
 };
 };
 
 
-const FixPageGrantAlert = (): JSX.Element => {
+export const FixPageGrantAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
-  const { data: pageId } = useCurrentPageId();
-  const { data: hasParent } = useHasParent();
+  const { data: pageData } = useSWRxCurrentPage();
+  const hasParent = pageData != null ? pageData.parent != null : false;
+  const pageId = pageData?._id;
 
 
   const [isOpen, setOpen] = useState<boolean>(false);
   const [isOpen, setOpen] = useState<boolean>(false);
 
 
@@ -242,6 +243,10 @@ const FixPageGrantAlert = (): JSX.Element => {
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
 
 
   // Dependencies
   // Dependencies
+  if (pageData == null) {
+    return <></>;
+  }
+
   if (!hasParent) {
   if (!hasParent) {
     return <></>;
     return <></>;
   }
   }
@@ -277,5 +282,3 @@ const FixPageGrantAlert = (): JSX.Element => {
     </>
     </>
   );
   );
 };
 };
-
-export default FixPageGrantAlert;

+ 27 - 0
packages/app/src/components/PageAlert/OldRevisionAlert.tsx

@@ -0,0 +1,27 @@
+import React from 'react';
+
+import Link from 'next/link';
+import { useTranslation } from 'react-i18next';
+
+import { useIsLatestRevision } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
+
+export const OldRevisionAlert = (): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { data: isLatestRevision } = useIsLatestRevision();
+  const { data: page } = useSWRxCurrentPage();
+
+  if (page == null || isLatestRevision == null || isLatestRevision) {
+    return <></>;
+  }
+
+  return (
+    <div className="alert alert-warning">
+      <strong>{ t('Warning') }: </strong> { t('page_page.notice.version') }
+      <Link href={`/${page._id}`}>
+        <a><i className="icon-fw icon-arrow-right-circle"></i>{ t('Show latest') }</a>
+      </Link>
+    </div>
+  );
+};

+ 28 - 0
packages/app/src/components/PageAlert/PageAlerts.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { FixPageGrantAlert } from './FixPageGrantAlert';
+import { OldRevisionAlert } from './OldRevisionAlert';
+import { PageGrantAlert } from './PageGrantAlert';
+import { PageStaleAlert } from './PageStaleAlert';
+
+// dynamic import because TrashPageAlert uses localStorageMiddleware
+const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
+
+export const PageAlerts = (): JSX.Element => {
+
+
+  return (
+    <div className="row d-edit-none">
+      <div className="col-sm-12">
+        {/* alerts */}
+        <FixPageGrantAlert />
+        <PageGrantAlert />
+        <TrashPageAlert />
+        <PageStaleAlert />
+        <OldRevisionAlert />
+      </div>
+    </div>
+  );
+};

+ 53 - 0
packages/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useXss } from '~/stores/xss';
+import { useTranslation } from 'react-i18next';
+
+
+export const PageGrantAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: pageData } = useSWRxCurrentPage();
+  const { data: xss } = useXss();
+
+  if ( pageData == null || pageData.grant == null || pageData.grant == 1 || xss == null) {
+    return <></>
+  }
+
+  const renderAlertContent = () => {
+    const getGrantLabel = () => {
+      if (pageData.grant == 2) {
+        return (
+          <>
+            <i className="icon-fw icon-link"></i><strong>{t('Anyone with the link')} only</strong>
+          </>
+        )
+      }
+      if (pageData.grant == 4) {
+        return (
+          <>
+            <i className="icon-fw icon-lock"></i><strong>{t('Only me')} only</strong>
+          </>
+        )
+      }
+      if (pageData.grant == 5) {
+        return (
+          <>
+            <i className="icon-fw icon-organization"></i><strong>{xss.process(pageData.grantedGroup.name)} only</strong>
+          </>
+        )
+      }
+    };
+    return (
+      <>
+        {getGrantLabel()} ({t('Browsing of this page is restricted')})
+      </>
+    );
+  };
+
+
+  return (
+    <p className="alert alert-primary py-3 px-4">
+      {renderAlertContent()}
+    </p>
+  );
+}

+ 41 - 0
packages/app/src/components/PageAlert/PageStaleAlert.tsx

@@ -0,0 +1,41 @@
+import { useIsEnabledStaleNotification } from '../../stores/context'
+import { useSWRxCurrentPage, useSWRxPageInfo } from '../../stores/page'
+import { useTranslation } from 'react-i18next';
+
+export const PageStaleAlert = ():JSX.Element => {
+  const { t } = useTranslation()
+  const { data: isEnabledStaleNotification } = useIsEnabledStaleNotification();
+
+  // Todo: determine if it should fetch or not like useSWRxPageInfo below after https://redmine.weseek.co.jp/issues/96788
+  const { data: pageData } = useSWRxCurrentPage();
+  const { data: pageInfo } = useSWRxPageInfo(isEnabledStaleNotification ? pageData?._id : null);
+
+  const contentAge = pageInfo?.contentAge;
+
+  if (!isEnabledStaleNotification) {
+    return <></>
+  }
+
+  if( pageInfo == null || contentAge == null || contentAge === 0) {
+    return <></>
+  }
+
+  let alertClass;
+  switch (contentAge) {
+    case 1:
+      alertClass = "alert-info";
+      break;
+    case 2:
+      alertClass = "alert-warning";
+      break;
+    default:
+      alertClass = "alert-danger";
+  }
+
+  return (
+    <div className={`alert ${alertClass}`}>
+      <i className="icon-fw icon-hourglass"></i>
+      <strong>{ t('page_page.notice.stale', { count: pageInfo.contentAge }) }</strong>
+    </div>
+  )
+}

+ 34 - 44
packages/app/src/components/Page/TrashPageAlert.jsx → packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -1,18 +1,17 @@
 import React from 'react';
 import React from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
 
 
-import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt, useIsTrashPage, useShareLinkId } from '~/stores/context';
+import {
+  useIsTrashPage, useShareLinkId,
+} from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
-import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+const onDeletedHandler = (pathOrPathsToDelete) => {
   if (typeof pathOrPathsToDelete !== 'string') {
   if (typeof pathOrPathsToDelete !== 'string') {
     return;
     return;
   }
   }
@@ -20,42 +19,48 @@ const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
   window.location.href = '/';
   window.location.href = '/';
 };
 };
 
 
-const TrashPageAlert = (props) => {
+export const TrashPageAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { pageContainer } = props;
-  const {
-    pageId, revisionId, path, lastUpdateUsername, deletedUserName, deletedAt,
-  } = pageContainer.state;
 
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
-
-  /*
-  * TODO: Do not use useSWRxPageInfo on this component
-  * Ideal: use useSWRxPageInfo on TrashPage after applying Next.js
-  * Reference: https://github.com/weseek/growi/pull/5359#discussion_r808381329
-  */
+  const { data: pageData } = useSWRxCurrentPage();
+  const { data: isTrashPage } = useIsTrashPage();
+  const pageId = pageData?._id;
+  const pagePath = pageData?.path;
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
 
-  const { data: updatedAt } = useCurrentUpdatedAt();
-  const { data: isTrashPage } = useIsTrashPage();
 
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
 
+  const lastUpdateUserName = pageData?.lastUpdateUser.name;
+  const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
+  const revisionId = pageData?.revision._id;
+
+  if (!isTrashPage) {
+    return <></>;
+  }
+
   function openPutbackPageModalHandler() {
   function openPutbackPageModalHandler() {
-    const putBackedHandler = (path) => {
+    if (pageId === undefined || pagePath === undefined) {
+      return;
+    }
+    const putBackedHandler = () => {
       window.location.reload();
       window.location.reload();
     };
     };
-    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
+    openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
   }
   }
 
 
   function openPageDeleteModalHandler() {
   function openPageDeleteModalHandler() {
+    if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
+      return;
+    }
     const pageToDelete = {
     const pageToDelete = {
       data: {
       data: {
         _id: pageId,
         _id: pageId,
         revision: revisionId,
         revision: revisionId,
-        path,
+        path: pagePath,
       },
       },
       meta: pageInfo,
       meta: pageInfo,
     };
     };
@@ -90,15 +95,11 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isTrashPage && (
-            <>
-              <br />
-              <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />
-              <span className="ml-2">
-                Deleted by {deletedUserName || lastUpdateUsername} at {deletedAt || updatedAt}
-              </span>
-            </>
-          )}
+          <br />
+          <UserPicture user={{ username: lastUpdateUserName }} />
+          <span className="ml-2">
+            Deleted by { lastUpdateUserName } at {deletedAt || pageData?.updatedAt}
+          </span>
         </div>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
           { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
           { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
@@ -107,14 +108,3 @@ const TrashPageAlert = (props) => {
     </>
     </>
   );
   );
 };
 };
-
-TrashPageAlert.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const TrashPageAlertWrapper = withUnstatedContainers(TrashPageAlert, [PageContainer]);
-
-export default TrashPageAlertWrapper;

+ 0 - 1
packages/app/src/interfaces/global.ts

@@ -7,7 +7,6 @@ import { IInterceptorManager } from './interceptor-manager';
 
 
 export type CustomWindow = Window
 export type CustomWindow = Window
                          & typeof globalThis
                          & typeof globalThis
-                         & { xss: Xss }
                          & { interceptorManager: IInterceptorManager }
                          & { interceptorManager: IInterceptorManager }
                          & { globalEmitter: EventEmitter }
                          & { globalEmitter: EventEmitter }
                          & { GraphViewer: IGraphViewer };
                          & { GraphViewer: IGraphViewer };

+ 2 - 0
packages/app/src/interfaces/page.ts

@@ -31,6 +31,7 @@ export interface IPage {
   hasDraftOnHackmd: boolean,
   hasDraftOnHackmd: boolean,
   deleteUser: Ref<IUser>,
   deleteUser: Ref<IUser>,
   deletedAt: Date,
   deletedAt: Date,
+  latestRevision?: Ref<IRevision>,
 }
 }
 
 
 export const PageGrant = {
 export const PageGrant = {
@@ -53,6 +54,7 @@ export type IPageInfo = {
   isDeletable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
   isAbleToDeleteCompletely: boolean,
   isRevertible: boolean,
   isRevertible: boolean,
+  contentAge?: number,
 }
 }
 
 
 export type IPageInfoForEntity = IPageInfo & {
 export type IPageInfoForEntity = IPageInfo & {

+ 18 - 12
packages/app/src/pages/[[...path]].page.tsx

@@ -10,8 +10,7 @@ import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-
-// import { PageAlerts } from '~/components/PageAlert/PageAlerts';
+import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { PageComments } from '~/components/PageComment/PageComments';
 // import { PageComments } from '~/components/PageComment/PageComments';
 // import { useTranslation } from '~/i18n';
 // import { useTranslation } from '~/i18n';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
@@ -23,7 +22,8 @@ import { IPageWithMeta } from '~/interfaces/page';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { PageModel, PageDocument } from '~/server/models/page';
 import { PageModel, PageDocument } from '~/server/models/page';
 import UserUISettings, { UserUISettingsDocument } from '~/server/models/user-ui-settings';
 import UserUISettings, { UserUISettingsDocument } from '~/server/models/user-ui-settings';
-import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import Xss from '~/services/xss';
+import { useSWRxCurrentPage, useSWRxPageInfo, useSWRxPage } from '~/stores/page';
 import {
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -43,13 +43,14 @@ import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 
 
 import {
 import {
   useCurrentUser, useCurrentPagePath,
   useCurrentUser, useCurrentPagePath,
-  useOwnerOfCurrentPage,
+  useOwnerOfCurrentPage, useIsLatestRevision,
   useIsForbidden, useIsNotFound, useIsTrashPage, useShared, useShareLinkId, useIsSharedUser, useIsAbleToDeleteCompletely,
   useIsForbidden, useIsNotFound, useIsTrashPage, useShared, useShareLinkId, useIsSharedUser, useIsAbleToDeleteCompletely,
   useAppTitle, useSiteUrl, useConfidential, useIsEnabledStaleNotification,
   useAppTitle, useSiteUrl, useConfidential, useIsEnabledStaleNotification,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsMailerSetup,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsMailerSetup,
-  useIsAclEnabled, useHasSlackConfig, useDrawioUri, useHackmdUri, useMathJax,
+  useAclEnabled, useIsAclEnabled, useHasSlackConfig, useDrawioUri, useHackmdUri, useMathJax,
   useNoCdn, useEditorConfig, useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname, useIsSlackConfigured,
   useNoCdn, useEditorConfig, useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname, useIsSlackConfigured,
 } from '../stores/context';
 } from '../stores/context';
+import { useXss } from '../stores/xss';
 
 
 import {
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
@@ -77,6 +78,7 @@ type Props = CommonProps & {
   // redirectFrom?: string;
   // redirectFrom?: string;
 
 
   // shareLinkId?: string;
   // shareLinkId?: string;
+  isLatestRevision?: boolean
 
 
   isIdenticalPathPage?: boolean,
   isIdenticalPathPage?: boolean,
   isForbidden: boolean,
   isForbidden: boolean,
@@ -99,7 +101,7 @@ type Props = CommonProps & {
   // isAllReplyShown: boolean,
   // isAllReplyShown: boolean,
   // isContainerFluid: boolean,
   // isContainerFluid: boolean,
   // editorConfig: any,
   // editorConfig: any,
-  // isEnabledStaleNotification: boolean,
+  isEnabledStaleNotification: boolean,
   // isEnabledLinebreaks: boolean,
   // isEnabledLinebreaks: boolean,
   // isEnabledLinebreaksInComments: boolean,
   // isEnabledLinebreaksInComments: boolean,
   // adminPreferredIndentSize: number,
   // adminPreferredIndentSize: number,
@@ -122,6 +124,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // commons
   // commons
   useAppTitle(props.appTitle);
   useAppTitle(props.appTitle);
   useSiteUrl(props.siteUrl);
   useSiteUrl(props.siteUrl);
+  useXss(new Xss());
   // useEditorConfig(props.editorConfig);
   // useEditorConfig(props.editorConfig);
   useConfidential(props.confidential);
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
@@ -135,6 +138,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
 
   // page
   // page
   useCurrentPagePath(props.currentPathname);
   useCurrentPagePath(props.currentPathname);
+  useIsLatestRevision(props.isLatestRevision);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   useIsForbidden(props.isForbidden);
   useIsForbidden(props.isForbidden);
   useIsNotFound(props.isNotFound);
   useIsNotFound(props.isNotFound);
@@ -143,7 +147,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useShareLinkId(props.shareLinkId);
   // useShareLinkId(props.shareLinkId);
   // useIsAbleToDeleteCompletely(props.isAbleToDeleteCompletely);
   // useIsAbleToDeleteCompletely(props.isAbleToDeleteCompletely);
   useIsSharedUser(false); // this page cann't be routed for '/share'
   useIsSharedUser(false); // this page cann't be routed for '/share'
-  // useIsEnabledStaleNotification(props.isEnabledStaleNotification);
+  useIsEnabledStaleNotification(props.isEnabledStaleNotification);
 
 
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -174,7 +178,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   }
   }
   useCurrentPageId(pageWithMeta?.data._id);
   useCurrentPageId(pageWithMeta?.data._id);
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
+  // useSWRxPage(pageWithMeta?.data._id);
   useSWRxPageInfo(pageWithMeta?.data._id, undefined, pageWithMeta?.meta); // store initial data
   useSWRxPageInfo(pageWithMeta?.data._id, undefined, pageWithMeta?.meta); // store initial data
+  useIsTrashPage(_isTrashPage(pageWithMeta?.data.path ?? ''));
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
 
 
@@ -234,8 +240,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
 
                 { !props.isIdenticalPathPage && (
                 { !props.isIdenticalPathPage && (
                   <>
                   <>
-                    {/* <PageAlerts /> */}
-                    PageAlerts<br />
+                    <PageAlerts />
                     { props.isForbidden
                     { props.isForbidden
                       ? <>ForbiddenPage</>
                       ? <>ForbiddenPage</>
                       : <DisplaySwitcher />
                       : <DisplaySwitcher />
@@ -294,6 +299,7 @@ async function getPageData(context: GetServerSidePropsContext, props: Props): Pr
   const { pageService } = crowi;
   const { pageService } = crowi;
 
 
   const { currentPathname } = props;
   const { currentPathname } = props;
+
   const pageId = getPageIdFromPathname(currentPathname);
   const pageId = getPageIdFromPathname(currentPathname);
   const isPermalink = _isPermalink(currentPathname);
   const isPermalink = _isPermalink(currentPathname);
 
 
@@ -310,10 +316,11 @@ async function getPageData(context: GetServerSidePropsContext, props: Props): Pr
   const result: IPageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const result: IPageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const page = result?.data as unknown as PageDocument;
   const page = result?.data as unknown as PageDocument;
 
 
-  // populate
+  // populate & check if the revision is latest
   if (page != null) {
   if (page != null) {
     page.initLatestRevisionField(revisionId);
     page.initLatestRevisionField(revisionId);
     await page.populateDataToShowRevision();
     await page.populateDataToShowRevision();
+    props.isLatestRevision = page.isLatestRevision();
   }
   }
 
 
   return result;
   return result;
@@ -392,7 +399,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   // props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
-  // props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
+  props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');
   // props.editorConfig = {
   // props.editorConfig = {
@@ -423,7 +430,6 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
-
   const { user } = req;
   const { user } = req;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);

+ 2 - 1
packages/app/src/server/service/page.ts

@@ -2177,7 +2177,7 @@ class PageService {
     });
     });
   }
   }
 
 
-  constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
 
 
     if (page.isEmpty) {
     if (page.isEmpty) {
@@ -2205,6 +2205,7 @@ class PageService {
       isDeletable: isMovable,
       isDeletable: isMovable,
       isAbleToDeleteCompletely: false,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       isRevertible: isTrashPage(page.path),
+      contentAge: page.getContentAge(),
     };
     };
 
 
   }
   }

+ 0 - 7
packages/app/src/server/views/widget/page_alerts.html

@@ -52,13 +52,6 @@
     </div>
     </div>
     {% endif %}
     {% endif %}
 
 
-    {% if page and not page.isLatestRevision() %}
-    <div class="alert alert-warning">
-      <strong>{{ t('Warning') }}: </strong> {{ t('page_page.notice.version') }}
-      <a href="{{ encodeURI(page.path) }}"><i class="icon-fw icon-arrow-right-circle"></i>{{ t('Show latest') }}</a>
-    </div>
-    {% endif %}
-
     {% set dmessage = req.flash('dangerMessage') %}
     {% set dmessage = req.flash('dangerMessage') %}
     {% if dmessage.length %}
     {% if dmessage.length %}
     <div class="alert alert-danger mb-4">
     <div class="alert alert-danger mb-4">

+ 8 - 16
packages/app/src/stores/context.tsx

@@ -48,18 +48,10 @@ export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nu
   return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
   return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 };
 
 
-export const useEmptyPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('emptyPageId', initialData);
-};
-
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData);
   return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData);
 };
 };
 
 
-export const useCurrentCreatedAt = (initialData?: Nullable<Date>): SWRResponse<Nullable<Date>, Error> => {
-  return useStaticSWR<Nullable<Date>, Error>('createdAt', initialData);
-};
-
 export const useCurrentUpdatedAt = (initialData?: Nullable<Date>): SWRResponse<Nullable<Date>, Error> => {
 export const useCurrentUpdatedAt = (initialData?: Nullable<Date>): SWRResponse<Nullable<Date>, Error> => {
   return useStaticSWR<Nullable<Date>, Error>('updatedAt', initialData);
   return useStaticSWR<Nullable<Date>, Error>('updatedAt', initialData);
 };
 };
@@ -136,14 +128,6 @@ export const useHasDraftOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nu
   return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData);
   return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData);
 };
 };
 
 
-export const useCreator = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('creator', initialData);
-};
-
-export const useRevisionAuthor = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData);
-};
-
 export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
 export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData);
   return useStaticSWR<Nullable<any>, Error>('isSearchPage', initialData);
 };
 };
@@ -192,6 +176,14 @@ export const useGrowiVersion = (initialData?: string): SWRResponse<string, any>
   return useStaticSWR('growiVersion', initialData);
   return useStaticSWR('growiVersion', initialData);
 };
 };
 
 
+export const useIsEnabledStaleNotification = (initialData?: boolean): SWRResponse<boolean, any> => {
+  return useStaticSWR('isEnabledStaleNotification', initialData);
+};
+
+export const useIsLatestRevision = (initialData?: boolean): SWRResponse<boolean, any> => {
+  return useStaticSWR('isLatestRevision', initialData);
+};
+
 
 
 /** **********************************************************
 /** **********************************************************
  *                     Computed contexts
  *                     Computed contexts

+ 2 - 3
packages/app/src/stores/ui.tsx

@@ -20,7 +20,7 @@ import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
-  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
+  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
   useIsNotCreatable, useIsSharedUser, useIsForbidden, useIsIdenticalPath, useCurrentUser, useIsNotFound,
   useIsNotCreatable, useIsSharedUser, useIsForbidden, useIsIdenticalPath, useCurrentUser, useIsNotFound,
 } from './context';
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
@@ -409,11 +409,10 @@ export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageManagement';
   const key = 'isAbleToShowPageManagement';
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPageId } = useCurrentPageId();
-  const { data: emptyPageId } = useEmptyPageId();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
 
 
-  const pageId = currentPageId ?? emptyPageId;
+  const pageId = currentPageId;
   const includesUndefined = [pageId, isTrashPage, isSharedUser].some(v => v === undefined);
   const includesUndefined = [pageId, isTrashPage, isSharedUser].some(v => v === undefined);
   const isPageExist = pageId != null;
   const isPageExist = pageId != null;
 
 

+ 8 - 0
packages/app/src/stores/xss.ts

@@ -0,0 +1,8 @@
+
+import { useStaticSWR } from './use-static-swr';
+import { SWRResponse } from 'swr';
+import Xss from '~/services/xss';
+
+export const useXss = (initialData?: Xss): SWRResponse<Xss, Error> => {
+  return useStaticSWR<Xss, Error>('xss', initialData);
+};