GrowiContextualSubNavigation.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import React, {
  2. useState, useCallback, useMemo, type JSX,
  3. } from 'react';
  4. import { isPopulated } from '@growi/core';
  5. import type {
  6. IPagePopulatedToShowRevision,
  7. IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
  8. } from '@growi/core';
  9. import { pagePathUtils } from '@growi/core/dist/utils';
  10. import { GlobalCodeMirrorEditorKey } from '@growi/editor';
  11. import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
  12. import { useAtomValue } from 'jotai';
  13. import { useTranslation } from 'next-i18next';
  14. import dynamic from 'next/dynamic';
  15. import Link from 'next/link';
  16. import { useRouter } from 'next/router';
  17. import Sticky from 'react-stickynode';
  18. import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
  19. import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
  20. import { usePrintMode } from '~/client/services/use-print-mode';
  21. import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
  22. import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
  23. import { usePageBulkExportSelectModalActions } from '~/features/page-bulk-export/client/states/modal';
  24. import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
  25. import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
  26. import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
  27. import { useCurrentPathname, useCurrentUser } from '~/states/global';
  28. import { useCurrentPageId, useFetchCurrentPage } from '~/states/page';
  29. import { useShareLinkId } from '~/states/page/hooks';
  30. import {
  31. disableLinkSharingAtom,
  32. isBulkExportPagesEnabledAtom,
  33. isLocalAccountRegistrationEnabledAtom,
  34. isUploadEnabledAtom,
  35. } from '~/states/server-configurations';
  36. import { useDeviceLargerThanMd } from '~/states/ui/device';
  37. import { useEditorMode } from '~/states/ui/editor';
  38. import { PageAccessoriesModalContents, usePageAccessoriesModalActions } from '~/states/ui/modal/page-accessories';
  39. import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
  40. import { usePageDuplicateModalActions, type IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
  41. import { usePresentationModalActions } from '~/states/ui/modal/page-presentation';
  42. import { usePageRenameModalActions } from '~/states/ui/modal/page-rename';
  43. import {
  44. useIsAbleToShowPageManagement,
  45. useIsAbleToChangeEditorMode,
  46. } from '~/states/ui/page-abilities';
  47. import {
  48. useSWRxPageInfo,
  49. } from '~/stores/page';
  50. import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
  51. import { CreateTemplateModalLazyLoaded } from '../CreateTemplateModal';
  52. import { NotAvailable } from '../NotAvailable';
  53. import { Skeleton } from '../Skeleton';
  54. import styles from './GrowiContextualSubNavigation.module.scss';
  55. import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
  56. const PageEditorModeManager = dynamic(
  57. () => import('./PageEditorModeManager').then(mod => mod.PageEditorModeManager),
  58. { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },
  59. );
  60. const PageControls = dynamic(
  61. () => import('../PageControls').then(mod => mod.PageControls),
  62. { ssr: false, loading: () => <></> },
  63. );
  64. type PageOperationMenuItemsProps = {
  65. pageId: string,
  66. revisionId: string,
  67. isLinkSharingDisabled?: boolean,
  68. }
  69. const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element => {
  70. const { t } = useTranslation();
  71. const {
  72. pageId, revisionId, isLinkSharingDisabled,
  73. } = props;
  74. const isGuestUser = useIsGuestUser();
  75. const isReadOnlyUser = useIsReadOnlyUser();
  76. const isSharedUser = useIsSharedUser();
  77. const isBulkExportPagesEnabled = useAtomValue(isBulkExportPagesEnabledAtom);
  78. const isUploadEnabled = useAtomValue(isUploadEnabledAtom);
  79. const { open: openPresentationModal } = usePresentationModalActions();
  80. const { open: openAccessoriesModal } = usePageAccessoriesModalActions();
  81. const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModalActions();
  82. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
  83. const [isBulkExportTooltipOpen, setIsBulkExportTooltipOpen] = useState(false);
  84. const syncLatestRevisionBodyHandler = useCallback(async () => {
  85. // eslint-disable-next-line no-alert
  86. const answer = window.confirm(t('sync-latest-revision-body.confirm'));
  87. if (answer) {
  88. try {
  89. const editingMarkdownLength = codeMirrorEditor?.getDoc().length;
  90. const res = await syncLatestRevisionBody(pageId, editingMarkdownLength);
  91. if (!res.synced) {
  92. toastWarning(t('sync-latest-revision-body.skipped-toaster'));
  93. return;
  94. }
  95. if (res?.isYjsDataBroken) {
  96. // eslint-disable-next-line no-alert
  97. window.alert(t('sync-latest-revision-body.alert'));
  98. return;
  99. }
  100. toastSuccess(t('sync-latest-revision-body.success-toaster'));
  101. }
  102. catch {
  103. toastError(t('sync-latest-revision-body.error-toaster'));
  104. }
  105. }
  106. }, [codeMirrorEditor, pageId, t]);
  107. return (
  108. <>
  109. <DropdownItem
  110. onClick={() => syncLatestRevisionBodyHandler()}
  111. className="grw-page-control-dropdown-item"
  112. >
  113. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
  114. {t('sync-latest-revision-body.menuitem')}
  115. </DropdownItem>
  116. {/* Presentation */}
  117. <DropdownItem
  118. onClick={() => openPresentationModal()}
  119. data-testid="open-presentation-modal-btn"
  120. className="grw-page-control-dropdown-item"
  121. >
  122. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">jamboard_kiosk</span>
  123. {t('Presentation Mode')}
  124. </DropdownItem>
  125. {/* Export markdown */}
  126. <DropdownItem
  127. onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
  128. className="grw-page-control-dropdown-item"
  129. >
  130. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
  131. {t('page_export.export_page_markdown')}
  132. </DropdownItem>
  133. {/* Bulk export */}
  134. {isBulkExportPagesEnabled && (
  135. <>
  136. <span id="bulkExportDropdownItem">
  137. <DropdownItem
  138. onClick={openPageBulkExportSelectModal}
  139. className="grw-page-control-dropdown-item"
  140. disabled={!isUploadEnabled ?? true}
  141. >
  142. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
  143. {t('page_export.bulk_export')}
  144. </DropdownItem>
  145. </span>
  146. <Tooltip
  147. placement={window.innerWidth < 800 ? 'bottom' : 'left'}
  148. isOpen={!isUploadEnabled && isBulkExportTooltipOpen}
  149. // Tooltip cannot be activated when target is disabled so set the target to wrapper span
  150. target="bulkExportDropdownItem"
  151. toggle={() => setIsBulkExportTooltipOpen(!isBulkExportTooltipOpen)}
  152. >
  153. {t('page_export.file_upload_not_configured')}
  154. </Tooltip>
  155. </>
  156. )}
  157. <DropdownItem divider />
  158. {/*
  159. TODO: show Tooltip when menu is disabled
  160. refs: PageAccessoriesModalControl
  161. */}
  162. <DropdownItem
  163. onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
  164. disabled={!!isGuestUser || !!isSharedUser}
  165. data-testid="open-page-accessories-modal-btn-with-history-tab"
  166. className="grw-page-control-dropdown-item"
  167. >
  168. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">history</span>
  169. {t('History')}
  170. </DropdownItem>
  171. <DropdownItem
  172. onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
  173. data-testid="open-page-accessories-modal-btn-with-attachment-data-tab"
  174. className="grw-page-control-dropdown-item"
  175. >
  176. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">attachment</span>
  177. {t('attachment_data')}
  178. </DropdownItem>
  179. {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
  180. <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
  181. <DropdownItem
  182. onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
  183. data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
  184. className="grw-page-control-dropdown-item"
  185. >
  186. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">share</span>
  187. {t('share_links.share_link_management')}
  188. </DropdownItem>
  189. </NotAvailable>
  190. )}
  191. </>
  192. );
  193. };
  194. type CreateTemplateMenuItemsProps = {
  195. onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
  196. }
  197. const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Element => {
  198. const { t } = useTranslation();
  199. const { onClickTemplateMenuItem } = props;
  200. const openPageTemplateModalHandler = () => {
  201. onClickTemplateMenuItem(true);
  202. };
  203. return (
  204. <>
  205. {/* Create template */}
  206. <DropdownItem
  207. onClick={openPageTemplateModalHandler}
  208. className="grw-page-control-dropdown-item"
  209. data-testid="open-page-template-modal-btn"
  210. >
  211. <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">contract_edit</span>
  212. {t('template.option_label.create/edit')}
  213. </DropdownItem>
  214. </>
  215. );
  216. };
  217. type GrowiContextualSubNavigationProps = {
  218. currentPage?: IPagePopulatedToShowRevision | null,
  219. };
  220. const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
  221. const { currentPage } = props;
  222. const { t } = useTranslation();
  223. const router = useRouter();
  224. const isPrinting = usePrintMode();
  225. const shareLinkId = useShareLinkId();
  226. const { fetchCurrentPage } = useFetchCurrentPage();
  227. const currentPathname = useCurrentPathname();
  228. const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
  229. const revision = currentPage?.revision;
  230. const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
  231. const { editorMode } = useEditorMode();
  232. const pageId = useCurrentPageId();
  233. const currentUser = useCurrentUser();
  234. const isGuestUser = useIsGuestUser();
  235. const isReadOnlyUser = useIsReadOnlyUser();
  236. const isLocalAccountRegistrationEnabled = useAtomValue(isLocalAccountRegistrationEnabledAtom);
  237. const isLinkSharingDisabled = useAtomValue(disableLinkSharingAtom);
  238. const isSharedUser = useIsSharedUser();
  239. const shouldExpandContent = useShouldExpandContent(currentPage);
  240. const isAbleToShowPageManagement = useIsAbleToShowPageManagement();
  241. const isAbleToChangeEditorMode = useIsAbleToChangeEditorMode();
  242. const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
  243. const { open: openDuplicateModal } = usePageDuplicateModalActions();
  244. const { open: openRenameModal } = usePageRenameModalActions();
  245. const { open: openDeleteModal } = usePageDeleteModalActions();
  246. const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
  247. const [isStickyActive, setStickyActive] = useState(false);
  248. const path = currentPage?.path ?? currentPathname;
  249. // const grant = currentPage?.grant ?? grantData?.grant;
  250. // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
  251. const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
  252. const duplicateItemClickedHandler = useCallback(async (page: IPageForPageDuplicateModal) => {
  253. const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
  254. router.push(toPath);
  255. };
  256. openDuplicateModal(page, { onDuplicated: duplicatedHandler });
  257. }, [openDuplicateModal, router]);
  258. const renameItemClickedHandler = useCallback(async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
  259. const renamedHandler: OnRenamedFunction = () => {
  260. fetchCurrentPage({ force: true });
  261. mutatePageInfo();
  262. mutatePageTree();
  263. mutateRecentlyUpdated();
  264. };
  265. openRenameModal(page, { onRenamed: renamedHandler });
  266. }, [fetchCurrentPage, mutatePageInfo, openRenameModal]);
  267. const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
  268. const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
  269. if (typeof pathOrPathsToDelete !== 'string') {
  270. return;
  271. }
  272. const path = pathOrPathsToDelete;
  273. if (isCompletely) {
  274. // redirect to NotFound Page
  275. router.push(path);
  276. }
  277. else if (currentPathname != null) {
  278. router.push(currentPathname);
  279. }
  280. fetchCurrentPage({ force: true });
  281. mutatePageInfo();
  282. mutatePageTree();
  283. mutateRecentlyUpdated();
  284. };
  285. openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
  286. }, [currentPathname, fetchCurrentPage, openDeleteModal, router, mutatePageInfo]);
  287. const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
  288. if (!isSharedPage) {
  289. await updateContentWidth(pageId, value);
  290. fetchCurrentPage({ force: true });
  291. }
  292. }, [isSharedPage, fetchCurrentPage]);
  293. const additionalMenuItemsRenderer = useCallback(() => {
  294. if (revisionId == null || pageId == null) {
  295. return (
  296. <>
  297. {!isReadOnlyUser
  298. && (
  299. <CreateTemplateMenuItems
  300. onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
  301. />
  302. )
  303. }
  304. </>
  305. );
  306. }
  307. return (
  308. <>
  309. <PageOperationMenuItems
  310. pageId={pageId}
  311. revisionId={revisionId}
  312. isLinkSharingDisabled={isLinkSharingDisabled}
  313. />
  314. {!isReadOnlyUser && (
  315. <>
  316. <DropdownItem divider />
  317. <CreateTemplateMenuItems
  318. onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
  319. />
  320. </>
  321. )
  322. }
  323. </>
  324. );
  325. }, [isLinkSharingDisabled, pageId, revisionId, isReadOnlyUser]);
  326. // hide sub controls when sticky on mobile device
  327. const hideSubControls = useMemo(() => {
  328. return !isDeviceLargerThanMd && isStickyActive;
  329. }, [isDeviceLargerThanMd, isStickyActive]);
  330. return (
  331. <>
  332. <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
  333. <Sticky
  334. className="z-1"
  335. enabled={!isPrinting}
  336. onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
  337. innerActiveClass="w-100 end-0"
  338. >
  339. <GroundGlassBar>
  340. <nav
  341. className={`${styles['grw-contextual-sub-navigation']}
  342. d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
  343. `}
  344. data-testid="grw-contextual-sub-nav"
  345. id="grw-contextual-sub-nav"
  346. >
  347. {pageId != null && (
  348. <PageControls
  349. pageId={pageId}
  350. revisionId={revisionId}
  351. shareLinkId={shareLinkId}
  352. path={path ?? currentPathname} // If the page is empty, "path" is undefined
  353. expandContentWidth={shouldExpandContent}
  354. disableSeenUserInfoPopover={isSharedUser}
  355. hideSubControls={hideSubControls}
  356. showPageControlDropdown={isAbleToShowPageManagement}
  357. additionalMenuItemRenderer={additionalMenuItemsRenderer}
  358. onClickDuplicateMenuItem={duplicateItemClickedHandler}
  359. onClickRenameMenuItem={renameItemClickedHandler}
  360. onClickDeleteMenuItem={deleteItemClickedHandler}
  361. onClickSwitchContentWidth={switchContentWidthHandler}
  362. />
  363. )}
  364. {isAbleToChangeEditorMode && (
  365. <PageEditorModeManager
  366. editorMode={editorMode}
  367. isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
  368. path={path}
  369. // grant={grant}
  370. // grantUserGroupId={grantUserGroupId}
  371. />
  372. )}
  373. {isGuestUser && (
  374. <div className="mt-2">
  375. <span>
  376. <span className="d-inline-block" id="sign-up-link">
  377. <Link
  378. href={!isLocalAccountRegistrationEnabled ? '#' : '/login#register'}
  379. className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
  380. style={{ pointerEvents: !isLocalAccountRegistrationEnabled ? 'none' : undefined }}
  381. prefetch={false}
  382. >
  383. <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
  384. </Link>
  385. </span>
  386. {!isLocalAccountRegistrationEnabled && (
  387. <UncontrolledTooltip target="sign-up-link" fade={false}>
  388. {t('tooltip.login_required')}
  389. </UncontrolledTooltip>
  390. )}
  391. </span>
  392. <Link href="/login#login" className="btn btn-primary" prefetch={false}>
  393. <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
  394. </Link>
  395. </div>
  396. )}
  397. </nav>
  398. </GroundGlassBar>
  399. </Sticky>
  400. {path != null && currentUser != null && !isReadOnlyUser && (
  401. <CreateTemplateModalLazyLoaded
  402. path={path}
  403. isOpen={isPageTemplateModalShown}
  404. onClose={() => setIsPageTempleteModalShown(false)}
  405. />
  406. )}
  407. </>
  408. );
  409. };
  410. export default GrowiContextualSubNavigation;