layout.web.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import React, { useEffect, useCallback, useState } from 'react';
  2. import Footer from 'app/customization/nav/footer';
  3. import { Modal } from 'app/design/controls'
  4. import Informer from 'app/components/elements/informer';
  5. import Suggestions from 'app/ui/molecules/misc/suggestions';
  6. import AsyncWorker from 'app/ui/molecules/system/async-worker';
  7. import { View } from 'app/design/view';
  8. import { useCurrentUser } from 'app/context/user'
  9. import BottomSheet from 'app/ui/molecules/dialogs/bottomsheet-content';
  10. import { appSetting, storageClear, decodeText, isChatLayout } from 'app/lib/util'
  11. import { appStatic } from 'app/lib/app-static'
  12. import { scheduleOneSignalSubscription } from 'app/lib/platform/one-signal';
  13. import { useThemeName, useThemeValue } from 'app/design/theme';
  14. import { useTranslation } from 'react-i18next'
  15. import { useLayoutSettings } from 'app/context/layout-settings';
  16. import PopupModal from 'app/ui/molecules/dialogs/popup-modal'
  17. import { PageHeader } from 'app/ui/molecules/header/page-header';
  18. import { HeaderOptionsProvider } from 'app/ui/molecules/header/options';
  19. import { useFooter } from 'app/context/jotai/layout';
  20. import Script from 'next/script';
  21. const MemoizedContent = React.memo(({ currentUser, pageLayoutName, layoutName, data, children, blocks }) => {
  22. const [isModal, setIsModal] = useState(false);
  23. const { t } = useTranslation()
  24. useEffect(() => {
  25. if (currentUser === false && appSetting('layout', 'show_login_modal') > 0 && !['create-account', 'login','home', 'forgot-password', 'confirm-email'].includes(data.uri)) {
  26. const timeoutId = setTimeout(() => {
  27. setIsModal(true)
  28. }, appSetting('layout', 'show_login_modal'));
  29. return () => clearTimeout(timeoutId);
  30. }
  31. }, [currentUser]);
  32. const handleCloseModal = () => {
  33. setIsModal(false)
  34. }
  35. if (data.page_status == 503 || currentUser?.page_status == 503) {
  36. return <>
  37. {appStatic('maintenance_mode')}
  38. </>
  39. }
  40. return (
  41. <View className="w-full flex-1 bg-background">
  42. <a
  43. href="#main-content"
  44. className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[10000] focus:h-auto focus:w-auto focus:overflow-visible focus:whitespace-normal focus:rounded-lg focus:border focus:border-border focus:bg-card focus:px-4 focus:py-2.5 focus:text-sm focus:font-medium focus:text-card-foreground focus:shadow-md focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-ring"
  45. >
  46. Skip to content
  47. </a>
  48. <Suggestions />
  49. <AsyncWorker />
  50. <HeaderOptionsProvider>
  51. <PageHeader layoutName={layoutName} pageLayoutName={pageLayoutName} pageData={data} />
  52. <Content layoutName={layoutName} children={children} currentUser={currentUser} url={data?.url} />
  53. </HeaderOptionsProvider>
  54. <Footer />
  55. <BottomSheet />
  56. {isModal && (
  57. <Modal title={t('login_modal_title')} onVisible={isModal} onClose={() => handleCloseModal()}>
  58. <PopupModal />
  59. </Modal>
  60. )}
  61. </View>
  62. );
  63. });
  64. const metaAdder = (queryProperty, value) => {
  65. let element = document.querySelector(`meta[${queryProperty}]`);
  66. if (element) {
  67. element.setAttribute("content", value);
  68. } else {
  69. element = `<meta ${queryProperty} content="${value}" />`;
  70. document.head.insertAdjacentHTML("beforeend", element);
  71. }
  72. };
  73. export default function Layout(props) {
  74. const { currentUser } = useCurrentUser();
  75. const { layout, data, blocks, children } = props;
  76. const uri = data.uri;
  77. const { layoutName } = layout;
  78. const theme = useThemeName();
  79. // Set theme attributes - must be in useEffect for SSR compatibility
  80. useEffect(() => {
  81. const root = document.documentElement;
  82. root.setAttribute('theme', theme);
  83. root.setAttribute('data-theme', theme);
  84. }, [theme]);
  85. const handlePageShow = useCallback((event) => {
  86. storageClear();
  87. }, []);
  88. useEffect(() => {
  89. window.addEventListener('beforeunload', handlePageShow);
  90. return () => {
  91. window.removeEventListener('beforeunload', handlePageShow);
  92. };
  93. }, [handlePageShow]);
  94. useEffect(() => {
  95. if (!currentUser?.id) {
  96. return;
  97. }
  98. const addLinkTag = (rel, href, crossOrigin) => {
  99. const exists = document.querySelector(`link[rel="${rel}"][href="${href}"]`);
  100. if (exists) return;
  101. const link = document.createElement('link');
  102. link.rel = rel;
  103. link.href = href;
  104. if (crossOrigin) link.crossOrigin = crossOrigin;
  105. document.head.appendChild(link);
  106. };
  107. addLinkTag('preconnect', 'https://onesignal.com', 'anonymous');
  108. addLinkTag('preconnect', 'https://cdn.onesignal.com', 'anonymous');
  109. addLinkTag('dns-prefetch', 'https://onesignal.com');
  110. return scheduleOneSignalSubscription(currentUser, {
  111. askPermission: appSetting('notifications', 'onesignal_request_on_load') ?? appSetting('native', 'onesignal_request_on_load'),
  112. });
  113. }, [currentUser?.id]);
  114. function getFullOffsetTop(element) {
  115. let offsetTop = 0;
  116. while (element) {
  117. offsetTop += element.offsetTop;
  118. element = element.offsetParent;
  119. }
  120. return offsetTop;
  121. }
  122. function getTopOffset() {
  123. const fixedEls = document.querySelectorAll('.header-fixed');
  124. let sum = 0;
  125. fixedEls.forEach(el => {
  126. const rect = el.getBoundingClientRect();
  127. const style = getComputedStyle(el);
  128. const height = rect.height
  129. + parseFloat(style.marginTop || 0)
  130. + parseFloat(style.marginBottom || 0);
  131. sum += height;
  132. });
  133. return sum;
  134. }
  135. function clearFixedProcess(el) {
  136. el.classList.remove('is-fixed');
  137. el.style.position = '';
  138. el.style.top = '';
  139. el.style.bottom = '';
  140. el.style.width = '';
  141. el.style.left = '';
  142. }
  143. const { layoutName: pageLayoutName } = useLayoutSettings();
  144. useEffect(() => {
  145. if (data?.title) {
  146. if (appSetting('notifications', 'count_in_title')) {
  147. if (currentUser?.notifications > 0) {
  148. document.title = decodeText('(' + currentUser?.notifications + ') ' + data?.title);
  149. }
  150. else {
  151. document.title = decodeText(data?.title);
  152. }
  153. }
  154. else {
  155. document.title = decodeText(data?.title);
  156. }
  157. }
  158. metaAdder('property="og:title"', decodeText(data?.title))
  159. }, [currentUser?.notifications]);
  160. const themeSuffix = useThemeValue('', '_dark');
  161. const stylesBgImage = {
  162. backgroundAttachment: 'fixed',
  163. backgroundImage: appSetting('layout', `background_image${themeSuffix}`)
  164. };
  165. const stylesBg = {
  166. backgroundColor: appSetting('layout', `background_color${themeSuffix}`)
  167. };
  168. useEffect(() => {
  169. const applyStyles = styles => {
  170. for (const style in styles) {
  171. document.body.style[style] = styles[style];
  172. }
  173. };
  174. applyStyles(stylesBgImage);
  175. applyStyles(stylesBg);
  176. }, [stylesBgImage, stylesBg]);
  177. if (data?.empty)
  178. return <>{children}</>
  179. return <MemoizedContent pageLayoutName={pageLayoutName} blocks={blocks} currentUser={currentUser} layoutName={layoutName} data={data} children={children} uri={uri} />
  180. }
  181. const Content = React.memo(({ children, currentUser, layoutName, url }) => {
  182. const externalScripts = appSetting('layout', 'external_scripts') || [];
  183. const footer = useFooter();
  184. const isHideHeader = (appSetting('layout', 'hide_header_for_non_logged') && !currentUser) || appSetting('layout', 'hide_header_for_all');
  185. // Only reserve bottom space when the tab bar is actually rendered.
  186. // The tab bar renders for logged-in users (or when show_tabbar_on_mobile_non_logged is set)
  187. // and only when the footer atom is truthy. Avoids phantom scroll on auth/guest pages.
  188. const showBottomTabBar = footer && (currentUser || appSetting('layout', 'show_tabbar_on_mobile_non_logged'));
  189. const mainClassName = `${(!isChatLayout(layoutName) &&layoutName != 'post' && layoutName != 'task' && !isHideHeader && showBottomTabBar) ? ' pb-16 web:lg:pb-0 lg:pb-0 ' : ''} ns--main-content-- w-full mx-auto ne--`;
  190. const contentKey = (layoutName === 'layout_1_column_wiki' || layoutName === 'wiki')
  191. ? 'wiki'
  192. : url;
  193. return (
  194. <View className="ns--main-wrapper-- w-full mx-auto items-stretch cnt-root ne--" key={contentKey}>
  195. <main id="main-content" tabIndex={-1} className={`${mainClassName} outline-none scroll-mt-24`}>
  196. <Informer />
  197. {children}
  198. </main>
  199. {externalScripts.map((script) => {
  200. if (!script?.src) return null;
  201. const { src, strategy, ...attrs } = script;
  202. return (
  203. <Script
  204. key={src}
  205. src={src}
  206. strategy={strategy || "afterInteractive"}
  207. {...attrs}
  208. />
  209. );
  210. })}
  211. </View>
  212. );
  213. });