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

Merge branch 'master' into fix/search-page-scroll-for-chrome

jam411 3 лет назад
Родитель
Сommit
9cc3174f52

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

@@ -42,23 +42,9 @@ export const toggleBookmark = async(pageId: string, currentValue?: boolean): Pro
   }
 };
 
-// Utility to update body class
-const updateBodyClassByView = (expandContentWidth: boolean): void => {
-  const bodyClasses = document.body.classList;
-  const isLayoutFluid = bodyClasses.contains('growi-layout-fluid');
-
-  if (expandContentWidth && !isLayoutFluid) {
-    bodyClasses.add('growi-layout-fluid');
-  }
-  else if (isLayoutFluid) {
-    bodyClasses.remove('growi-layout-fluid');
-  }
-};
-
 export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
   try {
     await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
-    updateBodyClassByView(newValue);
   }
   catch (err) {
     toastError(err);

+ 5 - 5
packages/app/src/components/Admin/Customize/CustomizeCssSetting.tsx

@@ -21,7 +21,7 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
   const onClickSubmit = useCallback(async() => {
     try {
       await adminCustomizeContainer.updateCustomizeCss();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_css') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_css') }));
     }
     catch (err) {
       toastError(err);
@@ -32,12 +32,12 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.custom_css')}</h2>
+          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_css')}</h2>
 
           <Card className="card well my-3">
             <CardBody className="px-0 py-2">
-              { t('admin:customize_setting.write_css') }<br />
-              { t('admin:customize_setting.reflect_change') }
+              { t('admin:customize_settings.write_css') }<br />
+              { t('admin:customize_settings.reflect_change') }
             </CardBody>
           </Card>
 
@@ -50,7 +50,7 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
             />
             <p className="form-text text-muted text-right">
               <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
-              {t('admin:customize_setting.ctrl_space')}
+              {t('admin:customize_settings.ctrl_space')}
             </p>
           </div>
 

+ 22 - 22
packages/app/src/components/Admin/Customize/CustomizeFunctionSetting.tsx

@@ -25,7 +25,7 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
 
     try {
       await adminCustomizeContainer.updateCustomizeFunction();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.function') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.function') }));
     }
     catch (err) {
       toastError(err);
@@ -36,10 +36,10 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.function')}</h2>
+          <h2 className="admin-setting-header">{t('admin:customize_settings.function')}</h2>
           <Card className="card well my-3">
             <CardBody className="px-0 py-2">
-              {t('admin:customize_setting.function_desc')}
+              {t('admin:customize_settings.function_desc')}
             </CardBody>
           </Card>
 
@@ -48,13 +48,13 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-3 col-md-6 text-left">
               <CustomizeFunctionOption
                 optionId="isSavedStatesOfTabChanges"
-                label={t('admin:customize_setting.function_options.tab_switch')}
+                label={t('admin:customize_settings.function_options.tab_switch')}
                 isChecked={adminCustomizeContainer.state.isSavedStatesOfTabChanges}
                 onChecked={() => { adminCustomizeContainer.switchSavedStatesOfTabChanges() }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_setting.function_options.tab_switch_desc1')}<br />
-                  {t('admin:customize_setting.function_options.tab_switch_desc2')}
+                  {t('admin:customize_settings.function_options.tab_switch_desc1')}<br />
+                  {t('admin:customize_settings.function_options.tab_switch_desc2')}
                 </p>
               </CustomizeFunctionOption>
             </div>
@@ -63,41 +63,41 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-3 col-md-6 text-left">
               <CustomizeFunctionOption
                 optionId="isEnabledAttachTitleHeader"
-                label={t('admin:customize_setting.function_options.attach_title_header')}
+                label={t('admin:customize_settings.function_options.attach_title_header')}
                 isChecked={adminCustomizeContainer.state.isEnabledAttachTitleHeader}
                 onChecked={() => { adminCustomizeContainer.switchEnabledAttachTitleHeader() }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_setting.function_options.attach_title_header_desc')}
+                  {t('admin:customize_settings.function_options.attach_title_header_desc')}
                 </p>
               </CustomizeFunctionOption>
             </div>
           </div>
 
           <PagingSizeUncontrolledDropdown
-            label={t('admin:customize_setting.function_options.list_num_s')}
-            desc={t('admin:customize_setting.function_options.list_num_desc_s')}
+            label={t('admin:customize_settings.function_options.list_num_s')}
+            desc={t('admin:customize_settings.function_options.list_num_desc_s')}
             toggleLabel={adminCustomizeContainer.state.pageLimitationS || 20}
             dropdownItemSize={[10, 20, 50, 100]}
             onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationS}
           />
           <PagingSizeUncontrolledDropdown
-            label={t('admin:customize_setting.function_options.list_num_m')}
-            desc={t('admin:customize_setting.function_options.list_num_desc_m')}
+            label={t('admin:customize_settings.function_options.list_num_m')}
+            desc={t('admin:customize_settings.function_options.list_num_desc_m')}
             toggleLabel={adminCustomizeContainer.state.pageLimitationM || 10}
             dropdownItemSize={[5, 10, 20, 50, 100]}
             onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationM}
           />
           <PagingSizeUncontrolledDropdown
-            label={t('admin:customize_setting.function_options.list_num_l')}
-            desc={t('admin:customize_setting.function_options.list_num_desc_l')}
+            label={t('admin:customize_settings.function_options.list_num_l')}
+            desc={t('admin:customize_settings.function_options.list_num_desc_l')}
             toggleLabel={adminCustomizeContainer.state.pageLimitationL || 50}
             dropdownItemSize={[20, 50, 100, 200]}
             onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationL}
           />
           <PagingSizeUncontrolledDropdown
-            label={t('admin:customize_setting.function_options.list_num_xl')}
-            desc={t('admin:customize_setting.function_options.list_num_desc_xl')}
+            label={t('admin:customize_settings.function_options.list_num_xl')}
+            desc={t('admin:customize_settings.function_options.list_num_desc_xl')}
             toggleLabel={adminCustomizeContainer.state.pageLimitationXL || 20}
             dropdownItemSize={[5, 10, 20, 50, 100]}
             onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationXL}
@@ -107,12 +107,12 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-3 col-md-6 text-left">
               <CustomizeFunctionOption
                 optionId="isEnabledStaleNotification"
-                label={t('admin:customize_setting.function_options.stale_notification')}
+                label={t('admin:customize_settings.function_options.stale_notification')}
                 isChecked={adminCustomizeContainer.state.isEnabledStaleNotification}
                 onChecked={() => { adminCustomizeContainer.switchEnableStaleNotification() }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_setting.function_options.stale_notification_desc')}
+                  {t('admin:customize_settings.function_options.stale_notification_desc')}
                 </p>
               </CustomizeFunctionOption>
             </div>
@@ -122,12 +122,12 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-3 col-md-6 text-left">
               <CustomizeFunctionOption
                 optionId="isAllReplyShown"
-                label={t('admin:customize_setting.function_options.show_all_reply_comments')}
+                label={t('admin:customize_settings.function_options.show_all_reply_comments')}
                 isChecked={adminCustomizeContainer.state.isAllReplyShown || false}
                 onChecked={() => { adminCustomizeContainer.switchIsAllReplyShown() }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_setting.function_options.show_all_reply_comments_desc')}
+                  {t('admin:customize_settings.function_options.show_all_reply_comments_desc')}
                 </p>
               </CustomizeFunctionOption>
             </div>
@@ -137,12 +137,12 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             <div className="offset-md-3 col-md-6 text-left">
               <CustomizeFunctionOption
                 optionId="isSearchScopeChildrenAsDefault"
-                label={t('admin:customize_setting.function_options.select_search_scope_children_as_default')}
+                label={t('admin:customize_settings.function_options.select_search_scope_children_as_default')}
                 isChecked={adminCustomizeContainer.state.isSearchScopeChildrenAsDefault || false}
                 onChecked={() => { adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault() }}
               >
                 <p className="form-text text-muted">
-                  {t('admin:customize_setting.function_options.select_search_scope_children_as_default_desc')}
+                  {t('admin:customize_settings.function_options.select_search_scope_children_as_default_desc')}
                 </p>
               </CustomizeFunctionOption>
             </div>

+ 4 - 4
packages/app/src/components/Admin/Customize/CustomizeHeaderSetting.tsx

@@ -21,7 +21,7 @@ const CustomizeHeaderSetting = (props: Props): JSX.Element => {
   const onClickSubmit = useCallback(async() => {
     try {
       await adminCustomizeContainer.updateCustomizeHeader();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_header') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_header') }));
     }
     catch (err) {
       toastError(err);
@@ -32,13 +32,13 @@ const CustomizeHeaderSetting = (props: Props): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.custom_header')}</h2>
+          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_header')}</h2>
 
           <Card className="card well my-3">
             <CardBody className="px-0 py-2">
               <span
                 // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.custom_header_detail') }}
+                dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.custom_header_detail') }}
               />
             </CardBody>
           </Card>
@@ -61,7 +61,7 @@ const CustomizeHeaderSetting = (props: Props): JSX.Element => {
             />
             <p className="form-text text-muted text-right">
               <i className="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
-              {t('admin:customize_setting.ctrl_space')}
+              {t('admin:customize_settings.ctrl_space')}
             </p>
           </div>
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />

+ 4 - 4
packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.tsx

@@ -59,7 +59,7 @@ const CustomizeHighlightSetting = (props: Props): JSX.Element => {
   const onClickSubmit = useCallback(async() => {
     try {
       await adminCustomizeContainer.updateHighlightJsStyle();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.code_highlight') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.code_highlight') }));
     }
     catch (err) {
       toastError(err);
@@ -90,12 +90,12 @@ const CustomizeHighlightSetting = (props: Props): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.code_highlight')}</h2>
+          <h2 className="admin-setting-header">{t('admin:customize_settings.code_highlight')}</h2>
 
           <div className="form-group row">
             <div className="offset-md-3 col-md-6 text-left">
               <div className="my-0">
-                <label>{t('admin:customize_setting.theme')}</label>
+                <label>{t('admin:customize_settings.theme')}</label>
               </div>
               <Dropdown isOpen={isDropdownOpen} toggle={onToggleDropdown}>
                 <DropdownToggle className="text-right col-6" caret>
@@ -107,7 +107,7 @@ const CustomizeHighlightSetting = (props: Props): JSX.Element => {
               </Dropdown>
               <p className="form-text text-warning">
                 {/* eslint-disable-next-line react/no-danger */}
-                <span dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.nocdn_desc') }} />
+                <span dangerouslySetInnerHTML={{ __html: t('admin:customize_settings.nocdn_desc') }} />
               </p>
             </div>
           </div>

+ 5 - 18
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -3,37 +3,24 @@ import React, { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useSWRxLayoutSetting } from '~/stores/admin/customize';
 import { useNextThemes } from '~/stores/use-next-themes';
 
 const CustomizeLayoutSetting = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
   const { resolvedTheme } = useNextThemes();
+  const { data: layoutSetting, mutate: mutateLayoutSetting } = useSWRxLayoutSetting();
 
-  const [isContainerFluid, setIsContainerFluid] = useState(false);
+  const [isContainerFluid, setIsContainerFluid] = useState<boolean>(layoutSetting?.isContainerFluid ?? false);
   const [retrieveError, setRetrieveError] = useState<any>();
 
-  const retrieveData = useCallback(async() => {
-    try {
-      const res = await apiv3Get('/customize-setting/layout');
-      setIsContainerFluid(res.data.isContainerFluid);
-    }
-    catch (err) {
-      setRetrieveError(err);
-      toastError(err);
-    }
-  }, []);
-
-  useEffect(() => {
-    retrieveData();
-  }, [retrieveData]);
-
   const onClickSubmit = async() => {
     try {
       await apiv3Put('/customize-setting/layout', { isContainerFluid });
       toastSuccess(t('toaster.update_successed', { target: t('customize_settings.layout') }));
-      retrieveData();
+      mutateLayoutSetting();
     }
     catch (err) {
       toastError(err);

+ 9 - 9
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -59,7 +59,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
       const { customizedParams } = response.data;
       setIsDefaultLogo(customizedParams.isDefaultLogo);
       setCustomizedLogoSrc(customizedParams.customizedLogoSrc);
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_logo') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo') }));
     }
     catch (err) {
       toastError(err);
@@ -70,7 +70,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
     try {
       await apiv3Delete('/customize-setting/delete-brand-logo');
       setCustomizedLogoSrc(null);
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo') }));
     }
     catch (err) {
       toastError(err);
@@ -86,7 +86,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
       formData.append('file', croppedImage);
       const { data } = await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
       setCustomizedLogoSrc(data.attachment.filePathProxied);
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo') }));
     }
     catch (err) {
       toastError(err);
@@ -100,7 +100,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
       <div className="row">
         <div className="col-12">
           <div className="mb-5">
-            <h2 className="border-bottom my-4 admin-setting-header">{t('admin:customize_setting.custom_logo')}</h2>
+            <h2 className="border-bottom my-4 admin-setting-header">{t('admin:customize_settings.custom_logo')}</h2>
             <div className="row">
               <div className="col-md-6 col-12 mb-3 mb-md-0">
                 <h4>
@@ -115,7 +115,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       onChange={() => { setIsDefaultLogo(true) }}
                     />
                     <label className="custom-control-label" htmlFor="radioDefaultLogo">
-                      {t('admin:customize_setting.default_logo')}
+                      {t('admin:customize_settings.default_logo')}
                     </label>
                   </div>
                 </h4>
@@ -134,26 +134,26 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       onChange={() => { setIsDefaultLogo(false) }}
                     />
                     <label className="custom-control-label" htmlFor="radioUploadLogo">
-                      { t('admin:customize_setting.upload_logo') }
+                      { t('admin:customize_settings.upload_logo') }
                     </label>
                   </div>
                 </h4>
                 <div className="row mb-3">
                   <label className="col-sm-4 col-12 col-form-label text-left">
-                    { t('admin:customize_setting.current_logo') }
+                    { t('admin:customize_settings.current_logo') }
                   </label>
                   <div className="col-sm-8 col-12">
                     <p><img src={customizedLogoSrc || DEFAULT_LOGO} className="picture picture-lg " id="settingBrandLogo" width="64" /></p>
                     {(customizedLogoSrc != null) && (
                       <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
-                        { t('admin:customize_setting.delete_logo') }
+                        { t('admin:customize_settings.delete_logo') }
                       </button>
                     )}
                   </div>
                 </div>
                 <div className="row">
                   <label className="col-sm-4 col-12 col-form-label text-left">
-                    { t('admin:customize_setting.upload_new_logo') }
+                    { t('admin:customize_settings.upload_new_logo') }
                   </label>
                   <div className="col-sm-8 col-12">
                     <input type="file" onChange={onSelectFile} name="brandLogo" accept="image/*" />

+ 5 - 5
packages/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -21,7 +21,7 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
   const onClickSubmit = useCallback(async() => {
     try {
       await adminCustomizeContainer.updateCustomizeScript();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_script') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_script') }));
     }
     catch (err) {
       toastError(err);
@@ -40,11 +40,11 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
     <React.Fragment>
       <div className="row">
         <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.custom_script')}</h2>
+          <h2 className="admin-setting-header">{t('admin:customize_settings.custom_script')}</h2>
           <Card className="card well">
             <CardBody className="px-0 py-2">
-              {t('admin:customize_setting.write_java')}<br />
-              {t('admin:customize_setting.reflect_change')}
+              {t('admin:customize_settings.write_java')}<br />
+              {t('admin:customize_settings.reflect_change')}
             </CardBody>
           </Card>
 
@@ -91,7 +91,7 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
             />
             <p className="form-text text-muted text-right">
               <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
-              {t('admin:customize_setting.ctrl_space')}
+              {t('admin:customize_settings.ctrl_space')}
             </p>
           </div>
 

+ 2 - 2
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -35,7 +35,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
         });
       }
 
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.theme') }));
     }
     catch (err) {
       toastError(err);
@@ -45,7 +45,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
   return (
     <div className="row">
       <div className="col-12">
-        <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
+        <h2 className="admin-setting-header">{t('admin:customize_settings.theme')}</h2>
         <CustomizeThemeOptions onSelected={selectedHandler} currentTheme={currentTheme} />
         <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
       </div>

+ 1 - 1
packages/app/src/components/Admin/Customize/CustomizeTitle.tsx

@@ -22,7 +22,7 @@ export const CustomizeTitle: FC = () => {
       await apiv3Put('/customize-setting/customize-title', {
         customizeTitle: currentCustomizeTitle,
       });
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_title') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_title') }));
     }
     catch (err) {
       toastError(err);

+ 3 - 1
packages/app/src/components/PrivateLegacyPages.tsx

@@ -18,7 +18,7 @@ import { useCurrentUser } from '~/stores/context';
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
-import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import { usePageTreeTermManager, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import {
   useSWRxSearch,
 } from '~/stores/search';
@@ -213,6 +213,7 @@ const PrivateLegacyPages = (): JSX.Element => {
   });
 
   const { data: migrationStatus, mutate: mutateMigrationStatus } = useSWRxV5MigrationStatus();
+  const { advance: advancePt } = usePageTreeTermManager();
 
   const searchInvokedHandler = useCallback((_keyword: string) => {
     mutateMigrationStatus();
@@ -314,6 +315,7 @@ const PrivateLegacyPages = (): JSX.Element => {
         closeModal();
         mutateMigrationStatus();
         mutate();
+        advancePt();
       },
     );
   }, [data, mutate, openModal, closeModal, mutateMigrationStatus]);

+ 4 - 0
packages/app/src/interfaces/customize.ts

@@ -19,3 +19,7 @@ export type IHighlightJsCssSelectorOptions = {
     border: boolean
   }
 }
+
+export type IResLayoutSetting = {
+  isContainerFluid: boolean,
+};

+ 16 - 5
packages/app/src/pages/[[...path]].page.tsx

@@ -35,7 +35,8 @@ import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { PageModel, PageDocument } from '~/server/models/page';
 import { PageRedirectModel } from '~/server/models/page-redirect';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
-import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
+import { useSWRxLayoutSetting } from '~/stores/admin/customize';
+import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
@@ -62,12 +63,13 @@ import {
   useIsAclEnabled, useIsUserPage, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting,
 } from '../stores/context';
 
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
+import { calcIsContainerFluid } from './utils/layout';
 // import { useCurrentPageSWR } from '../stores/page';
 
 
@@ -152,7 +154,7 @@ type Props = CommonProps & {
   noCdn: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
-  // isContainerFluid: boolean,
+  isContainerFluid: boolean,
   editorConfig: EditorConfig,
   isEnabledStaleNotification: boolean,
   // isEnabledLinebreaks: boolean,
@@ -243,6 +245,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
   useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
+  const { data: layoutSetting } = useLayoutSetting({ isContainerFluid: props.isContainerFluid });
+  const { data: dataPageInfo } = useSWRxPageInfo(pageId);
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();
@@ -279,6 +283,13 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   const isTopPagePath = isTopPage(pageWithMeta?.data.path ?? '');
 
+  const isContainerFluidEachPage = dataPageInfo == null || !('expandContentWidth' in dataPageInfo)
+    ? null
+    : dataPageInfo.expandContentWidth;
+  const isContainerFluidDefault = props.isContainerFluid;
+  const isContainerFluidAdmin = layoutSetting?.isContainerFluid;
+  const isContainerFluid = calcIsContainerFluid(isContainerFluidEachPage, isContainerFluidDefault, isContainerFluidAdmin);
+
   return (
     <>
       <Head>
@@ -289,7 +300,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         */}
       </Head>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
-      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
+      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={isContainerFluid}>
         <div className="h-100 d-flex flex-column justify-content-between">
           <header className="py-0 position-relative">
             <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
@@ -496,7 +507,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
-  // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
+  props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');

+ 12 - 0
packages/app/src/pages/utils/layout.ts

@@ -0,0 +1,12 @@
+// Use `props.isContainerFluid` as default, `layoutSetting.isContainerFluid` as admin setting, `dataPageInfo.expandContentWidth` as each page's setting
+export const calcIsContainerFluid = (
+    isContainerFluidEachPage: boolean | undefined | null,
+    isContainerFluidDefault: boolean,
+    isContainerFluidAdmin: boolean | undefined,
+): boolean => {
+  const isContainerFluid = isContainerFluidEachPage == null
+    ? isContainerFluidAdmin ?? isContainerFluidDefault
+    : isContainerFluidEachPage ?? isContainerFluidAdmin ?? isContainerFluidDefault;
+
+  return isContainerFluid;
+};

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

@@ -2484,7 +2484,7 @@ class PageService {
       throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
     }
 
-    this.normalizeParentRecursivelyByPages(pages, user);
+    await this.normalizeParentRecursivelyByPages(pages, user);
 
     return;
   }

+ 27 - 0
packages/app/src/stores/admin/customize.tsx

@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IResLayoutSetting } from '~/interfaces/customize';
+
+import { useLayoutSetting } from '../context';
+
+
+export const useSWRxLayoutSetting = (fallbackData?: IResLayoutSetting): SWRResponse<IResLayoutSetting, Error> => {
+  const { mutate: mutateStatic } = useLayoutSetting();
+
+  const fetcher = useCallback(async() => {
+    const res = await apiv3Get('/customize-setting/layout');
+
+    mutateStatic(res.data);
+
+    return res.data;
+  }, [mutateStatic]);
+
+  return useSWR(
+    '/customize-setting/layout',
+    fetcher,
+    { fallbackData },
+  );
+};

+ 5 - 0
packages/app/src/stores/context.tsx

@@ -5,6 +5,7 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { SupportedActionType } from '~/interfaces/activity';
+import { IResLayoutSetting } from '~/interfaces/customize';
 import { EditorConfig } from '~/interfaces/editor-settings';
 // import { CustomWindow } from '~/interfaces/global';
 import { RendererConfig } from '~/interfaces/services/renderer';
@@ -258,6 +259,10 @@ export const useShowPageLimitationXL = (initialData?: number): SWRResponse<numbe
   return useStaticSWR('showPageLimitationXL', initialData);
 };
 
+export const useLayoutSetting = (initialData?: IResLayoutSetting): SWRResponse<IResLayoutSetting, Error> => {
+  return useStaticSWR('layoutSetting', initialData);
+};
+
 export const useCustomizeTitle = (initialData?: string): SWRResponse<string, Error> => {
   return useStaticSWR('CustomizeTitle', initialData);
 };

+ 1 - 0
packages/core/src/interfaces/page.ts

@@ -31,6 +31,7 @@ export type IPage = {
   deleteUser: Ref<IUser>,
   deletedAt: Date,
   latestRevision?: Ref<IRevision>,
+  expandContentWidth?: boolean,
 }
 
 export type IPagePopulatedToList = Omit<IPageHasId, 'lastUpdateUser'> & {