GrowiContextualSubNavigation.tsx 17 KB

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