layouts.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. "use client"
  2. import Layout from 'app/components/layout';
  3. import { useCurrentUser, isWebAuthReady } from 'app/context/user'
  4. import { components } from 'app/components/registry';
  5. import { appSetting, getLayoutName, getPageContentPaddingTiers, getPageContentWidth, getPageData, strToObj } from 'app/lib/util'
  6. import emitter, { EVENTS } from 'app/context/emitter';
  7. import { responsiveClasses } from 'app/lib/responsive-classes'
  8. import Cell from 'app/components/cell';
  9. import { appStatic } from 'app/lib/app-static'
  10. import Redirect from 'app/ui/atoms/redirect'
  11. import { useRef, useEffect, useMemo, useState } from 'react';
  12. import PageByUrl from 'app/ui/molecules/page/page-by-url'
  13. import { registerAll } from 'app/components/registry-init';
  14. import { useWindowDimensions, Platform } from 'react-native';
  15. import { useSetWindowSize } from 'app/context/measure';
  16. import { coerceVersion, isBelowRange, isAboveRange } from 'app/lib/server-version';
  17. import { FormModalHost } from 'app/ui/molecules/dialogs/form-modal';
  18. import { UnsavedFormConfirmHost } from 'app/ui/molecules/dialogs/unsaved-form-confirm-host';
  19. import { VersionIncompatible, VersionWarning } from 'app/ui/molecules/system/version-notice';
  20. import { applySoundConfig } from 'app/lib/sound';
  21. function WindowSizeSync() {
  22. const { width, height } = useWindowDimensions();
  23. const setWindowSize = useSetWindowSize();
  24. useEffect(() => {
  25. setWindowSize(Math.round(width), Math.round(height));
  26. }, [width, height, setWindowSize]);
  27. }
  28. export default function Layouts({ path, data }) {
  29. const url = data?.url
  30. const urlRef = useRef(url);
  31. useEffect(() => {
  32. urlRef.current = url;
  33. }, [url]);
  34. // UNA's interface sounds settings come with every page; sounds read the last ones, never wait for them.
  35. const sounds = data?.sounds;
  36. useEffect(() => {
  37. applySoundConfig(sounds);
  38. }, [sounds]);
  39. const isWeb = Platform.OS === 'web';
  40. if (isWeb)
  41. registerAll();
  42. const pageData = useMemo(() => {
  43. if (!data) return data;
  44. const raw = data.config;
  45. if (typeof raw !== 'string' || !appSetting('layout', 'user_remote_config')) {
  46. return data;
  47. }
  48. const parsed = strToObj(raw);
  49. return parsed ? { ...data, config: parsed } : data;
  50. }, [data]);
  51. // When connections change, refetch the current page and pass JSON to listeners.
  52. useEffect(() => {
  53. const subscription = emitter.addListener(EVENTS.connections, async (payload) => {
  54. if (payload?.action !== 'changed' || payload?.reload === false) return;
  55. const pageUrl = urlRef.current;
  56. if (!pageUrl) return;
  57. const sResponse = await getPageData(pageUrl);
  58. if (sResponse?.data) {
  59. emitter.emit(EVENTS.page, { action: 'updated', data: sResponse.data });
  60. }
  61. });
  62. return () => subscription.remove();
  63. }, []);
  64. const { currentUser } = useCurrentUser();
  65. const layout = useMemo(() => {
  66. return getLayoutName(pageData, pageData?.uri?.toString());
  67. }, [pageData]);
  68. const v = coerceVersion(data.version);
  69. const minVersion = appSetting('config', 'min_server_version');
  70. const maxVersion = appSetting('config', 'stable_server_version');
  71. if (data.version && isBelowRange(data.version, minVersion)) {
  72. return <VersionIncompatible serverVersion={data.version} />;
  73. }
  74. const isVersionInfo = !!v && isAboveRange(data.version, maxVersion) && currentUser?.operator;
  75. return (
  76. <Layout layout={layout} data={pageData}>
  77. <PageLayoutContent layout={layout} path={path} data={pageData} />
  78. <WindowSizeSync />
  79. <UnsavedFormConfirmHost />
  80. <FormModalHost />
  81. {isVersionInfo ? <VersionWarning serverVersion={data.version} /> : null}
  82. </Layout>
  83. )
  84. }
  85. function ErrorPage({ type }) {
  86. const redirectKey = type === 404 ? 'redirect_on_not_found' : 'redirect_on_forbidden';
  87. const staticPageKey = type === 404 ? 'page_not_found' : 'page_not_allowed';
  88. const redirectUrl = useMemo(() => appSetting('layout', redirectKey), [redirectKey]);
  89. const redirectRef = useRef();
  90. useEffect(() => {
  91. if (redirectUrl) {
  92. redirectRef.current.redirect(redirectUrl);
  93. }
  94. }, [redirectUrl]);
  95. return (
  96. <>
  97. <Redirect ref={redirectRef} />
  98. {!redirectUrl && appStatic(staticPageKey)}
  99. </>
  100. );
  101. }
  102. /**
  103. * The page an account without a profile sees: UNA's account-profile-switcher, rendered
  104. * like any page (layout padding, block header with icon, title and description) rather
  105. * than PageByUrl, which embeds bare blocks for inline forms.
  106. */
  107. function NoProfilePage({ uri }) {
  108. const [page, setPage] = useState(null);
  109. useEffect(() => {
  110. let alive = true;
  111. getPageData(uri, false).then((sResponse) => {
  112. if (alive && sResponse?.data) setPage(sResponse.data);
  113. });
  114. return () => { alive = false; };
  115. }, [uri]);
  116. const layout = useMemo(() => (page ? getLayoutName(page, page.uri?.toString()) : null), [page]);
  117. if (!page || !layout) return null;
  118. return <PageLayoutContent layout={layout} data={page} skipProfileLock />;
  119. }
  120. function PageLayoutContent({ layout, data, skipProfileLock = false }) {
  121. const { currentUser } = useCurrentUser();
  122. const { layoutName, layoutBlocks, isCustomLayout, columnLayout = '' } = layout;
  123. // Derived values; React Compiler memoizes them on their real inputs.
  124. const pageConfig = data?.config;
  125. const pageClasses = {
  126. padding: responsiveClasses('padding', pageConfig?.padding ?? getPageContentPaddingTiers(layoutName)),
  127. gap: responsiveClasses('gap', pageConfig?.gap),
  128. width: appSetting('layout', 'max_width'),
  129. contentWidth: getPageContentWidth(layoutName, data?.uri),
  130. };
  131. const hasProfileInformer = currentUser?.informer?.some(
  132. item => item.id === "sys-account-profile-system"
  133. );
  134. const Component = components['layout'][layoutName];
  135. // Keep the wiki shell mounted across doc routes (registry key is layout_1_column_wiki).
  136. const isWikiLayout = layoutName === 'layout_1_column_wiki' || layoutName === 'wiki';
  137. const pageUri = data?.uri;
  138. const pageUrl = data?.url;
  139. const pageTimestamp = data?.timestamp;
  140. const componentKey = isWikiLayout
  141. ? `layout-${layoutName}`
  142. : `layout-${layoutName}-${pageUri || pageUrl || ''}-${pageTimestamp || ''}`;
  143. if (data?.page_status === 404 || data?.page_status === 403) {
  144. return <ErrorPage type={data?.page_status} />;
  145. }
  146. const ConfirmEmail = components['molecule']['confirm_email']
  147. // confirm_pending: email just confirmed, page data not refetched yet (sockets may already report a confirmed user)
  148. if (currentUser && (!currentUser.confirmed || currentUser.confirm_pending) && appSetting('layout', 'lock_unconfirmed')) {
  149. return <ConfirmEmail url={data.url} data={data} />;
  150. }
  151. if (!skipProfileLock && (hasProfileInformer || currentUser?.membership==2 && data.uri =='home') && appSetting('layout', 'lock_no_profile')) {
  152. if (currentUser?.menu?.items?.length > 1) {
  153. // UNA's account-profile-switcher page: its "create profile" block brings the
  154. // title, description and icon (editable in Studio) and the sys_add_profile menu.
  155. return <NoProfilePage uri="account-profile-switcher" />;
  156. }
  157. else {
  158. return <PageByUrl url={currentUser?.menu?.items[0].link} />;
  159. }
  160. }
  161. if (!Component || !isWebAuthReady(data, currentUser)) {
  162. return null;
  163. }
  164. const Splash = components['molecule']['splash']
  165. if (!currentUser && layoutName == 'home' && (data.layout == 'splash' || appSetting('layout', 'use_splash_page')))
  166. return <Splash data={data} />
  167. if (isCustomLayout && layoutBlocks) {
  168. return (
  169. <Component key={componentKey} layoutName={layoutName} columnLayout={columnLayout} data={data} blocks={layoutBlocks} pageClasses={pageClasses} />
  170. );
  171. }
  172. if (!data || !data.elements) {
  173. return null;
  174. }
  175. const cells = Object.keys(data.elements).map((key) => (
  176. <Cell key={key} uri={data?.uri} url={data.url} blocks={data.elements[key]} />
  177. ));
  178. return (
  179. <Component key={componentKey} layoutName={layoutName} columnLayout={columnLayout} data={data} pageClasses={pageClasses}>
  180. {cells}
  181. </Component>
  182. );
  183. }