blob: 984e13f40c055d8da9cb30713c17fe25c721e639 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, memo, useEffect } from 'react';
import { Outlet, useLocation, ScrollRestoration } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import {
toastStore,
loginToContinueStore,
errorCodeStore,
siteLealStore,
} from '@/stores';
import {
Header,
Toast,
Customize,
CustomizeTheme,
PageTags,
HttpErrorContent,
} from '@/components';
import { LoginToContinueModal, BadgeModal } from '@/components/Modal';
import { changeTheme, Storage, scrollToElementTop } from '@/utils';
import { useQueryNotificationStatus } from '@/services';
import { useExternalToast } from '@/hooks';
import { EXTERNAL_CONTENT_DISPLAY_MODE } from '@/common/constants';
const Layout: FC = () => {
const location = useLocation();
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const externalToast = useExternalToast();
const externalContentDisplay = siteLealStore(
(state) => state.external_content_display,
);
const closeToast = () => {
toastClear();
};
const { code: httpStatusCode, reset: httpStatusReset } = errorCodeStore();
const { show: showLoginToContinueModal } = loginToContinueStore();
const { data: notificationData } = useQueryNotificationStatus();
useEffect(() => {
// handle footnote links
const fixFootnoteLinks = () => {
const footnoteLinks = document.querySelectorAll(
'a[href^="#"]:not([data-footnote-fixed])',
);
footnoteLinks.forEach((link) => {
link.setAttribute('data-footnote-fixed', 'true');
const href = link.getAttribute('href');
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = href?.substring(1) || '';
const targetElement = document.getElementById(targetId);
if (targetElement) {
window.history.pushState(null, '', `${location.pathname}${href}`);
scrollToElementTop(targetElement);
}
});
});
if (window.location.hash) {
const { hash } = window.location;
const targetElement = document.getElementById(hash.substring(1));
if (targetElement) {
setTimeout(() => {
scrollToElementTop(targetElement);
}, 100);
}
}
};
fixFootnoteLinks();
const observer = new MutationObserver(() => {
fixFootnoteLinks();
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['id', 'href'],
});
const handleHashChange = () => {
if (window.location.hash) {
const { hash } = window.location;
const targetElement = document.getElementById(hash.substring(1));
if (targetElement) {
setTimeout(() => {
scrollToElementTop(targetElement);
}, 100);
}
}
};
window.addEventListener('hashchange', handleHashChange);
return () => {
observer.disconnect();
window.removeEventListener('hashchange', handleHashChange);
};
}, [location.pathname]);
useEffect(() => {
httpStatusReset();
}, [location]);
useEffect(() => {
const systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
function handleSystemThemeChange(event) {
if (event.matches) {
changeTheme('dark');
} else {
changeTheme('light');
}
}
systemThemeQuery.addListener(handleSystemThemeChange);
return () => {
systemThemeQuery.removeListener(handleSystemThemeChange);
};
}, []);
const replaceImgSrc = () => {
const storageUserExternalMode = Storage.get(EXTERNAL_CONTENT_DISPLAY_MODE);
const images = document.querySelectorAll(
'img:not([data-processed])',
) as NodeListOf<HTMLImageElement>;
images.forEach((img) => {
// Mark as processed to avoid duplication
img.setAttribute('data-processed', 'true');
if (
img.src &&
storageUserExternalMode !== 'always' &&
!img.src.startsWith('/') &&
!img.src.startsWith('data:') &&
!img.src.startsWith('blob:') &&
!img.src.startsWith(window.location.origin)
) {
externalToast.onShow();
img.dataset.src = img.src;
img.removeAttribute('src');
}
});
};
useEffect(() => {
// Controlling the loading of external image resources
const observer = new MutationObserver((mutationsList) => {
let hasNewImages = false;
mutationsList.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (
node.nodeName === 'IMG' ||
(node.nodeType === 1 &&
(node as Element).querySelectorAll('img:not([data-processed])')
.length > 0)
) {
hasNewImages = true;
}
});
}
});
if (hasNewImages) {
replaceImgSrc();
}
});
if (externalContentDisplay !== 'always_display') {
observer.observe(document.body, { childList: true, subtree: true });
}
return () => observer.disconnect();
}, [externalContentDisplay]);
return (
<HelmetProvider>
<PageTags />
<CustomizeTheme />
<SWRConfig
value={{
revalidateOnFocus: false,
}}>
<Header />
<div className="position-relative page-wrap d-flex flex-column flex-fill">
{httpStatusCode ? (
<HttpErrorContent httpCode={httpStatusCode} />
) : (
<Outlet />
)}
</div>
<Toast msg={toastMsg} variant={variant} onClose={closeToast} />
<Customize />
<LoginToContinueModal visible={showLoginToContinueModal} />
<BadgeModal
badge={notificationData?.badge_award}
visible={Boolean(notificationData?.badge_award)}
/>
<ScrollRestoration />
</SWRConfig>
</HelmetProvider>
);
};
export default memo(Layout);