GrowiContextualSubNavigation.tsx 18 KB

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