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

WIP: refactor management for global state with jotai and hydration

Yuki Takei 8 месяцев назад
Родитель
Сommit
e7cd290b47

+ 194 - 251
apps/app/src/pages/[[...path]].page.tsx

@@ -14,7 +14,6 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import type {
 import type {
   GetServerSideProps, GetServerSidePropsContext,
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
-import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -23,36 +22,26 @@ import superjson from 'superjson';
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { PageView } from '~/components/PageView/PageView';
 import { PageView } from '~/components/PageView/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
-import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
-import type { IUserUISettings } from '~/interfaces/user-ui-settings';
-import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import { useEditorModeClassName } from '~/services/layout/use-editor-mode-class-name';
 import { useEditorModeClassName } from '~/services/layout/use-editor-mode-class-name';
-import { useHydratePageAtoms } from '~/states/hydrate/page';
 import { useHydrateSidebarAtoms } from '~/states/hydrate/sidebar';
 import { useHydrateSidebarAtoms } from '~/states/hydrate/sidebar';
 import {
 import {
-  useCurrentPageData, useFetchCurrentPage, useCurrentPageId, useCurrentPagePath,
+  useCurrentPageData, useFetchCurrentPage, useCurrentPageId, useCurrentPagePath, usePageNotFound,
 } from '~/states/page';
 } from '~/states/page';
+import { useHydratePageAtoms } from '~/states/page/hydrate';
+import { ServerConfigurationInitialProps, useHydrateServerConfigurationAtoms } from '~/states/server-configurations/hydrate';
 import {
 import {
-  useIsForbidden, useIsSharedUser,
-  useIsEnabledStaleNotification, useIsIdenticalPath,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
-  useDefaultIndentSize, useIsIndentSizeForced,
-  useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
-  useIsSearchScopeChildrenAsDefault, useIsEnabledMarp,
-  useIsSlackConfigured, useRendererConfig,
-  useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
-  useIsUploadAllFileAllowed, useIsUploadEnabled, useIsBulkExportPagesEnabled,
-  useElasticsearchMaxBodyLengthToIndex,
-  useIsLocalAccountRegistrationEnabled,
-  useIsRomUserAllowedToComment,
-  useIsPdfBulkExportEnabled,
-  useIsAiEnabled, useLimitLearnablePageCountPerAssistant, useIsUsersHomepageDeletionEnabled,
+  useDisableLinkSharing,
+  useRendererConfig,
+} from '~/states/server-configurations/server-configurations';
+import {
+  useIsSharedUser,
+  useIsSearchPage,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRedirectFrom } from '~/stores/page-redirect';
@@ -61,12 +50,16 @@ import { useCurrentPageYjsData, useSWRMUTxCurrentPageYjsData } from '~/stores/yj
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { NextPageWithLayout } from './_app.page';
 import type { NextPageWithLayout } from './_app.page';
-import type { CommonProps, PageTitleCustomizationProps } from './utils/commons';
 import {
 import {
-  getServerSidePageTitleCustomizationProps,
-  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, skipSSR, addActivity,
+  CommonEachProps, CommonInitialProps, getServerSideCommonInitialProps,
 } from './utils/commons';
 } from './utils/commons';
-import { detectNextjsRoutingType } from './utils/nextjs-routing-utils';
+
+import { NextjsRoutingType, detectNextjsRoutingType } from './utils/nextjs-routing-utils';
+import type { UserUISettingsProps } from './utils/user-ui-settings';
+import { PageTitleCustomizationProps, generateCustomTitleForPage, getServerSidePageTitleCustomizationProps } from './utils/page-title-customization';
+import { SSRProps, getServerSideSSRProps } from './utils/ssr';
+import { addActivity } from './utils/activity';
+import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 
 
 
 
 declare global {
 declare global {
@@ -143,25 +136,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   );
   );
 };
 };
 
 
-type Props = CommonProps & PageTitleCustomizationProps & {
+type InitialProps = CommonInitialProps & SSRProps & UserUISettingsProps & {
   pageWithMeta: IPageToShowRevisionWithMeta | null,
   pageWithMeta: IPageToShowRevisionWithMeta | null,
-  // pageUser?: any,
-  redirectDestination?: string,
-  redirectFrom?: string;
-
-  // shareLinkId?: string;
-  isLatestRevision?: boolean,
-
-  isIdenticalPathPage?: boolean,
-  isForbidden: boolean,
-  isNotFound: boolean,
-  isNotCreatable: boolean,
-  // isAbleToDeleteCompletely: boolean,
 
 
-  templateTagData?: string[],
-  templateBodyData?: string,
-
-  isLocalAccountRegistrationEnabled: boolean,
+  sidebarConfig: ISidebarConfig,
+  rendererConfig: RendererConfig,
 
 
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
@@ -171,17 +150,12 @@ type Props = CommonProps & PageTitleCustomizationProps & {
 
 
   isRomUserAllowedToComment: boolean,
   isRomUserAllowedToComment: boolean,
 
 
-  sidebarConfig: ISidebarConfig,
-  userUISettings: IUserUISettings,
-
   isSlackConfigured: boolean,
   isSlackConfigured: boolean,
-  // isMailerSetup: boolean,
   isAclEnabled: boolean,
   isAclEnabled: boolean,
-  // hasSlackConfig: boolean,
   drawioUri: string | null,
   drawioUri: string | null,
-  // highlightJsStyle: string,
   isAllReplyShown: boolean,
   isAllReplyShown: boolean,
   showPageSideAuthors: boolean,
   showPageSideAuthors: boolean,
+
   isContainerFluid: boolean,
   isContainerFluid: boolean,
   isUploadEnabled: boolean,
   isUploadEnabled: boolean,
   isUploadAllFileAllowed: boolean,
   isUploadAllFileAllowed: boolean,
@@ -189,21 +163,32 @@ type Props = CommonProps & PageTitleCustomizationProps & {
   isPdfBulkExportEnabled: boolean,
   isPdfBulkExportEnabled: boolean,
   isEnabledStaleNotification: boolean,
   isEnabledStaleNotification: boolean,
   isEnabledAttachTitleHeader: boolean,
   isEnabledAttachTitleHeader: boolean,
-  // isEnabledLinebreaks: boolean,
-  // isEnabledLinebreaksInComments: boolean,
+  isUsersHomepageDeletionEnabled: boolean,
+  isLocalAccountRegistrationEnabled: boolean,
+
   adminPreferredIndentSize: number,
   adminPreferredIndentSize: number,
   isIndentSizeForced: boolean,
   isIndentSizeForced: boolean,
   disableLinkSharing: boolean,
   disableLinkSharing: boolean,
-  skipSSR: boolean,
-  ssrMaxRevisionBodyLength: number,
-
-  yjsData: CurrentPageYjsData,
-
-  rendererConfig: RendererConfig,
 
 
   aiEnabled: boolean,
   aiEnabled: boolean,
   limitLearnablePageCountPerAssistant: number,
   limitLearnablePageCountPerAssistant: number,
-  isUsersHomepageDeletionEnabled: boolean,
+}
+
+type SameRouteEachProps = CommonEachProps & PageTitleCustomizationProps & {
+  redirectFrom?: string;
+
+  isIdenticalPathPage?: boolean,
+  isForbidden: boolean,
+  isNotCreatable: boolean,
+
+  templateTagData?: string[],
+  templateBodyData?: string,
+}
+
+type Props = SameRouteEachProps | (InitialProps & SameRouteEachProps);
+
+const isInitialProps = (props: Props): props is (InitialProps & SameRouteEachProps) => {
+  return props.nextjsRoutingPage === NextjsRoutingType.INITIAL;
 };
 };
 
 
 const Page: NextPageWithLayout<Props> = (props: Props) => {
 const Page: NextPageWithLayout<Props> = (props: Props) => {
@@ -214,79 +199,40 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
 
   const router = useRouter();
   const router = useRouter();
 
 
-  // page
-  useIsContainerFluid(props.isContainerFluid);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
-  useIsForbidden(props.isForbidden);
-  useIsNotCreatable(props.isNotCreatable);
   useRedirectFrom(props.redirectFrom ?? null);
   useRedirectFrom(props.redirectFrom ?? null);
   useIsSharedUser(false); // this page cann't be routed for '/share'
   useIsSharedUser(false); // this page cann't be routed for '/share'
-  useIsIdenticalPath(props.isIdenticalPathPage ?? false);
-  useIsEnabledStaleNotification(props.isEnabledStaleNotification);
   useIsSearchPage(false);
   useIsSearchPage(false);
 
 
-  useIsEnabledAttachTitleHeader(props.isEnabledAttachTitleHeader);
-  useIsSearchServiceConfigured(props.isSearchServiceConfigured);
-  useIsSearchServiceReachable(props.isSearchServiceReachable);
-  useElasticsearchMaxBodyLengthToIndex(props.elasticsearchMaxBodyLengthToIndex);
-  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
-
-  useIsSlackConfigured(props.isSlackConfigured);
-  // useIsMailerSetup(props.isMailerSetup);
-  useIsAclEnabled(props.isAclEnabled);
-  // useHasSlackConfig(props.hasSlackConfig);
-  useDefaultIndentSize(props.adminPreferredIndentSize);
-  useIsIndentSizeForced(props.isIndentSizeForced);
-  useDisableLinkSharing(props.disableLinkSharing);
-  useRendererConfig(props.rendererConfig);
-  useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
-  // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
-  // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
-  useIsAllReplyShown(props.isAllReplyShown);
-  useShowPageSideAuthors(props.showPageSideAuthors);
-
-  useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
-  useIsUploadEnabled(props.isUploadEnabled);
-  useIsBulkExportPagesEnabled(props.isBulkExportPagesEnabled);
-  useIsPdfBulkExportEnabled(props.isPdfBulkExportEnabled);
-
-  useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
-  useIsRomUserAllowedToComment(props.isRomUserAllowedToComment);
-
-  useIsAiEnabled(props.aiEnabled);
-  useLimitLearnablePageCountPerAssistant(props.limitLearnablePageCountPerAssistant);
-
-  useIsUsersHomepageDeletionEnabled(props.isUsersHomepageDeletionEnabled);
-
-
-  const { pageWithMeta } = props;
-
-  const pageId = pageWithMeta?.data._id;
-  const revisionId = pageWithMeta?.data.revision?._id;
-  const revisionBody = pageWithMeta?.data.revision?.body;
+  // Initialize server configuration atoms with props data
+  if (isInitialProps(props)) {
+    // Initialize Jotai atoms with initial data
+    useHydratePageAtoms(props.pageWithMeta?.data);
+    useHydrateServerConfigurationAtoms(props);
+  }
 
 
-  // Initialize Jotai atoms with initial data
-  useHydratePageAtoms(pageWithMeta?.data);
+  const [currentPage] = useCurrentPageData();
+  const [pageId, setCurrentPageId] = useCurrentPageId();
+  const [currentPagePath] = useCurrentPagePath();
+  const [isNotFound] = usePageNotFound();
+  const [rendererConfig] = useRendererConfig();
+  const [disableLinkSharing] = useDisableLinkSharing();
 
 
   const { fetchCurrentPage } = useFetchCurrentPage();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const { trigger: mutateCurrentPageYjsDataFromApi } = useSWRMUTxCurrentPageYjsData();
   const { trigger: mutateCurrentPageYjsDataFromApi } = useSWRMUTxCurrentPageYjsData();
 
 
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const [currentPageId, setCurrentPageId] = useCurrentPageId();
-  const [currentPagePath] = useCurrentPagePath();
-
-  const { mutate: mutateCurrentPageYjsData } = useCurrentPageYjsData();
 
 
   useSetupGlobalSocket();
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
   useSetupGlobalSocketForPage(pageId);
 
 
   // Store initial data (When revisionBody is not SSR)
   // Store initial data (When revisionBody is not SSR)
   useEffect(() => {
   useEffect(() => {
-    if (!props.skipSSR) {
+    if (isInitialProps(props) && !props.skipSSR) {
       return;
       return;
     }
     }
 
 
-    if (pageId != null && revisionId != null && !props.isNotFound) {
+    if (pageId != null && currentPage?.revision?._id != null && !isNotFound) {
       const mutatePageData = async() => {
       const mutatePageData = async() => {
         setCurrentPageId(pageId);
         setCurrentPageId(pageId);
         const pageData = await fetchCurrentPage();
         const pageData = await fetchCurrentPage();
@@ -297,14 +243,14 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       // Because pageWIthMeta does not contain revision.body
       // Because pageWIthMeta does not contain revision.body
       mutatePageData();
       mutatePageData();
     }
     }
-  }, [revisionId, currentPageId, mutateEditingMarkdown, props.isNotFound, props.skipSSR, fetchCurrentPage, pageId, setCurrentPageId]);
+  }, [pageId, currentPage?.revision?._id, isNotFound]);
 
 
   // Load current yjs data
   // Load current yjs data
   useEffect(() => {
   useEffect(() => {
-    if (currentPageId != null && revisionId != null && !props.isNotFound) {
+    if (pageId != null && currentPage?.revision?._id != null && !isNotFound) {
       mutateCurrentPageYjsDataFromApi();
       mutateCurrentPageYjsDataFromApi();
     }
     }
-  }, [currentPageId, mutateCurrentPageYjsDataFromApi, props.isNotFound, revisionId]);
+  }, [currentPage, mutateCurrentPageYjsDataFromApi, isNotFound, currentPage?.revision?._id]);
 
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
   useEffect(() => {
@@ -319,13 +265,13 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // need to include useCurrentPathname not useCurrentPagePath
   // need to include useCurrentPathname not useCurrentPagePath
   useEffect(() => {
   useEffect(() => {
     if (props.currentPathname != null) {
     if (props.currentPathname != null) {
-      mutateEditingMarkdown(revisionBody);
+      mutateEditingMarkdown(currentPage?.revision?.body);
     }
     }
-  }, [mutateEditingMarkdown, revisionBody, props.currentPathname]);
+  }, [mutateEditingMarkdown, currentPage?.revision?.body, props.currentPathname]);
 
 
-  useEffect(() => {
-    mutateCurrentPageYjsData(props.yjsData);
-  }, [mutateCurrentPageYjsData, props.yjsData]);
+  // useEffect(() => {
+  //   mutateCurrentPageYjsData(props.yjsData);
+  // }, [mutateCurrentPageYjsData, props.yjsData]);
 
 
   // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed
   // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed
   // So preferentially take page data from useSWRxCurrentPage
   // So preferentially take page data from useSWRxCurrentPage
@@ -341,12 +287,12 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       </Head>
       </Head>
       <div className="dynamic-layout-root justify-content-between">
       <div className="dynamic-layout-root justify-content-between">
 
 
-        <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
+        <GrowiContextualSubNavigation isLinkSharingDisabled={disableLinkSharing} />
 
 
         <PageView
         <PageView
           className="d-edit-none"
           className="d-edit-none"
           pagePath={pagePath}
           pagePath={pagePath}
-          rendererConfig={props.rendererConfig}
+          rendererConfig={rendererConfig}
         />
         />
 
 
         <EditablePageEffects />
         <EditablePageEffects />
@@ -369,17 +315,23 @@ type LayoutProps = Props & {
 }
 }
 
 
 const Layout = ({ children, ...props }: LayoutProps): JSX.Element => {
 const Layout = ({ children, ...props }: LayoutProps): JSX.Element => {
-  // Hydrate sidebar atoms with server-side data
-  useHydrateSidebarAtoms(props.sidebarConfig, props.userUISettings);
-
+  if (isInitialProps(props)) {
+    // Hydrate sidebar atoms with server-side data
+    useHydrateSidebarAtoms(props.sidebarConfig, props.userUISettings);
+  }
   return <BasicLayoutWithEditor>{children}</BasicLayoutWithEditor>;
   return <BasicLayoutWithEditor>{children}</BasicLayoutWithEditor>;
 };
 };
 
 
+let drawioUri = '';
 Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
 Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
+  if (isInitialProps(page.props)) {
+    drawioUri = page.props.rendererConfig.drawioUri;
+  }
+
   return (
   return (
     <>
     <>
       <GrowiPluginsActivator />
       <GrowiPluginsActivator />
-      <DrawioViewerScript drawioUri={page.props.rendererConfig.drawioUri} />
+      <DrawioViewerScript drawioUri={drawioUri} />
 
 
       <Layout {...page.props}>
       <Layout {...page.props}>
         {page}
         {page}
@@ -517,21 +469,7 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
   }
   }
 }
 }
 
 
-// async function injectPageUserInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
-//   const req: CrowiRequest = context.req as CrowiRequest;
-//   const { crowi } = req;
-//   const UserModel = crowi.model('User');
-
-//   if (isUserPage(props.currentPagePath)) {
-//     const user = await UserModel.findUserByUsername(UserModel.getUsernameByPath(props.currentPagePath));
-
-//     if (user != null) {
-//       props.pageUser = JSON.stringify(user.toObject());
-//     }
-//   }
-// }
-
-function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
+const getServerSideConfigurationProps: GetServerSideProps<ServerConfigurationInitialProps> = async (context: GetServerSidePropsContext) => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const { crowi } = req;
   const {
   const {
@@ -539,79 +477,65 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     slackIntegrationService, passportService,
     slackIntegrationService, passportService,
   } = crowi;
   } = crowi;
 
 
-  props.aiEnabled = configManager.getConfig('app:aiEnabled');
-  props.limitLearnablePageCountPerAssistant = configManager.getConfig('openai:limitLearnablePageCountPerAssistant');
-  props.isUsersHomepageDeletionEnabled = configManager.getConfig('security:user-homepage-deletion:isEnabled');
-  props.isSearchServiceConfigured = searchService.isConfigured;
-  props.isSearchServiceReachable = searchService.isReachable;
-  props.isSearchScopeChildrenAsDefault = configManager.getConfig('customize:isSearchScopeChildrenAsDefault');
-  props.elasticsearchMaxBodyLengthToIndex = configManager.getConfig('app:elasticsearchMaxBodyLengthToIndex');
-
-  props.isRomUserAllowedToComment = configManager.getConfig('security:isRomUserAllowedToComment');
-
-  props.isSlackConfigured = slackIntegrationService.isSlackConfigured;
-  // props.isMailerSetup = mailService.isMailerSetup;
-  props.isAclEnabled = aclService.isAclEnabled();
-  // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
-  props.drawioUri = configManager.getConfig('app:drawioUri');
-  // props.highlightJsStyle = configManager.getConfig('customize:highlightJsStyle');
-  props.isAllReplyShown = configManager.getConfig('customize:isAllReplyShown');
-  props.showPageSideAuthors = configManager.getConfig('customize:showPageSideAuthors');
-  props.isContainerFluid = configManager.getConfig('customize:isContainerFluid');
-  props.isEnabledStaleNotification = configManager.getConfig('customize:isEnabledStaleNotification');
-  props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');
-  props.isUploadAllFileAllowed = fileUploadService.getFileUploadEnabled();
-  props.isUploadEnabled = fileUploadService.getIsUploadable();
-  // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
-  props.isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null;
-  props.isPdfBulkExportEnabled = configManager.getConfig('app:pageBulkExportPdfConverterUri') != null;
-
-  props.isLocalAccountRegistrationEnabled = passportService.isLocalStrategySetup
-  && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED;
-
-  props.adminPreferredIndentSize = configManager.getConfig('markdown:adminPreferredIndentSize');
-  props.isIndentSizeForced = configManager.getConfig('markdown:isIndentSizeForced');
-
-  props.isEnabledAttachTitleHeader = configManager.getConfig('customize:isEnabledAttachTitleHeader');
-
-  props.sidebarConfig = {
-    isSidebarCollapsedMode: configManager.getConfig('customize:isSidebarCollapsedMode'),
-    isSidebarClosedAtDockMode: configManager.getConfig('customize:isSidebarClosedAtDockMode'),
-  };
-
-  props.rendererConfig = {
-    isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
-    isEnabledMarp: configManager.getConfig('customize:isEnabledMarp'),
-    adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
-
-    drawioUri: configManager.getConfig('app:drawioUri'),
-    plantumlUri: configManager.getConfig('app:plantumlUri'),
-
-    // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
-    sanitizeType: configManager.getConfig('markdown:rehypeSanitize:option'),
-    customTagWhitelist: configManager.getConfig('markdown:rehypeSanitize:tagNames'),
-    customAttrWhitelist: configManager.getConfig('markdown:rehypeSanitize:attributes') != null
-      ? JSON.parse(configManager.getConfig('markdown:rehypeSanitize:attributes'))
-      : undefined,
-    highlightJsStyleBorder: configManager.getConfig('customize:highlightJsStyleBorder'),
-  };
-
-  props.ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
+  return {
+    props: {
+      aiEnabled: configManager.getConfig('app:aiEnabled'),
+      limitLearnablePageCountPerAssistant: configManager.getConfig('openai:limitLearnablePageCountPerAssistant'),
+      isUsersHomepageDeletionEnabled: configManager.getConfig('security:user-homepage-deletion:isEnabled'),
+      isSearchServiceConfigured: searchService.isConfigured,
+      isSearchServiceReachable: searchService.isReachable,
+      isSearchScopeChildrenAsDefault: configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
+      elasticsearchMaxBodyLengthToIndex: configManager.getConfig('app:elasticsearchMaxBodyLengthToIndex'),
+
+      isRomUserAllowedToComment: configManager.getConfig('security:isRomUserAllowedToComment'),
+
+      isSlackConfigured: slackIntegrationService.isSlackConfigured,
+      isAclEnabled: aclService.isAclEnabled(),
+      drawioUri: configManager.getConfig('app:drawioUri'),
+      isAllReplyShown: configManager.getConfig('customize:isAllReplyShown'),
+      showPageSideAuthors: configManager.getConfig('customize:showPageSideAuthors'),
+      isContainerFluid: configManager.getConfig('customize:isContainerFluid'),
+      isEnabledStaleNotification: configManager.getConfig('customize:isEnabledStaleNotification'),
+      disableLinkSharing: configManager.getConfig('security:disableLinkSharing'),
+      isUploadAllFileAllowed: fileUploadService.getFileUploadEnabled(),
+      isUploadEnabled: fileUploadService.getIsUploadable(),
+
+      // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
+      isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null,
+      isPdfBulkExportEnabled: configManager.getConfig('app:pageBulkExportPdfConverterUri') != null,
+      isLocalAccountRegistrationEnabled: passportService.isLocalStrategySetup
+        && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED,
+
+      adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
+      isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
+      isEnabledAttachTitleHeader: configManager.getConfig('customize:isEnabledAttachTitleHeader'),
+      sidebarConfig: {
+        isSidebarCollapsedMode: configManager.getConfig('customize:isSidebarCollapsedMode'),
+        isSidebarClosedAtDockMode: configManager.getConfig('customize:isSidebarClosedAtDockMode'),
+      },
+      rendererConfig: {
+        isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
+        isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+        isEnabledMarp: configManager.getConfig('customize:isEnabledMarp'),
+        adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
+        isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
+
+        drawioUri: configManager.getConfig('app:drawioUri'),
+        plantumlUri: configManager.getConfig('app:plantumlUri'),
+
+        // XSS Options
+        isEnabledXssPrevention: configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
+        sanitizeType: configManager.getConfig('markdown:rehypeSanitize:option'),
+        customTagWhitelist: configManager.getConfig('markdown:rehypeSanitize:tagNames'),
+        customAttrWhitelist: configManager.getConfig('markdown:rehypeSanitize:attributes') != null
+          ? JSON.parse(configManager.getConfig('markdown:rehypeSanitize:attributes'))
+          : undefined,
+        highlightJsStyleBorder: configManager.getConfig('customize:highlightJsStyleBorder'),
+      },
+    },
+  }
 }
 }
 
 
-/**
- * for Server Side Translations
- * @param context
- * @param props
- * @param namespacesRequired
- */
-async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
-  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
-  props._nextI18Next = nextI18NextConfig._nextI18Next;
-}
 
 
 const getAction = (props: Props): SupportedActionType => {
 const getAction = (props: Props): SupportedActionType => {
   if (props.isNotCreatable) {
   if (props.isNotCreatable) {
@@ -640,54 +564,73 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
 
   console.log('=== getServerSideProps ===', { nextjsRoutingType });
   console.log('=== getServerSideProps ===', { nextjsRoutingType });
 
 
-  const commonPropsResult = await getServerSideCommonProps(context);
-  const pageTitleCustomizeationPropsResult = await getServerSidePageTitleCustomizationProps(context);
+  let props: Props;
 
 
-  // check for presence
-  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
-  if (!('props' in commonPropsResult) || !('props' in pageTitleCustomizeationPropsResult)) {
-    throw new Error('invalid getSSP result');
-  }
+  if (nextjsRoutingType === NextjsRoutingType.INITIAL) {
+    // props will be (InitialProps & SameRouteEachProps)
 
 
-  const props: Props = {
-    ...commonPropsResult.props,
-    ...pageTitleCustomizeationPropsResult.props,
-    nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
-  } as Props;
-
-  if (props.redirectDestination != null) {
-    return {
-      redirect: {
-        permanent: false,
-        destination: props.redirectDestination,
-      },
-    };
-  }
-
-  if (user != null) {
-    props.currentUser = user.toObject();
-  }
-
-  try {
-    await injectPageData(context, props);
+    // await injectPageData(context, props);
+    // await injectRoutingInformation(context, props);
+    // await getServerSideCommonInitialProps(context),
+    // await getServerSidePageTitleCustomizationProps(context),
+    // await getServerSideConfigurationProps(context),
+    // await getServerSideSSRProps(context, page, ['translation']),
+    // await addActivity(context, getAction(props));
   }
   }
-  catch (err) {
-    if (err instanceof MultiplePagesHitsError) {
-      props.isIdenticalPathPage = true;
-    }
-    else {
-      throw err;
-    }
+  else {
+    // props will be SameRouteEachProps
   }
   }
 
 
-  await injectRoutingInformation(context, props);
-  injectServerConfigurations(context, props);
-  await injectNextI18NextConfigurations(context, props, ['translation']);
 
 
-  addActivity(context, getAction(props));
-  return {
-    props,
-  };
+  /** Deprecated codes (start): */
+
+  // // check for presence
+  // // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  // if (!('props' in commonPropsResult) || !('props' in pageTitleCustomizeationPropsResult)) {
+  //   throw new Error('invalid getSSP result');
+  // }
+
+  // const props: Props = {
+  //   ...commonPropsResult.props,
+  //   ...pageTitleCustomizeationPropsResult.props,
+  //   nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
+  // } as Props;
+
+  // if (props.redirectDestination != null) {
+  //   return {
+  //     redirect: {
+  //       permanent: false,
+  //       destination: props.redirectDestination,
+  //     },
+  //   };
+  // }
+
+  // if (user != null) {
+  //   props.currentUser = user.toObject();
+  // }
+
+  // try {
+  //   await injectPageData(context, props);
+  // }
+  // catch (err) {
+  //   if (err instanceof MultiplePagesHitsError) {
+  //     props.isIdenticalPathPage = true;
+  //   }
+  //   else {
+  //     throw err;
+  //   }
+  // }
+
+  // await injectRoutingInformation(context, props);
+  // injectServerConfigurations(context, props);
+  // await getServerSideSSRProps(context, props, ['translation']);
+
+  // addActivity(context, getAction(props));
+  /** Deprecated codes (end): */
+
+  // return {
+  //   props,
+  // };
 };
 };
 
 
 export default Page;
 export default Page;

+ 20 - 0
apps/app/src/pages/utils/activity.ts

@@ -0,0 +1,20 @@
+import type { GetServerSidePropsContext } from 'next';
+
+import { type SupportedActionType } from '~/interfaces/activity';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+
+export const addActivity = async(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> => {
+  const req = context.req as CrowiRequest;
+
+  const parameters = {
+    ip: req.ip,
+    endpoint: req.originalUrl,
+    action,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  await req.crowi.activityService.createActivity(parameters);
+};

+ 49 - 189
apps/app/src/pages/utils/commons.ts

@@ -1,230 +1,90 @@
-import type { ColorScheme, IUserHasId, Locale } from '@growi/core';
-import { Lang, AllLang } from '@growi/core';
-import { DevidedPagePath } from '@growi/core/dist/models';
-import { isServer } from '@growi/core/dist/utils';
+import type { ColorScheme, IUserHasId } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
-import type { SSRConfig, UserConfig } from 'next-i18next';
 
 
-import * as nextI18NextConfig from '^/config/next-i18next.config';
-
-import { type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { IUserUISettings } from '~/interfaces/user-ui-settings';
-import type { PageDocument } from '~/server/models/page';
-import type { UserUISettingsDocument } from '~/server/models/user-ui-settings';
-import { detectLocaleFromBrowserAcceptLanguage } from '~/server/util/locale-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
 
 
-export type CommonProps = {
-  currentPathname: string,
-  currentUser?: IUserHasId,
-  nextjsRoutingPage?: string,
+export type CommonInitialProps = {
   appTitle: string,
   appTitle: string,
   siteUrl: string | undefined,
   siteUrl: string | undefined,
-  csrfToken: string,
   confidential: string,
   confidential: string,
   growiVersion: string,
   growiVersion: string,
-  isMaintenanceMode: boolean,
   isDefaultLogo: boolean,
   isDefaultLogo: boolean,
   growiCloudUri: string | undefined,
   growiCloudUri: string | undefined,
   forcedColorScheme?: ColorScheme,
   forcedColorScheme?: ColorScheme,
-  // namespacesRequired: string[], // i18next
-  // isContainerFluid: boolean,
-  // redirectDestination: string | null,
-  // isAccessDeniedForNonAdminUser?: boolean,
-  // userUISettings?: IUserUISettings
-} & Partial<SSRConfig>;
-
-export type PageTitleCustomizationProps = {
-  appTitle: string,
-  customTitleTemplate: string,
 };
 };
 
 
-// eslint-disable-next-line max-len
-export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(context: GetServerSidePropsContext) => {
-  // const getModelSafely = await import('~/server/util/mongoose-utils').then(mod => mod.getModelSafely);
-
+export const getServerSideCommonInitialProps: GetServerSideProps<CommonInitialProps> = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const req = context.req as CrowiRequest;
-  const { crowi, user } = req;
+  const { crowi } = req;
   const {
   const {
     appService, configManager, attachmentService,
     appService, configManager, attachmentService,
   } = crowi;
   } = crowi;
 
 
-  const url = new URL(context.resolvedUrl, 'http://example.com');
-  const currentPathname = decodeURIComponent(url.pathname);
-
-  const isMaintenanceMode = appService.isMaintenanceMode();
-
-  let currentUser;
-  if (user != null) {
-    currentUser = user.toObject();
-  }
-
-  // // Redirect destination for page transition by next/link
-  // let redirectDestination: string | null = null;
-  // if (!crowi.aclService.isGuestAllowedToRead() && currentUser == null) {
-  //   redirectDestination = '/login';
-  // }
-  // else if (!isMaintenanceMode && currentPathname === '/maintenance') {
-  //   redirectDestination = '/';
-  // }
-  // else if (isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance')) {
-  //   redirectDestination = '/maintenance';
-  // }
-  // else {
-  //   redirectDestination = null;
-  // }
-
   const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
   const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
   const isDefaultLogo = crowi.configManager.getConfig('customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const isDefaultLogo = crowi.configManager.getConfig('customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;
 
 
-  // retrieve UserUISett ings
-  // const UserUISettings = getModelSafely<UserUISettingsDocument>('UserUISettings');
-  // const userUISettings = user != null && UserUISettings != null
-  //   ? await UserUISettings.findOne({ user: user._id }).exec()
-  //   : req.session.uiSettings; // for guests
-
-  const props: CommonProps = {
-    // namespacesRequired: ['translation'],
-    currentPathname,
-    currentUser,
-    appTitle: appService.getAppTitle(),
-    siteUrl: configManager.getConfig('app:siteUrl'), // DON'T USE growiInfoService.getSiteUrl()
-    confidential: appService.getAppConfidential() || '',
-    csrfToken: req.csrfToken(),
-    // isContainerFluid: configManager.getConfig('customize:isContainerFluid') ?? false,
-    growiVersion: getGrowiVersion(),
-    isMaintenanceMode,
-    // redirectDestination,
-    isDefaultLogo,
-    forcedColorScheme,
-    growiCloudUri: configManager.getConfig('app:growiCloudUri'),
-    // userUISettings: userUISettings?.toObject?.() ?? userUISettings,
-  };
-
-  return { props };
-};
-
-export const getServerSidePageTitleCustomizationProps: GetServerSideProps<PageTitleCustomizationProps> = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest;
-  const { crowi } = req;
-  const {
-    appService, customizeService,
-  } = crowi;
-
-  const props: PageTitleCustomizationProps = {
-    appTitle: appService.getAppTitle(),
-    customTitleTemplate: customizeService.customTitleTemplate,
+  return {
+    props: {
+      appTitle: appService.getAppTitle(),
+      siteUrl: configManager.getConfig('app:siteUrl'), // DON'T USE growiInfoService.getSiteUrl()
+      confidential: appService.getAppConfidential() || '',
+      growiVersion: getGrowiVersion(),
+      isDefaultLogo,
+      growiCloudUri: configManager.getConfig('app:growiCloudUri'),
+      forcedColorScheme,
+    },
   };
   };
-
-  return { props };
 };
 };
 
 
-export type LangMap = {
-  readonly [key in Lang]: Locale;
+export type CommonEachProps = {
+  currentPathname: string,
+  currentUser?: IUserHasId,
+  nextjsRoutingPage?: string,
+  csrfToken: string,
+  isMaintenanceMode: boolean,
+  redirectDestination?: string | null,
 };
 };
 
 
-export const langMap: LangMap = {
-  [Lang.ja_JP]: 'ja-JP',
-  [Lang.en_US]: 'en-US',
-  [Lang.zh_CN]: 'zh-CN',
-  [Lang.fr_FR]: 'fr-FR',
-  [Lang.ko_KR]: 'ko-KR',
-} as const;
 
 
-// use this function to translate content
-export const getLangAtServerSide = (req: CrowiRequest): Lang => {
-  const { user, headers } = req;
-  const { configManager } = req.crowi;
-
-  return user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
-    : (user.lang ?? configManager.getConfig('app:globalLang') ?? Lang.en_US) ?? Lang.en_US;
-};
+export const getServerSideCommonEachProps: GetServerSideProps<CommonEachProps> = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest;
+  const { crowi, user } = req;
+  const { appService } = crowi;
 
 
-// use this function to get locale for html lang attribute
-export const getLocaleAtServerSide = (req: CrowiRequest): Locale => {
-  return langMap[getLangAtServerSide(req)];
-};
+  const url = new URL(context.resolvedUrl, 'http://example.com');
+  const currentPathname = decodeURIComponent(url.pathname);
 
 
-export const getNextI18NextConfig = async(
-    // 'serverSideTranslations' method should be given from Next.js Page
-    //  because importing it in this file causes https://github.com/isaachinman/next-i18next/issues/1545
-    serverSideTranslations: (
-      initialLocale: string, namespacesRequired?: string[] | undefined, configOverride?: UserConfig | null, extraLocales?: string[] | false
-    ) => Promise<SSRConfig>,
-    context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined, preloadAllLang = false,
-): Promise<SSRConfig> => {
+  const isMaintenanceMode = appService.isMaintenanceMode();
 
 
-  // determine language
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const lang = getLangAtServerSide(req);
+  let currentUser;
+  if (user != null) {
+    currentUser = user.toObject();
+  }
 
 
-  const namespaces = ['commons'];
-  if (namespacesRequired != null) {
-    namespaces.push(...namespacesRequired);
+  // Redirect destination for page transition by next/link
+  let redirectDestination: string | null = null;
+  if (!crowi.aclService.isGuestAllowedToRead() && currentUser == null) {
+    redirectDestination = '/login';
   }
   }
-  // TODO: deprecate 'translation.json' in the future
-  else {
-    namespaces.push('translation');
+  else if (!isMaintenanceMode && currentPathname === '/maintenance') {
+    redirectDestination = '/';
   }
   }
-
-  // The first argument must be a language code with an underscore, such as en_US
-  return serverSideTranslations(lang, namespaces, nextI18NextConfig, preloadAllLang ? AllLang : false);
-};
-
-/**
- * Generate whole title string for the specified title
- * @param props
- * @param title
- */
-export const generateCustomTitle = (props: PageTitleCustomizationProps, title: string): string => {
-  return props.customTitleTemplate
-    .replace('{{sitename}}', props.appTitle)
-    .replace('{{pagepath}}', title)
-    .replace('{{pagename}}', title);
-};
-
-/**
- * Generate whole title string for the specified page path
- * @param props
- * @param pagePath
- */
-export const generateCustomTitleForPage = (props: PageTitleCustomizationProps, pagePath: string): string => {
-  const dPagePath = new DevidedPagePath(pagePath, true, true);
-
-  return props.customTitleTemplate
-    .replace('{{sitename}}', props.appTitle)
-    .replace('{{pagepath}}', pagePath)
-    .replace('{{pagename}}', dPagePath.latter);
-};
-
-export const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: number): Promise<boolean> => {
-  if (!isServer()) {
-    throw new Error('This method is not available on the client-side');
+  else if (isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance')) {
+    redirectDestination = '/maintenance';
   }
   }
-
-  const latestRevisionBodyLength = await page.getLatestRevisionBodyLength();
-
-  if (latestRevisionBodyLength == null) {
-    return true;
+  else {
+    redirectDestination = null;
   }
   }
 
 
-  return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
-};
-
-export const addActivity = async(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> => {
-  const req = context.req as CrowiRequest;
-
-  const parameters = {
-    ip: req.ip,
-    endpoint: req.originalUrl,
-    action,
-    user: req.user?._id,
-    snapshot: {
-      username: req.user?.username,
+  return {
+    props: {
+      currentPathname,
+      currentUser,
+      csrfToken: req.csrfToken(),
+      isMaintenanceMode,
+      redirectDestination,
     },
     },
   };
   };
-
-  await req.crowi.activityService.createActivity(parameters);
 };
 };

+ 31 - 0
apps/app/src/pages/utils/locale.ts

@@ -0,0 +1,31 @@
+import type { Locale } from '@growi/core';
+import { Lang } from '@growi/core';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { detectLocaleFromBrowserAcceptLanguage } from '~/server/util/locale-utils';
+
+export type LangMap = {
+  readonly [key in Lang]: Locale;
+};
+
+export const langMap: LangMap = {
+  [Lang.ja_JP]: 'ja-JP',
+  [Lang.en_US]: 'en-US',
+  [Lang.zh_CN]: 'zh-CN',
+  [Lang.fr_FR]: 'fr-FR',
+  [Lang.ko_KR]: 'ko-KR',
+} as const;
+
+// use this function to translate content
+export const getLangAtServerSide = (req: CrowiRequest): Lang => {
+  const { user, headers } = req;
+  const { configManager } = req.crowi;
+
+  return user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
+    : (user.lang ?? configManager.getConfig('app:globalLang') ?? Lang.en_US) ?? Lang.en_US;
+};
+
+// use this function to get locale for html lang attribute
+export const getLocaleAtServerSide = (req: CrowiRequest): Locale => {
+  return langMap[getLangAtServerSide(req)];
+};

+ 2 - 2
apps/app/src/pages/utils/nextjs-routing-utils.ts

@@ -16,12 +16,12 @@ export const useNextjsRoutingPageRegister = (nextjsRoutingPage: string | undefin
   }, [nextjsRoutingPage]);
   }, [nextjsRoutingPage]);
 };
 };
 
 
-const NextjsRoutingType = {
+export const NextjsRoutingType = {
   INITIAL: 'initial',
   INITIAL: 'initial',
   SAME_ROUTE: 'same-route',
   SAME_ROUTE: 'same-route',
   FROM_OUTSIDE: 'from-outside',
   FROM_OUTSIDE: 'from-outside',
 } as const;
 } as const;
-type NextjsRoutingType = (typeof NextjsRoutingType)[keyof typeof NextjsRoutingType];
+export type NextjsRoutingType = (typeof NextjsRoutingType)[keyof typeof NextjsRoutingType];
 
 
 export const detectNextjsRoutingType = (context: GetServerSidePropsContext, previousRoutingPage: string): NextjsRoutingType => {
 export const detectNextjsRoutingType = (context: GetServerSidePropsContext, previousRoutingPage: string): NextjsRoutingType => {
   const isCSR = !!context.req.headers['x-nextjs-data'];
   const isCSR = !!context.req.headers['x-nextjs-data'];

+ 50 - 0
apps/app/src/pages/utils/page-title-customization.ts

@@ -0,0 +1,50 @@
+import { DevidedPagePath } from '@growi/core/dist/models';
+import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+
+export type PageTitleCustomizationProps = {
+  appTitle: string,
+  customTitleTemplate: string,
+};
+
+export const getServerSidePageTitleCustomizationProps: GetServerSideProps<PageTitleCustomizationProps> = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    appService, customizeService,
+  } = crowi;
+
+  return {
+    props: {
+      appTitle: appService.getAppTitle(),
+      customTitleTemplate: customizeService.customTitleTemplate,
+    },
+  };
+};
+
+/**
+ * Generate whole title string for the specified title
+ * @param props
+ * @param title
+ */
+export const generateCustomTitle = (props: PageTitleCustomizationProps, title: string): string => {
+  return props.customTitleTemplate
+    .replace('{{sitename}}', props.appTitle)
+    .replace('{{pagepath}}', title)
+    .replace('{{pagename}}', title);
+};
+
+/**
+ * Generate whole title string for the specified page path
+ * @param props
+ * @param pagePath
+ */
+export const generateCustomTitleForPage = (props: PageTitleCustomizationProps, pagePath: string): string => {
+  const dPagePath = new DevidedPagePath(pagePath, true, true);
+
+  return props.customTitleTemplate
+    .replace('{{sitename}}', props.appTitle)
+    .replace('{{pagepath}}', pagePath)
+    .replace('{{pagename}}', dPagePath.latter);
+};

+ 78 - 0
apps/app/src/pages/utils/ssr.ts

@@ -0,0 +1,78 @@
+import { AllLang } from '@growi/core';
+import { isServer } from '@growi/core/dist/utils';
+import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
+import type { SSRConfig, UserConfig } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+
+import * as nextI18NextConfig from '^/config/next-i18next.config';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { PageDocument } from '~/server/models/page';
+
+import { getLangAtServerSide } from './locale';
+
+
+const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: number): Promise<boolean> => {
+  if (!isServer()) {
+    throw new Error('This method is not available on the client-side');
+  }
+
+  const latestRevisionBodyLength = await page.getLatestRevisionBodyLength();
+
+  if (latestRevisionBodyLength == null) {
+    return true;
+  }
+
+  return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
+};
+
+const getNextI18NextConfig = async(
+    // 'serverSideTranslations' method should be given from Next.js Page
+    //  because importing it in this file causes https://github.com/isaachinman/next-i18next/issues/1545
+    serverSideTranslations: (
+      initialLocale: string, namespacesRequired?: string[] | undefined, configOverride?: UserConfig | null, extraLocales?: string[] | false
+    ) => Promise<SSRConfig>,
+    context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined, preloadAllLang = false,
+): Promise<SSRConfig> => {
+
+  // determine language
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const lang = getLangAtServerSide(req);
+
+  const namespaces = ['commons'];
+  if (namespacesRequired != null) {
+    namespaces.push(...namespacesRequired);
+  }
+  // TODO: deprecate 'translation.json' in the future
+  else {
+    namespaces.push('translation');
+  }
+
+  // The first argument must be a language code with an underscore, such as en_US
+  return serverSideTranslations(lang, namespaces, nextI18NextConfig, preloadAllLang ? AllLang : false);
+};
+
+export type SSRProps = SSRConfig & {
+  skipSSR: boolean;
+}
+
+export const getServerSideSSRProps = async(
+    context: GetServerSidePropsContext,
+    page: PageDocument,
+    namespacesRequired?: string[] | undefined,
+): Promise<GetServerSidePropsResult<SSRProps>> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { configManager } = crowi;
+
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+
+  const ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
+
+  return {
+    props: {
+      _nextI18Next: nextI18NextConfig._nextI18Next,
+      skipSSR: await skipSSR(page, ssrMaxRevisionBodyLength),
+    },
+  };
+};

+ 27 - 0
apps/app/src/pages/utils/user-ui-settings.ts

@@ -0,0 +1,27 @@
+import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { IUserUISettings } from '~/interfaces/user-ui-settings';
+import type { UserUISettingsDocument } from '~/server/models/user-ui-settings';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+
+export type UserUISettingsProps = {
+  userUISettings: IUserUISettings,
+};
+
+export const getServerSideUserUISettingsProps: GetServerSideProps<UserUISettingsProps> = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest;
+  const { user } = req;
+
+  // retrieve UserUISettings
+  const UserUISettings = getModelSafely<UserUISettingsDocument>('UserUISettings');
+  const userUISettings = user != null && UserUISettings != null
+    ? await UserUISettings.findOne({ user: user._id }).exec()
+    : req.session.uiSettings; // for guests
+
+  return {
+    props: {
+      userUISettings,
+    },
+  };
+};

+ 45 - 0
apps/app/src/states/context.ts

@@ -3,6 +3,33 @@ import { atom, useAtom } from 'jotai';
 import { currentUserAtom, growiCloudUriAtom } from './global';
 import { currentUserAtom, growiCloudUriAtom } from './global';
 import type { UseAtom } from './ui/helper';
 import type { UseAtom } from './ui/helper';
 
 
+/**
+ * Atom for checking if current path is identical
+ */
+const isIdenticalPathAtom = atom<boolean>(false);
+
+export const useIsIdenticalPath = (): UseAtom<typeof isIdenticalPathAtom> => {
+  return useAtom(isIdenticalPathAtom);
+};
+
+/**
+ * Atom for checking if current page is forbidden
+ */
+const isForbiddenAtom = atom<boolean>(false);
+
+export const useIsForbidden = (): UseAtom<typeof isForbiddenAtom> => {
+  return useAtom(isForbiddenAtom);
+};
+
+/**
+ * Atom for checking if current page is not creatable
+ */
+const isNotCreatableAtom = atom<boolean>(false);
+
+export const useIsNotCreatable = (): UseAtom<typeof isNotCreatableAtom> => {
+  return useAtom(isNotCreatableAtom);
+};
+
 /**
 /**
  * Computed atom for checking if current user is a guest user
  * Computed atom for checking if current user is a guest user
  * Depends on currentUser atom
  * Depends on currentUser atom
@@ -62,3 +89,21 @@ const growiDocumentationUrlAtom = atom((get) => {
 export const useGrowiDocumentationUrl = (): UseAtom<typeof growiDocumentationUrlAtom> => {
 export const useGrowiDocumentationUrl = (): UseAtom<typeof growiDocumentationUrlAtom> => {
   return useAtom(growiDocumentationUrlAtom);
   return useAtom(growiDocumentationUrlAtom);
 };
 };
+
+/**
+ * Computed atom for checking if current page is editable
+ * Depends on multiple atoms: isGuestUser, isReadOnlyUser, isForbidden, isNotCreatable, isIdenticalPath
+ */
+const isEditableAtom = atom((get) => {
+  const isGuestUser = get(isGuestUserAtom);
+  const isReadOnlyUser = get(isReadOnlyUserAtom);
+  const isForbidden = get(isForbiddenAtom);
+  const isNotCreatable = get(isNotCreatableAtom);
+  const isIdenticalPath = get(isIdenticalPathAtom);
+
+  return (!isForbidden && !isIdenticalPath && !isNotCreatable && !isGuestUser && !isReadOnlyUser);
+});
+
+export const useIsEditable = (): UseAtom<typeof isEditableAtom> => {
+  return useAtom(isEditableAtom);
+};

+ 11 - 10
apps/app/src/states/auto-update/global.ts → apps/app/src/states/global/auto-update.ts

@@ -1,34 +1,35 @@
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 
-import type { CommonProps } from '../../pages/utils/commons';
+import type { CommonEachProps } from '../../pages/utils/commons';
+
 import {
 import {
   useCurrentPathname,
   useCurrentPathname,
   useCurrentUser,
   useCurrentUser,
   useCsrfToken,
   useCsrfToken,
-} from '../global';
+} from './global';
 
 
 /**
 /**
  * Hook for auto-updating global UI state atoms with server-side data
  * Hook for auto-updating global UI state atoms with server-side data
  *
  *
- * @param commonProps - Server-side common properties from getServerSideCommonProps
+ * @param props - Server-side common properties from getServerSideCommonEachProps
  */
  */
-export const useAutoUpdateGlobalAtoms = (commonProps: CommonProps): void => {
+export const useAutoUpdateGlobalAtoms = (props: CommonEachProps): void => {
   // Update pathname and user atoms
   // Update pathname and user atoms
   const [, setCurrentPathname] = useCurrentPathname();
   const [, setCurrentPathname] = useCurrentPathname();
   useIsomorphicLayoutEffect(() => {
   useIsomorphicLayoutEffect(() => {
-    setCurrentPathname(commonProps.currentPathname);
-  }, [setCurrentPathname, commonProps.currentPathname]);
+    setCurrentPathname(props.currentPathname);
+  }, [setCurrentPathname, props.currentPathname]);
 
 
   // Update user atom
   // Update user atom
   const [, setCurrentUser] = useCurrentUser();
   const [, setCurrentUser] = useCurrentUser();
   useIsomorphicLayoutEffect(() => {
   useIsomorphicLayoutEffect(() => {
-    setCurrentUser(commonProps.currentUser);
-  }, [setCurrentUser, commonProps.currentUser]);
+    setCurrentUser(props.currentUser);
+  }, [setCurrentUser, props.currentUser]);
 
 
   // Update CSRF token atom
   // Update CSRF token atom
   const [, setCsrfToken] = useCsrfToken();
   const [, setCsrfToken] = useCsrfToken();
   useIsomorphicLayoutEffect(() => {
   useIsomorphicLayoutEffect(() => {
-    setCsrfToken(commonProps.csrfToken);
-  }, [setCsrfToken, commonProps.csrfToken]);
+    setCsrfToken(props.csrfToken);
+  }, [setCsrfToken, props.csrfToken]);
 
 
 };
 };

+ 1 - 1
apps/app/src/states/global.ts → apps/app/src/states/global/global.ts

@@ -1,7 +1,7 @@
 import type { ColorScheme, IUserHasId } from '@growi/core';
 import type { ColorScheme, IUserHasId } from '@growi/core';
 import { atom, useAtom } from 'jotai';
 import { atom, useAtom } from 'jotai';
 
 
-import type { UseAtom } from './ui/helper';
+import type { UseAtom } from '../ui/helper';
 
 
 // CSRF Token atom (no persistence needed as it's server-provided)
 // CSRF Token atom (no persistence needed as it's server-provided)
 export const csrfTokenAtom = atom<string>('');
 export const csrfTokenAtom = atom<string>('');

+ 32 - 0
apps/app/src/states/global/hydrate.ts

@@ -0,0 +1,32 @@
+import { useHydrateAtoms } from 'jotai/utils';
+
+import type { CommonInitialProps } from '~/pages/utils/commons';
+
+import {
+  appTitleAtom,
+  siteUrlAtom,
+  confidentialAtom,
+  growiVersionAtom,
+  isDefaultLogoAtom,
+  growiCloudUriAtom,
+  forcedColorSchemeAtom,
+} from './global';
+
+/**
+ * Hook for hydrating global UI state atoms with server-side data
+ * This should be called early in the app component to ensure atoms are properly initialized before rendering
+ *
+ * @param commonInitialProps - Server-side common properties from getServerSideCommonInitialProps
+ */
+export const useHydrateGlobalInitialAtoms = (commonInitialProps: CommonInitialProps): void => {
+  // Hydrate global atoms with server-side data
+  useHydrateAtoms([
+    [appTitleAtom, commonInitialProps.appTitle],
+    [siteUrlAtom, commonInitialProps.siteUrl],
+    [confidentialAtom, commonInitialProps.confidential],
+    [growiVersionAtom, commonInitialProps.growiVersion],
+    [isDefaultLogoAtom, commonInitialProps.isDefaultLogo],
+    [growiCloudUriAtom, commonInitialProps.growiCloudUri],
+    [forcedColorSchemeAtom, commonInitialProps.forcedColorScheme],
+  ]);
+};

+ 3 - 0
apps/app/src/states/global/index.ts

@@ -0,0 +1,3 @@
+export * from './auto-update';
+export * from './global';
+export * from './hydrate';

+ 0 - 39
apps/app/src/states/hydrate/global.ts

@@ -1,39 +0,0 @@
-import { useHydrateAtoms } from 'jotai/utils';
-
-import type { CommonProps } from '../../pages/utils/commons';
-import {
-  currentPathnameAtom,
-  currentUserAtom,
-  csrfTokenAtom,
-  appTitleAtom,
-  siteUrlAtom,
-  confidentialAtom,
-  growiVersionAtom,
-  isMaintenanceModeAtom,
-  isDefaultLogoAtom,
-  growiCloudUriAtom,
-  forcedColorSchemeAtom,
-} from '../global';
-
-/**
- * Hook for hydrating global UI state atoms with server-side data
- * This should be called early in the app component to ensure atoms are properly initialized before rendering
- *
- * @param commonProps - Server-side common properties from getServerSideCommonProps
- */
-export const useHydrateGlobalAtoms = (commonProps: CommonProps): void => {
-  // Hydrate global atoms with server-side data
-  useHydrateAtoms([
-    [currentPathnameAtom, commonProps.currentPathname],
-    [currentUserAtom, commonProps.currentUser],
-    [csrfTokenAtom, commonProps.csrfToken],
-    [appTitleAtom, commonProps.appTitle],
-    [siteUrlAtom, commonProps.siteUrl],
-    [confidentialAtom, commonProps.confidential],
-    [growiVersionAtom, commonProps.growiVersion],
-    [isMaintenanceModeAtom, commonProps.isMaintenanceMode],
-    [isDefaultLogoAtom, commonProps.isDefaultLogo],
-    [growiCloudUriAtom, commonProps.growiCloudUri],
-    [forcedColorSchemeAtom, commonProps.forcedColorScheme],
-  ]);
-};

+ 5 - 2
apps/app/src/states/hydrate/page.ts → apps/app/src/states/page/hydrate.ts

@@ -10,7 +10,8 @@ import {
   templateBodyAtom,
   templateBodyAtom,
   remoteRevisionIdAtom,
   remoteRevisionIdAtom,
   remoteRevisionBodyAtom,
   remoteRevisionBodyAtom,
-} from '../page/internal-atoms';
+  pageNotCreatableAtom,
+} from './internal-atoms';
 
 
 /**
 /**
  * Hook for hydrating page-related atoms with server-side data
  * Hook for hydrating page-related atoms with server-side data
@@ -39,6 +40,7 @@ export const useHydratePageAtoms = (
     page: IPagePopulatedToShowRevision | undefined,
     page: IPagePopulatedToShowRevision | undefined,
     options?: {
     options?: {
       isNotFound?: boolean;
       isNotFound?: boolean;
+      isNotCreatable?: boolean;
       isLatestRevision?: boolean;
       isLatestRevision?: boolean;
       templateTags?: string[];
       templateTags?: string[];
       templateBody?: string;
       templateBody?: string;
@@ -48,7 +50,8 @@ export const useHydratePageAtoms = (
     // Core page state - automatically extract from page object
     // Core page state - automatically extract from page object
     [currentPageIdAtom, page?._id],
     [currentPageIdAtom, page?._id],
     [currentPageDataAtom, page],
     [currentPageDataAtom, page],
-    [pageNotFoundAtom, options?.isNotFound ?? (page == null)],
+    [pageNotFoundAtom, options?.isNotFound ?? (page == null || page.isEmpty)],
+    [pageNotCreatableAtom, options?.isNotCreatable ?? false],
     [latestRevisionAtom, options?.isLatestRevision ?? true],
     [latestRevisionAtom, options?.isLatestRevision ?? true],
 
 
     // Template data - from options (not auto-extracted from page)
     // Template data - from options (not auto-extracted from page)

+ 1 - 0
apps/app/src/states/page/internal-atoms.ts

@@ -11,6 +11,7 @@ import { atom } from 'jotai';
 export const currentPageIdAtom = atom<string>();
 export const currentPageIdAtom = atom<string>();
 export const currentPageDataAtom = atom<IPagePopulatedToShowRevision>();
 export const currentPageDataAtom = atom<IPagePopulatedToShowRevision>();
 export const pageNotFoundAtom = atom(false);
 export const pageNotFoundAtom = atom(false);
+export const pageNotCreatableAtom = atom(false);
 export const latestRevisionAtom = atom(true);
 export const latestRevisionAtom = atom(true);
 
 
 // Template data atoms (internal)
 // Template data atoms (internal)

+ 98 - 0
apps/app/src/states/server-configurations/hydrate.ts

@@ -0,0 +1,98 @@
+import { useHydrateAtoms } from 'jotai/utils';
+
+import type { RendererConfig } from '~/interfaces/services/renderer';
+import {
+  aiEnabledAtom,
+  limitLearnablePageCountPerAssistantAtom,
+  isUsersHomepageDeletionEnabledAtom,
+  defaultIndentSizeAtom,
+  isSearchScopeChildrenAsDefaultAtom,
+  elasticsearchMaxBodyLengthToIndexAtom,
+  isRomUserAllowedToCommentAtom,
+  drawioUriAtom,
+  isAllReplyShownAtom,
+  showPageSideAuthorsAtom,
+  isContainerFluidAtom,
+  isEnabledStaleNotificationAtom,
+  disableLinkSharingAtom,
+  isIndentSizeForcedAtom,
+  isEnabledAttachTitleHeaderAtom,
+  isSearchServiceConfiguredAtom,
+  isSearchServiceReachableAtom,
+  isSlackConfiguredAtom,
+  isAclEnabledAtom,
+  isUploadAllFileAllowedAtom,
+  isUploadEnabledAtom,
+  isBulkExportPagesEnabledAtom,
+  isPdfBulkExportEnabledAtom,
+  isLocalAccountRegistrationEnabledAtom,
+  rendererConfigAtom,
+} from '~/states/server-configurations/server-configurations';
+
+/**
+ * Type for server configuration initial props
+ */
+export type ServerConfigurationInitialProps = {
+  aiEnabled: boolean;
+  limitLearnablePageCountPerAssistant: number;
+  isUsersHomepageDeletionEnabled: boolean;
+  adminPreferredIndentSize: number;
+  isSearchScopeChildrenAsDefault: boolean;
+  elasticsearchMaxBodyLengthToIndex: number;
+  isRomUserAllowedToComment: boolean;
+  drawioUri: string | null;
+  isAllReplyShown: boolean;
+  showPageSideAuthors: boolean;
+  isContainerFluid: boolean;
+  isEnabledStaleNotification: boolean;
+  disableLinkSharing: boolean;
+  isIndentSizeForced: boolean;
+  isEnabledAttachTitleHeader: boolean;
+  isSearchServiceConfigured: boolean;
+  isSearchServiceReachable: boolean;
+  isSlackConfigured: boolean;
+  isAclEnabled: boolean;
+  isUploadAllFileAllowed: boolean;
+  isUploadEnabled: boolean;
+  isBulkExportPagesEnabled: boolean;
+  isPdfBulkExportEnabled: boolean;
+  isLocalAccountRegistrationEnabled: boolean;
+  rendererConfig: RendererConfig;
+};
+
+/**
+ * Hook for hydrating server configuration atoms with server-side data
+ * This should be called early in the app component to ensure atoms are properly initialized before rendering
+ *
+ * @param serverConfigProps - Server-side server configuration properties
+ */
+export const useHydrateServerConfigurationAtoms = (serverConfigProps: ServerConfigurationInitialProps): void => {
+  // Hydrate server configuration atoms with server-side data
+  useHydrateAtoms([
+    [aiEnabledAtom, serverConfigProps.aiEnabled],
+    [limitLearnablePageCountPerAssistantAtom, serverConfigProps.limitLearnablePageCountPerAssistant],
+    [isUsersHomepageDeletionEnabledAtom, serverConfigProps.isUsersHomepageDeletionEnabled],
+    [defaultIndentSizeAtom, serverConfigProps.adminPreferredIndentSize],
+    [isSearchScopeChildrenAsDefaultAtom, serverConfigProps.isSearchScopeChildrenAsDefault],
+    [elasticsearchMaxBodyLengthToIndexAtom, serverConfigProps.elasticsearchMaxBodyLengthToIndex],
+    [isRomUserAllowedToCommentAtom, serverConfigProps.isRomUserAllowedToComment],
+    [drawioUriAtom, serverConfigProps.drawioUri],
+    [isAllReplyShownAtom, serverConfigProps.isAllReplyShown],
+    [showPageSideAuthorsAtom, serverConfigProps.showPageSideAuthors],
+    [isContainerFluidAtom, serverConfigProps.isContainerFluid],
+    [isEnabledStaleNotificationAtom, serverConfigProps.isEnabledStaleNotification],
+    [disableLinkSharingAtom, serverConfigProps.disableLinkSharing],
+    [isIndentSizeForcedAtom, serverConfigProps.isIndentSizeForced],
+    [isEnabledAttachTitleHeaderAtom, serverConfigProps.isEnabledAttachTitleHeader],
+    [isSearchServiceConfiguredAtom, serverConfigProps.isSearchServiceConfigured],
+    [isSearchServiceReachableAtom, serverConfigProps.isSearchServiceReachable],
+    [isSlackConfiguredAtom, serverConfigProps.isSlackConfigured],
+    [isAclEnabledAtom, serverConfigProps.isAclEnabled],
+    [isUploadAllFileAllowedAtom, serverConfigProps.isUploadAllFileAllowed],
+    [isUploadEnabledAtom, serverConfigProps.isUploadEnabled],
+    [isBulkExportPagesEnabledAtom, serverConfigProps.isBulkExportPagesEnabled],
+    [isPdfBulkExportEnabledAtom, serverConfigProps.isPdfBulkExportEnabled],
+    [isLocalAccountRegistrationEnabledAtom, serverConfigProps.isLocalAccountRegistrationEnabled],
+    [rendererConfigAtom, serverConfigProps.rendererConfig],
+  ]);
+};

+ 243 - 0
apps/app/src/states/server-configurations/server-configurations.ts

@@ -0,0 +1,243 @@
+import { atom, useAtom } from 'jotai';
+
+import type { RendererConfig } from '~/interfaces/services/renderer';
+
+import type { UseAtom } from '../ui/helper';
+
+/**
+ * Atom for AI feature enabled status
+ */
+export const aiEnabledAtom = atom<boolean>(false);
+
+export const useIsAiEnabled = (): UseAtom<typeof aiEnabledAtom> => {
+  return useAtom(aiEnabledAtom);
+};
+
+/**
+ * Atom for limit learnable page count per assistant
+ */
+export const limitLearnablePageCountPerAssistantAtom = atom<number>(0);
+
+export const useLimitLearnablePageCountPerAssistant = (): UseAtom<typeof limitLearnablePageCountPerAssistantAtom> => {
+  return useAtom(limitLearnablePageCountPerAssistantAtom);
+};
+
+/**
+ * Atom for users homepage deletion enabled status
+ */
+export const isUsersHomepageDeletionEnabledAtom = atom<boolean>(false);
+
+export const useIsUsersHomepageDeletionEnabled = (): UseAtom<typeof isUsersHomepageDeletionEnabledAtom> => {
+  return useAtom(isUsersHomepageDeletionEnabledAtom);
+};
+
+/**
+ * Atom for default indent size (default indent size)
+ */
+export const defaultIndentSizeAtom = atom<number>(4);
+
+export const useDefaultIndentSize = (): UseAtom<typeof defaultIndentSizeAtom> => {
+  return useAtom(defaultIndentSizeAtom);
+};
+
+/**
+ * Atom for search scope children as default
+ */
+export const isSearchScopeChildrenAsDefaultAtom = atom<boolean>(false);
+
+export const useIsSearchScopeChildrenAsDefault = (): UseAtom<typeof isSearchScopeChildrenAsDefaultAtom> => {
+  return useAtom(isSearchScopeChildrenAsDefaultAtom);
+};
+
+/**
+ * Atom for elasticsearch max body length to index
+ */
+export const elasticsearchMaxBodyLengthToIndexAtom = atom<number>(0);
+
+export const useElasticsearchMaxBodyLengthToIndex = (): UseAtom<typeof elasticsearchMaxBodyLengthToIndexAtom> => {
+  return useAtom(elasticsearchMaxBodyLengthToIndexAtom);
+};
+
+/**
+ * Atom for ROM user allowed to comment
+ */
+export const isRomUserAllowedToCommentAtom = atom<boolean>(false);
+
+export const useIsRomUserAllowedToComment = (): UseAtom<typeof isRomUserAllowedToCommentAtom> => {
+  return useAtom(isRomUserAllowedToCommentAtom);
+};
+
+/**
+ * Atom for drawio URI
+ */
+export const drawioUriAtom = atom<string | null>(null);
+
+export const useDrawioUri = (): UseAtom<typeof drawioUriAtom> => {
+  return useAtom(drawioUriAtom);
+};
+
+/**
+ * Atom for all reply shown
+ */
+export const isAllReplyShownAtom = atom<boolean>(false);
+
+export const useIsAllReplyShown = (): UseAtom<typeof isAllReplyShownAtom> => {
+  return useAtom(isAllReplyShownAtom);
+};
+
+/**
+ * Atom for show page side authors
+ */
+export const showPageSideAuthorsAtom = atom<boolean>(false);
+
+export const useShowPageSideAuthors = (): UseAtom<typeof showPageSideAuthorsAtom> => {
+  return useAtom(showPageSideAuthorsAtom);
+};
+
+/**
+ * Atom for container fluid
+ */
+export const isContainerFluidAtom = atom<boolean>(false);
+
+export const useIsContainerFluid = (): UseAtom<typeof isContainerFluidAtom> => {
+  return useAtom(isContainerFluidAtom);
+};
+
+/**
+ * Atom for stale notification enabled
+ */
+export const isEnabledStaleNotificationAtom = atom<boolean>(false);
+
+export const useIsEnabledStaleNotification = (): UseAtom<typeof isEnabledStaleNotificationAtom> => {
+  return useAtom(isEnabledStaleNotificationAtom);
+};
+
+/**
+ * Atom for disable link sharing
+ */
+export const disableLinkSharingAtom = atom<boolean>(false);
+
+export const useDisableLinkSharing = (): UseAtom<typeof disableLinkSharingAtom> => {
+  return useAtom(disableLinkSharingAtom);
+};
+
+/**
+ * Atom for indent size forced
+ */
+export const isIndentSizeForcedAtom = atom<boolean>(false);
+
+export const useIsIndentSizeForced = (): UseAtom<typeof isIndentSizeForcedAtom> => {
+  return useAtom(isIndentSizeForcedAtom);
+};
+
+/**
+ * Atom for attach title header enabled
+ */
+export const isEnabledAttachTitleHeaderAtom = atom<boolean>(false);
+
+export const useIsEnabledAttachTitleHeader = (): UseAtom<typeof isEnabledAttachTitleHeaderAtom> => {
+  return useAtom(isEnabledAttachTitleHeaderAtom);
+};
+
+/**
+ * Atom for search service configured
+ */
+export const isSearchServiceConfiguredAtom = atom<boolean>(false);
+
+export const useIsSearchServiceConfigured = (): UseAtom<typeof isSearchServiceConfiguredAtom> => {
+  return useAtom(isSearchServiceConfiguredAtom);
+};
+
+/**
+ * Atom for search service reachable
+ */
+export const isSearchServiceReachableAtom = atom<boolean>(false);
+
+export const useIsSearchServiceReachable = (): UseAtom<typeof isSearchServiceReachableAtom> => {
+  return useAtom(isSearchServiceReachableAtom);
+};
+
+/**
+ * Atom for Slack configured
+ */
+export const isSlackConfiguredAtom = atom<boolean>(false);
+
+export const useIsSlackConfigured = (): UseAtom<typeof isSlackConfiguredAtom> => {
+  return useAtom(isSlackConfiguredAtom);
+};
+
+/**
+ * Atom for ACL enabled
+ */
+export const isAclEnabledAtom = atom<boolean>(false);
+
+export const useIsAclEnabled = (): UseAtom<typeof isAclEnabledAtom> => {
+  return useAtom(isAclEnabledAtom);
+};
+
+/**
+ * Atom for upload all file allowed
+ */
+export const isUploadAllFileAllowedAtom = atom<boolean>(false);
+
+export const useIsUploadAllFileAllowed = (): UseAtom<typeof isUploadAllFileAllowedAtom> => {
+  return useAtom(isUploadAllFileAllowedAtom);
+};
+
+/**
+ * Atom for upload enabled
+ */
+export const isUploadEnabledAtom = atom<boolean>(false);
+
+export const useIsUploadEnabled = (): UseAtom<typeof isUploadEnabledAtom> => {
+  return useAtom(isUploadEnabledAtom);
+};
+
+/**
+ * Atom for bulk export pages enabled
+ */
+export const isBulkExportPagesEnabledAtom = atom<boolean>(false);
+
+export const useIsBulkExportPagesEnabled = (): UseAtom<typeof isBulkExportPagesEnabledAtom> => {
+  return useAtom(isBulkExportPagesEnabledAtom);
+};
+
+/**
+ * Atom for PDF bulk export enabled
+ */
+export const isPdfBulkExportEnabledAtom = atom<boolean>(false);
+
+export const useIsPdfBulkExportEnabled = (): UseAtom<typeof isPdfBulkExportEnabledAtom> => {
+  return useAtom(isPdfBulkExportEnabledAtom);
+};
+
+/**
+ * Atom for local account registration enabled
+ */
+export const isLocalAccountRegistrationEnabledAtom = atom<boolean>(false);
+
+export const useIsLocalAccountRegistrationEnabled = (): UseAtom<typeof isLocalAccountRegistrationEnabledAtom> => {
+  return useAtom(isLocalAccountRegistrationEnabledAtom);
+};
+
+/**
+ * Atom for renderer config
+ */
+export const rendererConfigAtom = atom<RendererConfig>({
+  isEnabledLinebreaks: false,
+  isEnabledLinebreaksInComments: false,
+  isEnabledMarp: false,
+  adminPreferredIndentSize: 4,
+  isIndentSizeForced: false,
+  drawioUri: '',
+  plantumlUri: '',
+  highlightJsStyleBorder: false,
+  isEnabledXssPrevention: true,
+  sanitizeType: 'Recommended',
+  customTagWhitelist: [],
+  customAttrWhitelist: {},
+});
+
+export const useRendererConfig = (): UseAtom<typeof rendererConfigAtom> => {
+  return useAtom(rendererConfigAtom);
+};

+ 3 - 133
apps/app/src/stores-universal/context.tsx

@@ -6,9 +6,7 @@ import type { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import type { SupportedActionType } from '~/interfaces/activity';
 import type { SupportedActionType } from '~/interfaces/activity';
-import type { RendererConfig } from '~/interfaces/services/renderer';
-
-import { useIsGuestUser, useIsReadOnlyUser } from '../states/context';
+import { useIsUploadEnabled, useIsUploadAllFileAllowed } from '~/states/server-configurations/server-configurations';
 
 
 import { useContextSWR } from './use-context-swr';
 import { useContextSWR } from './use-context-swr';
 
 
@@ -20,18 +18,6 @@ declare global {
 type Nullable<T> = T | null;
 type Nullable<T> = T | null;
 
 
 
 
-export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
-};
-
-export const useIsForbidden = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
-};
-
-export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
-};
-
 export const useIsSharedUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsSharedUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR<boolean, Error>('isSharedUser', initialData);
   return useContextSWR<boolean, Error>('isSharedUser', initialData);
 };
 };
@@ -40,10 +26,6 @@ export const useShareLinkId = (initialData?: string): SWRResponse<string, Error>
   return useContextSWR('shareLinkId', initialData);
   return useContextSWR('shareLinkId', initialData);
 };
 };
 
 
-export const useDisableLinkSharing = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
-  return useContextSWR<Nullable<boolean>, Error>('disableLinkSharing', initialData);
-};
-
 export const useRegistrationWhitelist = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
 export const useRegistrationWhitelist = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
   return useContextSWR<Nullable<string[]>, Error>('registrationWhitelist', initialData);
   return useContextSWR<Nullable<string[]>, Error>('registrationWhitelist', initialData);
 };
 };
@@ -52,54 +34,10 @@ export const useIsSearchPage = (initialData?: Nullable<boolean>) : SWRResponse<N
   return useContextSWR<Nullable<boolean>, Error>('isSearchPage', initialData);
   return useContextSWR<Nullable<boolean>, Error>('isSearchPage', initialData);
 };
 };
 
 
-export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isAclEnabled', initialData);
-};
-
-export const useIsSearchServiceConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isSearchServiceConfigured', initialData);
-};
-
-export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isSearchServiceReachable', initialData);
-};
-
-export const useElasticsearchMaxBodyLengthToIndex = (initialData?: number) : SWRResponse<number, Error> => {
-  return useContextSWR('elasticsearchMaxBodyLengthToIndex', initialData);
-};
-
 export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR('isMailerSetup', initialData);
   return useContextSWR('isMailerSetup', initialData);
 };
 };
 
 
-export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData, { fallbackData: false });
-};
-
-export const useShowPageSideAuthors = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('showPageSideAuthors', initialData, { fallbackData: false });
-};
-
-export const useIsEnabledMarp = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isEnabledMarp', initialData, { fallbackData: false });
-};
-
-export const useIsSlackConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isSlackConfigured', initialData);
-};
-
-export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
-};
-
-export const useIsIndentSizeForced = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isIndentSizeForced', initialData, { fallbackData: false });
-};
-
-export const useDefaultIndentSize = (initialData?: number) : SWRResponse<number, Error> => {
-  return useContextSWR<number, Error>('defaultIndentSize', initialData, { fallbackData: 4 });
-};
-
 export const useAuditLogEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useAuditLogEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
   return useContextSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
 };
 };
@@ -112,38 +50,10 @@ export const useAuditLogAvailableActions = (initialData?: Array<SupportedActionT
   return useContextSWR<Array<SupportedActionType>, Error>('auditLogAvailableActions', initialData);
   return useContextSWR<Array<SupportedActionType>, Error>('auditLogAvailableActions', initialData);
 };
 };
 
 
-export const useIsEnabledStaleNotification = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isEnabledStaleNotification', initialData);
-};
-
-export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<RendererConfig, Error> => {
-  return useContextSWR('growiRendererConfig', initialData);
-};
-
-export const useIsAllReplyShown = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isAllReplyShown', initialData);
-};
-
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
   return useContextSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
 };
 };
 
 
-export const useIsUploadEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isUploadEnabled', initialData);
-};
-
-export const useIsUploadAllFileAllowed = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isUploadAllFileAllowed', initialData);
-};
-
-export const useIsBulkExportPagesEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isBulkExportPagesEnabled', initialData);
-};
-
-export const useIsPdfBulkExportEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isPdfBulkExportEnabled', initialData);
-};
-
 export const useShowPageLimitationL = (initialData?: number): SWRResponse<number, Error> => {
 export const useShowPageLimitationL = (initialData?: number): SWRResponse<number, Error> => {
   return useContextSWR('showPageLimitationL', initialData);
   return useContextSWR('showPageLimitationL', initialData);
 };
 };
@@ -164,31 +74,6 @@ export const useGrowiAppIdForGrowiCloud = (initialData?: number): SWRResponse<nu
   return useContextSWR('growiAppIdForGrowiCloud', initialData);
   return useContextSWR('growiAppIdForGrowiCloud', initialData);
 };
 };
 
 
-export const useIsContainerFluid = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isContainerFluid', initialData);
-};
-
-export const useIsLocalAccountRegistrationEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isLocalAccountRegistrationEnabled', initialData);
-};
-
-export const useIsRomUserAllowedToComment = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isRomUserAllowedToComment', initialData);
-};
-
-export const useIsAiEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isAiEnabled', initialData);
-};
-
-export const useLimitLearnablePageCountPerAssistant = (initialData?: number): SWRResponse<number, Error> => {
-  return useContextSWR('limitLearnablePageCountPerAssistant', initialData);
-};
-
-
-export const useIsUsersHomepageDeletionEnabled = (initialData?: boolean): SWRResponse<boolean, false> => {
-  return useContextSWR('isUsersHomepageDeletionEnabled', initialData);
-};
-
 export const useIsEnableUnifiedMergeView = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsEnableUnifiedMergeView = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useSWRStatic<boolean, Error>('isEnableUnifiedMergeView', initialData, { fallbackData: false });
   return useSWRStatic<boolean, Error>('isEnableUnifiedMergeView', initialData, { fallbackData: false });
 
 
@@ -198,24 +83,9 @@ export const useIsEnableUnifiedMergeView = (initialData?: boolean): SWRResponse<
  *                     Computed contexts
  *                     Computed contexts
  *********************************************************** */
  *********************************************************** */
 
 
-export const useIsEditable = (): SWRResponse<boolean, Error> => {
-  const [isGuestUser] = useIsGuestUser();
-  const [isReadOnlyUser] = useIsReadOnlyUser();
-  const { data: isForbidden } = useIsForbidden();
-  const { data: isNotCreatable } = useIsNotCreatable();
-  const { data: isIdenticalPath } = useIsIdenticalPath();
-
-  return useSWRImmutable(
-    ['isEditable', isGuestUser, isReadOnlyUser, isForbidden, isNotCreatable, isIdenticalPath],
-    ([, isGuestUser, isReadOnlyUser, isForbidden, isNotCreatable, isIdenticalPath]) => {
-      return (!isForbidden && !isIdenticalPath && !isNotCreatable && !isGuestUser && !isReadOnlyUser);
-    },
-  );
-};
-
 export const useAcceptedUploadFileType = (): SWRResponse<AcceptedUploadFileType, Error> => {
 export const useAcceptedUploadFileType = (): SWRResponse<AcceptedUploadFileType, Error> => {
-  const { data: isUploadEnabled } = useIsUploadEnabled();
-  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+  const [isUploadEnabled] = useIsUploadEnabled();
+  const [isUploadAllFileAllowed] = useIsUploadAllFileAllowed();
 
 
   return useSWRImmutable(
   return useSWRImmutable(
     ['acceptedUploadFileType', isUploadEnabled, isUploadAllFileAllowed],
     ['acceptedUploadFileType', isUploadEnabled, isUploadAllFileAllowed],