409 lines
13 KiB
JavaScript
409 lines
13 KiB
JavaScript
import PropTypes from "prop-types";
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { Alert } from "antd";
|
|
import { useMediaQuery } from "react-responsive";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { Element, scroller } from "react-scroll";
|
|
import axios from "axios";
|
|
import Page from "./components/Page";
|
|
import Images from "./components/ImageSidebar";
|
|
import { ImageProvider, useImageContext } from "./contexts/ImageContext";
|
|
import LoadingModal from "./components/LoadingModal";
|
|
import { ActionProvider } from "./contexts/ActionContext";
|
|
import SubPage from "./components/SubPage";
|
|
import PropertyPage from "./components/PropertyPage";
|
|
import { MenuProvider } from "./contexts/MenuContext";
|
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
|
import {
|
|
SettingsProvider,
|
|
useSettingsContext,
|
|
} from "./contexts/SettingsContext";
|
|
const apiUrl = import.meta.env.VITE_API_URL;
|
|
|
|
// Component that handles image loading after API data is fetched
|
|
const AppContent = ({ pages, properties, images }) => {
|
|
const { loadImages } = useImageContext();
|
|
const [loadedOnce, setLoadedOnce] = useState(false);
|
|
const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" });
|
|
const [currentTheme, setCurrentTheme] = useState();
|
|
const [currentProperty, setCurrentProperty] = useState(null);
|
|
const [currentPageIdx, setCurrentPageIdx] = useState(0);
|
|
const [contentVisible, setContentVisible] = useState(true);
|
|
const [subPageVisible, setSubPageVisible] = useState(false);
|
|
const locationRef = useRef();
|
|
const previousPageRef = useRef(null);
|
|
const currentPageRef = useRef(currentPage);
|
|
const landingPages = pages.filter((page) => page.pageType == "landingPage");
|
|
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const settings = useSettingsContext();
|
|
const isMobile = useMediaQuery({ maxWidth: 800 });
|
|
|
|
// Load images when they become available
|
|
useEffect(() => {
|
|
if (!loadedOnce && images && images.length > 0) {
|
|
loadImages(images);
|
|
setLoadedOnce(true);
|
|
}
|
|
}, [images, loadImages, loadedOnce]);
|
|
|
|
const setPageTitle = (name) => {
|
|
document.title = name ? `${name} - The Hideout` : "The Hideout";
|
|
};
|
|
|
|
// Handle direct URL navigation
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useEffect(() => {
|
|
locationRef.current = location.pathname;
|
|
if (Object.keys(settings).length == 0) {
|
|
return;
|
|
}
|
|
// Check if the URL matches /properties/:slug
|
|
if (location.pathname.startsWith("/properties/")) {
|
|
const propertySlug = location.pathname.split("/properties/")[1];
|
|
if (propertySlug != "") {
|
|
const property = properties.find((p) => p.slug === propertySlug);
|
|
if (property) {
|
|
// Set previousPage when navigating to a property
|
|
if (currentPageRef.current.pageType !== "subPage") {
|
|
previousPageRef.current = { ...currentPageRef.current };
|
|
}
|
|
|
|
setCurrentProperty(property);
|
|
setContentVisible(true);
|
|
if (currentProperty != null || currentPage.pageType == "subPage") {
|
|
setSubPageVisible(false);
|
|
setTimeout(() => {
|
|
setSubPageVisible(true);
|
|
}, 500);
|
|
} else {
|
|
setSubPageVisible(true);
|
|
}
|
|
setPageTitle(property.name);
|
|
} else {
|
|
navigate(settings.redirects["404"]);
|
|
setCurrentProperty(null); // property not found
|
|
}
|
|
return; // exit early since we're on a property page
|
|
}
|
|
}
|
|
setCurrentProperty(null);
|
|
if (pages.length > 0 && location.pathname !== "/") {
|
|
const slug = location.pathname.slice(1); // Remove leading slash
|
|
const page = pages.find((p) => p.slug === slug);
|
|
|
|
if (page) {
|
|
// Set previousPage when navigating to a subpage
|
|
if (
|
|
page.pageType === "subPage" &&
|
|
currentPageRef.current.pageType !== "subPage"
|
|
) {
|
|
previousPageRef.current = { ...currentPageRef.current };
|
|
}
|
|
|
|
setCurrentPage(page);
|
|
setCurrentPageIdx(pages.indexOf(page));
|
|
setPageTitle(page.name);
|
|
|
|
if (page.pageType === "landingPage") {
|
|
// Ensure DOM has rendered before attempting to scroll
|
|
setSubPageVisible(false);
|
|
requestAnimationFrame(() => {
|
|
scroller.scrollTo(slug, {
|
|
duration: 0,
|
|
delay: 0,
|
|
smooth: "easeInOutQuart",
|
|
containerId: "app-container",
|
|
});
|
|
});
|
|
} else {
|
|
setContentVisible(false);
|
|
if (currentPage.pageType == page.pageType) {
|
|
setSubPageVisible(false);
|
|
setTimeout(() => {
|
|
setSubPageVisible(true);
|
|
}, 500);
|
|
} else {
|
|
setSubPageVisible(true);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
setContentVisible(true);
|
|
}, 600);
|
|
}
|
|
} else {
|
|
navigate(settings.redirects["404"]);
|
|
}
|
|
} else if (
|
|
pages.length > 0 &&
|
|
location.pathname === "/" &&
|
|
settings?.redirects?.index != null
|
|
) {
|
|
console.log("settings.redirects", settings.redirects);
|
|
const indexPage = pages.find((p) => p.slug === settings.redirects.index);
|
|
// Default to index page
|
|
setCurrentPage(indexPage);
|
|
setCurrentPageIdx(0);
|
|
setPageTitle(indexPage.name);
|
|
navigate(`/${indexPage.slug}`, { replace: true });
|
|
}
|
|
}, [
|
|
location.pathname,
|
|
pages,
|
|
settings?.redirects?.index,
|
|
navigate,
|
|
settings,
|
|
properties,
|
|
currentPage.pageType,
|
|
currentProperty,
|
|
]);
|
|
|
|
// Set up scroll spy to update URL when scrolling
|
|
useEffect(() => {
|
|
if (currentPage.pageType == "subPage") {
|
|
return;
|
|
}
|
|
if (pages.length > 0) {
|
|
let scrollTimeout;
|
|
|
|
const handleScrollSpy = () => {
|
|
const container = document.getElementById("app-container");
|
|
if (!container) return;
|
|
|
|
const scrollTop = container.scrollTop;
|
|
const containerHeight = container.clientHeight;
|
|
const viewportCenter = scrollTop + containerHeight / 2;
|
|
|
|
// Find which page is currently in the center of the viewport
|
|
let currentPageIndex = 0;
|
|
let minDistance = Infinity;
|
|
|
|
pages.forEach((page, index) => {
|
|
const element = document.querySelector(`[data-name="${page.slug}"]`);
|
|
if (element) {
|
|
const elementTop = element.offsetTop;
|
|
//const elementBottom = elementTop + element.offsetHeight;
|
|
const elementCenter = elementTop + element.offsetHeight / 2;
|
|
|
|
const distance = Math.abs(viewportCenter - elementCenter);
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
currentPageIndex = index;
|
|
if (minDistance != 0) {
|
|
setContentVisible(false);
|
|
}
|
|
if (minDistance == 0) {
|
|
setContentVisible(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Debounce both current page state and URL updates to reduce frequency
|
|
clearTimeout(scrollTimeout);
|
|
scrollTimeout = setTimeout(() => {
|
|
if (pages[currentPageIndex] && pages[currentPageIndex].slug) {
|
|
// Update current page state for Images component
|
|
if (currentPageIdx !== currentPageIndex) {
|
|
setCurrentPage(pages[currentPageIndex]);
|
|
setCurrentPageIdx(currentPageIndex);
|
|
setPageTitle(pages[currentPageIndex].name);
|
|
|
|
const newPath = `/${pages[currentPageIndex].slug}`;
|
|
if (locationRef.current !== newPath) {
|
|
navigate(newPath, { replace: true });
|
|
}
|
|
}
|
|
}
|
|
}, 100); // Debounce for 100ms
|
|
};
|
|
|
|
const container = document.getElementById("app-container");
|
|
if (container) {
|
|
container.addEventListener("scroll", handleScrollSpy, {
|
|
passive: true,
|
|
});
|
|
return () => {
|
|
container.removeEventListener("scroll", handleScrollSpy);
|
|
clearTimeout(scrollTimeout);
|
|
};
|
|
}
|
|
}
|
|
}, [pages, navigate, currentPageIdx, currentPage.pageType]);
|
|
|
|
// Update currentPageRef whenever currentPage changes
|
|
useEffect(() => {
|
|
currentPageRef.current = currentPage;
|
|
}, [currentPage]);
|
|
|
|
// Set body background color to match current page's theme
|
|
useEffect(() => {
|
|
const theme = settings.themes.find(
|
|
(theme) => theme.name === currentPage?.theme
|
|
);
|
|
setCurrentTheme(theme);
|
|
}, [currentPage, settings.themes]);
|
|
|
|
// Handle subpage close with smart navigation
|
|
const handleSubPageClose = () => {
|
|
// Check if there's a previous page stored in ref
|
|
if (previousPageRef.current && previousPageRef.current.slug) {
|
|
// Navigate to the previous page's slug
|
|
navigate(`/${previousPageRef.current.slug}`);
|
|
} else {
|
|
// No previous page, redirect to home
|
|
navigate("/");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ThemeProvider currentTheme={currentTheme}>
|
|
<MenuProvider pages={pages} currentPageSlug={currentPage?.slug}>
|
|
<div
|
|
className={
|
|
!isMobile
|
|
? "th-app-container"
|
|
: "th-app-container th-app-container-mobile"
|
|
}
|
|
id="app-container"
|
|
>
|
|
{landingPages.map((pageData, index) => (
|
|
<Element
|
|
key={index}
|
|
name={pageData.slug}
|
|
className={
|
|
!isMobile
|
|
? "th-page-wrapper"
|
|
: "th-page-wrapper th-page-wrapper-mobile"
|
|
}
|
|
data-name={pageData.slug}
|
|
>
|
|
<Page
|
|
pageData={pageData}
|
|
visible={
|
|
contentVisible && pageData.notionId == currentPage.notionId
|
|
}
|
|
theme={settings.themes.find(
|
|
(theme) => theme.name === pageData.theme
|
|
)}
|
|
themes={settings.themes}
|
|
isSubPage={false}
|
|
id={index}
|
|
/>
|
|
</Element>
|
|
))}
|
|
</div>
|
|
<SubPage
|
|
visible={currentPage.pageType == "subPage" && subPageVisible == true}
|
|
>
|
|
<Page
|
|
pageData={currentPage}
|
|
visible={contentVisible}
|
|
theme={settings.themes.find(
|
|
(theme) => theme.name === currentPage.theme
|
|
)}
|
|
themes={settings.themes}
|
|
isSubPage={true}
|
|
id={currentPageIdx}
|
|
showClose={true}
|
|
showMenu={false}
|
|
onClose={handleSubPageClose}
|
|
/>
|
|
</SubPage>
|
|
<SubPage visible={currentProperty != null && subPageVisible == true}>
|
|
<PropertyPage
|
|
propertyData={currentProperty}
|
|
visible={contentVisible}
|
|
onClose={() => {
|
|
navigate("/" + settings.redirects.properties);
|
|
}}
|
|
/>
|
|
</SubPage>
|
|
{isMobile && <div className="th-mobile-overlay" />}
|
|
|
|
{!isMobile && (
|
|
<Images
|
|
imageCollections={[...pages, ...properties]}
|
|
currentSlug={currentProperty?.slug || currentPage?.slug}
|
|
/>
|
|
)}
|
|
</MenuProvider>
|
|
</ThemeProvider>
|
|
);
|
|
};
|
|
|
|
AppContent.propTypes = {
|
|
images: PropTypes.shape({
|
|
length: PropTypes.number,
|
|
}),
|
|
pages: PropTypes.array.isRequired,
|
|
properties: PropTypes.array.isRequired,
|
|
themes: PropTypes.shape({
|
|
find: PropTypes.func,
|
|
}),
|
|
};
|
|
|
|
const App = () => {
|
|
const [pages, setPages] = useState([]);
|
|
const [properties, setProperties] = useState([]);
|
|
const [images, setImages] = useState([]);
|
|
const [settings, setSettings] = useState({
|
|
themes: [],
|
|
redirects: {},
|
|
branding: [],
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
const actions = {};
|
|
|
|
useEffect(() => {
|
|
const fetchPages = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await axios.get(`${apiUrl}/content`);
|
|
setPages(response.data.pages || response.data); // Handle both array and object with pages property
|
|
setProperties(response.data.properties || response.data);
|
|
setImages(response.data.images || response.data);
|
|
setSettings(response.data.settings || response.data);
|
|
} catch (err) {
|
|
setError(
|
|
err.response?.data?.message || err.message || "Failed to fetch pages"
|
|
);
|
|
console.error("Error fetching pages:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchPages();
|
|
}, []);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="th-error-container">
|
|
<Alert
|
|
message="Error Loading Pages"
|
|
description={error}
|
|
type="error"
|
|
showIcon
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SettingsProvider settings={settings}>
|
|
<ActionProvider onAction={actions}>
|
|
<ImageProvider>
|
|
<AppContent pages={pages} images={images} properties={properties} />
|
|
<LoadingModal visible={loading} />
|
|
</ImageProvider>
|
|
</ActionProvider>
|
|
</SettingsProvider>
|
|
);
|
|
};
|
|
|
|
export default App;
|