PageControls.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. import React, {
  2. type JSX,
  3. memo,
  4. useCallback,
  5. useEffect,
  6. useId,
  7. useMemo,
  8. useRef,
  9. } from 'react';
  10. import type {
  11. IPageInfo,
  12. IPageToDeleteWithMeta,
  13. IPageToRenameWithMeta,
  14. } from '@growi/core';
  15. import {
  16. isIPageInfoForEmpty,
  17. isIPageInfoForEntity,
  18. isIPageInfoForOperation,
  19. } from '@growi/core';
  20. import { useRect } from '@growi/ui/dist/utils';
  21. import { useTranslation } from 'next-i18next';
  22. import { DropdownItem } from 'reactstrap';
  23. import { toggleLike, toggleSubscribe } from '~/client/services/page-operation';
  24. import { toastError } from '~/client/util/toastr';
  25. import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
  26. import {
  27. useIsGuestUser,
  28. useIsReadOnlyUser,
  29. useIsSearchPage,
  30. } from '~/states/context';
  31. import { useCurrentPagePath } from '~/states/page';
  32. import { useDeviceLargerThanMd } from '~/states/ui/device';
  33. import { EditorMode, useEditorMode } from '~/states/ui/editor';
  34. import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
  35. import { useTagEditModalActions } from '~/states/ui/modal/tag-edit';
  36. import { useSetPageControlsX } from '~/states/ui/page';
  37. import loggerFactory from '~/utils/logger';
  38. import { useSWRxPageInfo, useSWRxTagsInfo } from '../../../stores/page';
  39. import { useSWRxUsersList } from '../../../stores/user';
  40. import type {
  41. AdditionalMenuItemsRendererProps,
  42. ForceHideMenuItems,
  43. } from '../Common/Dropdown/PageItemControl';
  44. import {
  45. MenuItemType,
  46. PageItemControl,
  47. } from '../Common/Dropdown/PageItemControl';
  48. import { BookmarkButtons } from './BookmarkButtons';
  49. import LikeButtons from './LikeButtons';
  50. import SearchButton from './SearchButton';
  51. import SeenUserInfo from './SeenUserInfo';
  52. import SubscribeButton from './SubscribeButton';
  53. import styles from './PageControls.module.scss';
  54. const logger = loggerFactory('growi:components/PageControls');
  55. type TagsProps = {
  56. onClickEditTagsButton: () => void;
  57. };
  58. const Tags = (props: TagsProps): JSX.Element => {
  59. const { onClickEditTagsButton } = props;
  60. const { t } = useTranslation();
  61. return (
  62. <div className="grw-tag-labels-container d-flex align-items-center">
  63. <button
  64. type="button"
  65. className="btn btn-sm btn-outline-neutral-secondary"
  66. onClick={onClickEditTagsButton}
  67. >
  68. <span className="material-symbols-outlined">local_offer</span>
  69. <span className="d-none d-sm-inline ms-1">{t('Tags')}</span>
  70. </button>
  71. </div>
  72. );
  73. };
  74. type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
  75. onClick: () => void;
  76. expandContentWidth?: boolean;
  77. };
  78. const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
  79. const { t } = useTranslation();
  80. const { onClick, expandContentWidth } = props;
  81. const wideViewId = useId();
  82. return (
  83. <DropdownItem
  84. className="grw-page-control-dropdown-item dropdown-item"
  85. onClick={onClick}
  86. toggle={false}
  87. >
  88. <div className="form-check form-switch ms-1">
  89. <input
  90. className="form-check-input pe-none"
  91. type="checkbox"
  92. id={wideViewId}
  93. checked={expandContentWidth}
  94. onChange={() => {}}
  95. />
  96. <label className="form-check-label pe-none" htmlFor={wideViewId}>
  97. {t('wide_view')}
  98. </label>
  99. </div>
  100. </DropdownItem>
  101. );
  102. };
  103. type CommonProps = {
  104. pageId?: string;
  105. shareLinkId?: string | null;
  106. revisionId?: string | null;
  107. path?: string | null;
  108. expandContentWidth?: boolean;
  109. disableSeenUserInfoPopover?: boolean;
  110. hideSubControls?: boolean;
  111. showPageControlDropdown?: boolean;
  112. forceHideMenuItems?: ForceHideMenuItems;
  113. additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
  114. onClickDuplicateMenuItem?: (
  115. pageToDuplicate: IPageForPageDuplicateModal,
  116. ) => void;
  117. onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void;
  118. onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void;
  119. onClickSwitchContentWidth?: (pageId: string, value: boolean) => void;
  120. };
  121. type PageControlsSubstanceProps = CommonProps & {
  122. pageInfo: IPageInfo | undefined;
  123. onClickEditTagsButton: () => void;
  124. };
  125. const PageControlsSubstance = (
  126. props: PageControlsSubstanceProps,
  127. ): JSX.Element => {
  128. const {
  129. pageInfo,
  130. pageId,
  131. revisionId,
  132. path,
  133. shareLinkId,
  134. expandContentWidth,
  135. disableSeenUserInfoPopover,
  136. hideSubControls,
  137. showPageControlDropdown,
  138. forceHideMenuItems,
  139. additionalMenuItemRenderer,
  140. onClickEditTagsButton,
  141. onClickDuplicateMenuItem,
  142. onClickRenameMenuItem,
  143. onClickDeleteMenuItem,
  144. onClickSwitchContentWidth,
  145. } = props;
  146. const isGuestUser = useIsGuestUser();
  147. const isReadOnlyUser = useIsReadOnlyUser();
  148. const { editorMode } = useEditorMode();
  149. const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
  150. const isSearchPage = useIsSearchPage();
  151. const currentPagePath = useCurrentPagePath();
  152. const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
  153. const likerIds = isIPageInfoForEntity(pageInfo)
  154. ? (pageInfo.likerIds ?? []).slice(0, 15)
  155. : [];
  156. const seenUserIds = isIPageInfoForEntity(pageInfo)
  157. ? (pageInfo.seenUserIds ?? []).slice(0, 15)
  158. : [];
  159. const setPageControlsX = useSetPageControlsX();
  160. const pageControlsRef = useRef<HTMLDivElement>(null);
  161. const [pageControlsRect] = useRect(pageControlsRef);
  162. useEffect(() => {
  163. if (pageControlsRect?.x == null) {
  164. return;
  165. }
  166. setPageControlsX(pageControlsRect.x);
  167. }, [pageControlsRect?.x, setPageControlsX]);
  168. // Put in a mixture of seenUserIds and likerIds data to make the cache work
  169. const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
  170. const likers =
  171. usersList != null
  172. ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15)
  173. : [];
  174. const seenUsers =
  175. usersList != null
  176. ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15)
  177. : [];
  178. const subscribeClickhandler = useCallback(async () => {
  179. if (isGuestUser) {
  180. logger.warn('Guest users cannot subscribe to pages');
  181. return;
  182. }
  183. if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
  184. logger.warn('PageInfo is not for operation or pageId is null');
  185. return;
  186. }
  187. await toggleSubscribe(pageId, pageInfo.subscriptionStatus);
  188. mutatePageInfo();
  189. }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
  190. const likeClickhandler = useCallback(async () => {
  191. if (isGuestUser) {
  192. logger.warn('Guest users cannot like pages');
  193. return;
  194. }
  195. if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
  196. logger.warn('PageInfo is not for operation or pageId is null');
  197. return;
  198. }
  199. await toggleLike(pageId, pageInfo.isLiked);
  200. mutatePageInfo();
  201. }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
  202. const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
  203. if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
  204. logger.warn(
  205. 'Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null',
  206. );
  207. return;
  208. }
  209. const page: IPageForPageDuplicateModal = { pageId, path };
  210. onClickDuplicateMenuItem(page);
  211. }, [onClickDuplicateMenuItem, pageId, path]);
  212. const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
  213. if (onClickRenameMenuItem == null || pageId == null || path == null) {
  214. logger.warn(
  215. 'Cannot rename the page because onClickRenameMenuItem, pageId or path is null',
  216. );
  217. return;
  218. }
  219. const page: IPageToRenameWithMeta = {
  220. data: {
  221. _id: pageId,
  222. revision: revisionId ?? null,
  223. path,
  224. },
  225. meta: pageInfo,
  226. };
  227. onClickRenameMenuItem(page);
  228. }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
  229. const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
  230. if (onClickDeleteMenuItem == null || pageId == null || path == null) {
  231. logger.warn(
  232. 'Cannot delete the page because onClickDeleteMenuItem, pageId or path is null',
  233. );
  234. return;
  235. }
  236. const pageToDelete: IPageToDeleteWithMeta = {
  237. data: {
  238. _id: pageId,
  239. revision: revisionId ?? null,
  240. path,
  241. },
  242. meta: pageInfo,
  243. };
  244. onClickDeleteMenuItem(pageToDelete);
  245. }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
  246. const switchContentWidthClickHandler = useCallback(() => {
  247. if (isGuestUser || isReadOnlyUser) {
  248. logger.warn('Guest or read-only users cannot switch content width');
  249. return;
  250. }
  251. if (onClickSwitchContentWidth == null || pageId == null) {
  252. logger.warn(
  253. 'Cannot switch content width because onClickSwitchContentWidth or pageId is null',
  254. );
  255. return;
  256. }
  257. if (!isIPageInfoForEntity(pageInfo)) {
  258. logger.warn('PageInfo is not for entity');
  259. return;
  260. }
  261. try {
  262. const newValue = !expandContentWidth;
  263. onClickSwitchContentWidth(pageId, newValue);
  264. } catch (err) {
  265. toastError(err);
  266. }
  267. }, [
  268. expandContentWidth,
  269. isGuestUser,
  270. isReadOnlyUser,
  271. onClickSwitchContentWidth,
  272. pageId,
  273. pageInfo,
  274. ]);
  275. const isEnableActions = useMemo(() => {
  276. if (isGuestUser) {
  277. return false;
  278. }
  279. if (currentPagePath == null) {
  280. return false;
  281. }
  282. return true;
  283. }, [currentPagePath, isGuestUser]);
  284. const additionalMenuItemOnTopRenderer = useMemo(() => {
  285. if (!isIPageInfoForEntity(pageInfo)) {
  286. return undefined;
  287. }
  288. if (onClickSwitchContentWidth == null) {
  289. return undefined;
  290. }
  291. const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
  292. return (
  293. <WideViewMenuItem
  294. {...props}
  295. onClick={switchContentWidthClickHandler}
  296. expandContentWidth={expandContentWidth}
  297. />
  298. );
  299. };
  300. return wideviewMenuItemRenderer;
  301. }, [
  302. pageInfo,
  303. expandContentWidth,
  304. onClickSwitchContentWidth,
  305. switchContentWidthClickHandler,
  306. ]);
  307. const forceHideMenuItemsWithAdditions = [
  308. ...(forceHideMenuItems ?? []),
  309. MenuItemType.BOOKMARK,
  310. MenuItemType.REVERT,
  311. ];
  312. const isViewMode = editorMode === EditorMode.View;
  313. return (
  314. <div
  315. className={`${styles['grw-page-controls']} hstack gap-2`}
  316. ref={pageControlsRef}
  317. >
  318. {isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
  319. <>
  320. <SearchButton />
  321. <OpenDefaultAiAssistantButton />
  322. </>
  323. )}
  324. {revisionId != null && !isViewMode && (
  325. <Tags onClickEditTagsButton={onClickEditTagsButton} />
  326. )}
  327. {!hideSubControls && (
  328. <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
  329. {isIPageInfoForOperation(pageInfo) && (
  330. <SubscribeButton
  331. status={pageInfo.subscriptionStatus}
  332. onClick={subscribeClickhandler}
  333. />
  334. )}
  335. {isIPageInfoForOperation(pageInfo) && (
  336. <LikeButtons
  337. onLikeClicked={likeClickhandler}
  338. sumOfLikers={pageInfo.sumOfLikers}
  339. isLiked={pageInfo.isLiked}
  340. likers={likers}
  341. />
  342. )}
  343. {(isIPageInfoForOperation(pageInfo) ||
  344. isIPageInfoForEmpty(pageInfo)) &&
  345. pageId != null && (
  346. <BookmarkButtons
  347. pageId={pageId}
  348. isBookmarked={pageInfo.isBookmarked}
  349. bookmarkCount={pageInfo.bookmarkCount}
  350. />
  351. )}
  352. {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
  353. <SeenUserInfo
  354. seenUsers={seenUsers}
  355. sumOfSeenUsers={pageInfo.sumOfSeenUsers}
  356. disabled={disableSeenUserInfoPopover}
  357. />
  358. )}
  359. </div>
  360. )}
  361. {showPageControlDropdown && (
  362. <PageItemControl
  363. pageId={pageId}
  364. pageInfo={pageInfo}
  365. isEnableActions={isEnableActions}
  366. isReadOnlyUser={!!isReadOnlyUser}
  367. forceHideMenuItems={forceHideMenuItemsWithAdditions}
  368. additionalMenuItemOnTopRenderer={
  369. !isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined
  370. }
  371. additionalMenuItemRenderer={additionalMenuItemRenderer}
  372. onClickRenameMenuItem={renameMenuItemClickHandler}
  373. onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
  374. onClickDeleteMenuItem={deleteMenuItemClickHandler}
  375. />
  376. )}
  377. </div>
  378. );
  379. };
  380. type PageControlsProps = CommonProps;
  381. export const PageControls = memo((props: PageControlsProps): JSX.Element => {
  382. const { pageId, revisionId, shareLinkId, ...rest } = props;
  383. const { data: pageInfo, error } = useSWRxPageInfo(
  384. pageId ?? null,
  385. shareLinkId,
  386. );
  387. const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
  388. const { open: openTagEditModal } = useTagEditModalActions();
  389. const onClickEditTagsButton = useCallback(() => {
  390. if (tagsInfoData == null || pageId == null || revisionId == null) {
  391. return;
  392. }
  393. openTagEditModal(tagsInfoData.tags, pageId, revisionId);
  394. }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
  395. if (error != null) {
  396. return <></>;
  397. }
  398. return (
  399. <PageControlsSubstance
  400. pageInfo={pageInfo}
  401. pageId={pageId}
  402. revisionId={revisionId}
  403. onClickEditTagsButton={onClickEditTagsButton}
  404. {...rest}
  405. />
  406. );
  407. });