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

Merge pull request #6107 from weseek/feat/gw7817-make-content-width-of-each-page-configurable

feat: Make content width of each page configurable
Yuki Takei 3 лет назад
Родитель
Сommit
d1aa4e26bd

+ 1 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -164,6 +164,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
+  "wide_view": "Wide View",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",

+ 1 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -166,6 +166,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
+  "wide_view": "ワイドビュー",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",

+ 1 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -172,6 +172,7 @@
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
+  "wide_view": "视野开阔",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",

+ 4 - 0
packages/app/src/client/services/page-operation.ts

@@ -37,6 +37,10 @@ export const toggleBookmark = async(pageId: string, currentValue?: boolean): Pro
   }
 };
 
+export const toggleContentWidth = async(pageId: string, currentValue: boolean): Promise<void> => {
+  await apiv3Put(`/page/${pageId}/content-width`, { isContainerFluid: !currentValue });
+};
+
 export const bookmark = async(pageId: string): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });

+ 62 - 6
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -23,6 +23,7 @@ export const MenuItemType = {
   DELETE: 'delete',
   REVERT: 'revert',
   PATH_RECOVERY: 'pathRecovery',
+  SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -40,11 +41,13 @@ type CommonProps = {
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickSwitchContentWidthMenuItem?: (pageId: string, isContainerFluid?: boolean) => Promise<void>,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
   alignRight?: boolean,
+  isContainerFluid?: boolean
 }
 
 
@@ -54,16 +57,36 @@ type DropdownMenuProps = CommonProps & {
   operationProcessData?: IPageOperationProcessData,
 }
 
+// Utility to update body class
+const updateBodyClassByView = (isContainerFluid: boolean): void => {
+  const bodyClasses = document.body.classList;
+  const isLayoutFluid = bodyClasses.contains('growi-layout-fluid');
+
+  if (isContainerFluid && !isLayoutFluid) {
+    bodyClasses.add('growi-layout-fluid');
+  }
+  else if (isLayoutFluid) {
+    bodyClasses.remove('growi-layout-fluid');
+  }
+};
+
 const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
+    pageId, isLoading, pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickRevertMenuItem, onClickPathRecoveryMenuItem, onClickSwitchContentWidthMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
   } = props;
 
+  const switchContentWidthHandler = useCallback(async() => {
+    if (!isIPageInfoForOperation(pageInfo) || onClickSwitchContentWidthMenuItem == null) {
+      return;
+    }
+    await onClickSwitchContentWidthMenuItem(pageId, pageInfo.isContainerFluid);
+    updateBodyClassByView(!pageInfo.isContainerFluid);
+  }, [onClickSwitchContentWidthMenuItem, pageId, pageInfo]);
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
@@ -149,6 +172,29 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        {/* Content width switcher */}
+        { !forceHideMenuItems?.includes(MenuItemType.SWITCH_CONTENT_WIDTH) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+          <DropdownItem
+            onClick={switchContentWidthHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <div className="custom-control custom-switch ml-1">
+              <input
+                id="switchContentWidth"
+                className="custom-control-input"
+                type="checkbox"
+                checked={pageInfo.isContainerFluid}
+                onChange={() => {}}
+              />
+              <label className="custom-control-label" htmlFor="switchContentWidth">
+                { t('wide_view') }
+              </label>
+            </div>
+          </DropdownItem>
+        ) }
+        { !forceHideMenuItems?.includes(MenuItemType.SWITCH_CONTENT_WIDTH) && !pageInfo.isEmpty && <DropdownItem divider /> }
+
+
         {/* Bookmark */}
         { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem
@@ -252,9 +298,8 @@ type PageItemControlSubstanceProps = CommonProps & {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
   const {
-    pageId, pageInfo: presetPageInfo, fetchOnInit,
-    children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId, pageInfo: presetPageInfo, fetchOnInit, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
+    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem, onClickSwitchContentWidthMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
@@ -272,6 +317,16 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     }
   }, [isOpen, presetPageInfo, shouldFetch]);
 
+  const switchContentWidthMenuItemHandler = useCallback(async(_pageId: string, _isContainerFluid: boolean) => {
+    if (onClickSwitchContentWidthMenuItem != null) {
+      await onClickSwitchContentWidthMenuItem(_pageId, _isContainerFluid);
+    }
+
+    if (shouldFetch) {
+      mutatePageInfo();
+    }
+  }, [mutatePageInfo, onClickSwitchContentWidthMenuItem, shouldFetch]);
+
   // mutate after handle event
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
     if (onClickBookmarkMenuItem != null) {
@@ -329,6 +384,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         onClickRenameMenuItem={renameMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
+        onClickSwitchContentWidthMenuItem={switchContentWidthMenuItemHandler}
         onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
       />
     </Dropdown>

+ 24 - 1
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,6 +1,9 @@
 import React, { useCallback } from 'react';
 
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
+import {
+  toggleBookmark, toggleLike, toggleSubscribe, toggleContentWidth,
+} from '~/client/services/page-operation';
+import { toastError } from '~/client/util/apiNotification';
 import {
   IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
@@ -140,6 +143,25 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
+  const switchContentWidthClickHandler = useCallback(async() => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
+    try {
+      await toggleContentWidth(pageId, pageInfo.isContainerFluid);
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  if (!isIPageInfoForOperation(pageInfo)) {
+    return <></>;
+  }
 
   const {
     sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
@@ -192,6 +214,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
           onClickDeleteMenuItem={deleteMenuItemClickHandler}
+          onClickSwitchContentWidthMenuItem={switchContentWidthClickHandler}
         />
       )}
     </div>

+ 12 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -14,7 +14,7 @@ import urljoin from 'url-join';
 
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
-import { bookmark, unbookmark } from '~/client/services/page-operation';
+import { bookmark, unbookmark, toggleContentWidth } from '~/client/services/page-operation';
 import {
   IPageInfoAll, isIPageInfoForListing, isIPageInfoForEntity, IPageWithMeta, IPageInfoForListing,
 } from '~/interfaces/page';
@@ -31,6 +31,7 @@ import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 import { useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
+import { toastError } from '~/client/util/apiNotification';
 
 type Props = {
   page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
@@ -124,6 +125,15 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     await bookmarkOperation(_pageId);
   };
 
+  const switchContentWidthMenuItemClickHandler = async(_pageId: string, _isContainerFluid: boolean): Promise<void> => {
+    try {
+      await toggleContentWidth(_pageId, _isContainerFluid);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
   const duplicateMenuItemClickHandler = useCallback(() => {
     const page = {
       pageId: pageData._id,
@@ -235,6 +245,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
                   onClickDeleteMenuItem={deleteMenuItemClickHandler}
                   onClickRevertMenuItem={revertMenuItemClickHandler}
+                  onClickSwitchContentWidthMenuItem={switchContentWidthMenuItemClickHandler}
                 />
               </div>
             </div>

+ 5 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -21,7 +21,7 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 
 
 import AppContainer from '../../client/services/AppContainer';
-import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
@@ -176,6 +176,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       ? page.revision
       : page.revision._id;
 
+    const forceHideMenuItemsWithSwitchContentWidth = forceHideMenuItems ?? [];
+    forceHideMenuItemsWithSwitchContentWidth.push(MenuItemType.SWITCH_CONTENT_WIDTH);
+
     return (
       <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
         <SubNavButtons
@@ -183,7 +186,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           revisionId={revisionId}
           path={page.path}
           showPageControlDropdown={showPageControlDropdown}
-          forceHideMenuItems={forceHideMenuItems}
+          forceHideMenuItems={forceHideMenuItemsWithSwitchContentWidth}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
           isCompactMode
           onClickDuplicateMenuItem={duplicateItemClickedHandler}

+ 4 - 3
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -17,7 +17,7 @@ import { useSWRxPageInfoForList } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
 
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { ForceHideMenuItems, MenuItemType } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 
 
@@ -126,7 +126,8 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     advanceFts();
   };
 
-
+  const forceHideMenuItemsWithSwitchContent = forceHideMenuItems ?? [];
+  forceHideMenuItemsWithSwitchContent.push(MenuItemType.SWITCH_CONTENT_WIDTH);
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPages ?? pages).map((page, i) => {
@@ -138,7 +139,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             page={page}
             isEnableActions={!isGuestUser}
             isSelected={page.data._id === selectedPageId}
-            forceHideMenuItems={forceHideMenuItems}
+            forceHideMenuItems={forceHideMenuItemsWithSwitchContent}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
             onPageDuplicated={duplicatedHandler}

+ 3 - 1
packages/app/src/interfaces/page.ts

@@ -29,6 +29,7 @@ export interface IPage {
   pageIdOnHackmd: string,
   revisionHackmdSynced: Ref<IRevision>,
   hasDraftOnHackmd: boolean,
+  isContainerFluid: boolean,
   deleteUser: Ref<IUser>,
   deletedAt: Date,
 }
@@ -53,6 +54,7 @@ export type IPageInfo = {
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
   isRevertible: boolean,
+  isContainerFluid: boolean,
 }
 
 export type IPageInfoForEntity = IPageInfo & {
@@ -82,7 +84,7 @@ export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPa
 export const isIPageInfoForOperation = (pageInfo: any | undefined): pageInfo is IPageInfoForOperation => {
   return pageInfo != null
     && isIPageInfoForEntity(pageInfo)
-    && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
+    && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo || 'isContainerFluid' in pageInfo);
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 4 - 1
packages/app/src/server/models/obsolete-page.js

@@ -683,6 +683,7 @@ export const getPageSchema = (crowi) => {
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
     const grantUserGroupId = options.grantUserGroupId || null;
+    const isContainerFluid = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -704,7 +705,9 @@ export const getPageSchema = (crowi) => {
     page.creator = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
-
+    if (isContainerFluid != null) {
+      page.isContainerFluid = isContainerFluid;
+    }
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
 

+ 1 - 0
packages/app/src/server/models/page.ts

@@ -102,6 +102,7 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
+  isContainerFluid: { type: Boolean, default: false },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },

+ 19 - 1
packages/app/src/server/routes/apiv3/page.js

@@ -18,7 +18,6 @@ const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
-
 /**
  * @swagger
  *  tags:
@@ -220,6 +219,9 @@ module.exports = (crowi) => {
     subscribeStatus: [
       query('pageId').isString(),
     ],
+    contentWidth: [
+      body('isContainerFluid').isBoolean(),
+    ],
   };
 
   /**
@@ -817,5 +819,21 @@ module.exports = (crowi) => {
     }
   });
 
+
+  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, csrf,
+    validator.contentWidth, apiV3FormValidator, async(req, res) => {
+      const { pageId } = req.params;
+      const { isContainerFluid } = req.body;
+
+      try {
+        const page = await Page.updateOne({ _id: pageId }, { $set: { isContainerFluid } });
+        return res.apiv3({ page });
+      }
+      catch (err) {
+        logger.error('update-content-width-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   return router;
 };

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

@@ -246,6 +246,7 @@ class PageService {
           isDeletable: false,
           isAbleToDeleteCompletely: false,
           isRevertible: false,
+          isContainerFluid: page.isContainerFluid,
         },
       };
     }
@@ -272,6 +273,9 @@ class PageService {
     const isLiked: boolean = page.isLiked(user);
 
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
+    const isContainerFluid: boolean = page.isContainerFluid != null
+      ? page.isContainerFluid
+      : this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     let creatorId = page.creator;
     if (page.isEmpty) {
@@ -292,6 +296,7 @@ class PageService {
         isBookmarked,
         isLiked,
         subscriptionStatus: subscription?.status,
+        isContainerFluid,
       },
     };
   }
@@ -2168,6 +2173,7 @@ class PageService {
         isDeletable: false,
         isAbleToDeleteCompletely: false,
         isRevertible: false,
+        isContainerFluid: false,
       };
     }
 
@@ -2185,6 +2191,7 @@ class PageService {
       isDeletable: isMovable,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
+      isContainerFluid: page.isContainerFluid,
     };
 
   }
@@ -3354,6 +3361,8 @@ class PageService {
   async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
+    const isContainerFluid = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
@@ -3400,7 +3409,9 @@ class PageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
     }
-
+    if (isContainerFluid != null) {
+      page.isContainerFluid = isContainerFluid;
+    }
     // Save
     let savedPage = await page.save();
 

+ 1 - 1
packages/app/src/server/views/layout/layout.html

@@ -61,7 +61,7 @@
 {% block html_body %}
 {% set additionalBodyClasses = []; %}
 {% block html_additional_body_classes %}{% endblock %}
-{% if getConfig('crowi', 'customize:isContainerFluid') %}
+{% if getConfig('crowi', 'customize:isContainerFluid') || page.isContainerFluid %}
   {% set additionalBodyClasses = additionalBodyClasses|push('growi-layout-fluid') %}
 {% endif %}
 <body