Update environment variables, refactor context usage, and enhance UI components
- Changed API and Keycloak URLs in `.env` and `.env.development`. - Updated `vite.config.js` to reflect new allowed hosts. - Refactored components to use `useContent` context instead of individual contexts for blogs, companies, and projects. - Improved error handling in `App` component with `AppError`. - Added FPS monitoring to `ParticlesBackground` component for developer mode. - Removed unused contexts and adjusted related imports. - Enhanced styling for various components and added new styles for FPS monitor. - Cleaned up SVG files by removing unnecessary attributes.
This commit is contained in:
parent
cc6582923a
commit
8ef109b8e7
7
.env
7
.env
@ -1,2 +1,5 @@
|
|||||||
VITE_API_URL=https://api.thehideoutltd.com
|
VITE_API_URL=https://api2026.tombutcher.work
|
||||||
VITE_TURNSTILE_KEY=0x4AAAAAAB2uebWFPXaK8spB
|
VITE_TURNSTILE_KEY=0x4AAAAAAA_bc3QTrE68whtg
|
||||||
|
VITE_KEYCLOAK_URL=https://auth.tombutcher.work
|
||||||
|
VITE_KEYCLOAK_REALM=master
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID=2026-web-client
|
||||||
@ -1,2 +1,5 @@
|
|||||||
VITE_API_URL=https://thehideout.tombutcher.work/api
|
VITE_API_URL=https://dev.tombutcher.work/api
|
||||||
VITE_TURNSTILE_KEY=0x4AAAAAAB2dBq6i8m4kYzDm
|
VITE_TURNSTILE_KEY=0x4AAAAAAA_bc3QTrE68whtg
|
||||||
|
VITE_KEYCLOAK_URL=https://auth.tombutcher.work
|
||||||
|
VITE_KEYCLOAK_REALM=master
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID=dev-web-client
|
||||||
1
assets/errorcloudicon.svg
Normal file
1
assets/errorcloudicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.0 KiB |
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<svg width="16px" height="16px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
<svg viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
|
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
|
||||||
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
|
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
|
||||||
<path d="M0,29.061C0,30.199 0.474,31.533 1.689,32.681L26.424,55.923C28.054,57.447 29.405,58.123 31.083,58.123C33.494,58.123 35.262,56.329 35.262,53.949L35.262,42.08L60.317,42.08C65.141,42.08 67.966,39.332 67.966,34.565L67.966,23.645C67.966,18.878 65.141,16.119 60.317,16.119L35.262,16.119L35.262,4.307C35.262,1.927 33.494,0 31.031,0C29.376,0 28.216,0.679 26.424,2.359L1.678,25.422C0.442,26.595 0,27.875 0,29.061Z" style="fill:var(--th-textColor);fill-opacity:0.85;fill-rule:nonzero;"/>
|
<path d="M0,29.061C0,30.199 0.474,31.533 1.689,32.681L26.424,55.923C28.054,57.447 29.405,58.123 31.083,58.123C33.494,58.123 35.262,56.329 35.262,53.949L35.262,42.08L60.317,42.08C65.141,42.08 67.966,39.332 67.966,34.565L67.966,23.645C67.966,18.878 65.141,16.119 60.317,16.119L35.262,16.119L35.262,4.307C35.262,1.927 33.494,0 31.031,0C29.376,0 28.216,0.679 26.424,2.359L1.678,25.422C0.442,26.595 0,27.875 0,29.061Z" style="fill:var(--th-textColor);fill-opacity:0.85;fill-rule:nonzero;"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
10
assets/reloadicon.svg
Normal file
10
assets/reloadicon.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 64 65" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g id="Artboard1" transform="matrix(1,0,0,1.015625,0,0)">
|
||||||
|
<rect x="0" y="0" width="64" height="64" style="fill:none;"/>
|
||||||
|
<g transform="matrix(0.812835,0,0,0.80033,0,3.938462)">
|
||||||
|
<path d="M76.037,26.688L59.287,26.688C56.568,26.688 55.943,29.406 57.38,31.25L65.787,42C66.755,43.219 68.599,43.219 69.537,42L77.974,31.25C79.443,29.406 78.787,26.688 76.037,26.688ZM39.38,11.125C52.099,11.125 62.38,21.406 62.38,34.188C62.38,37.25 64.88,39.75 67.943,39.75C71.005,39.75 73.505,37.25 73.505,34.188C73.505,15.25 58.224,0 39.38,0C32.443,0 26.068,2.094 20.755,5.594C17.599,7.625 17.537,11.219 19.193,13.562C20.724,15.688 23.693,16.812 27.193,14.719C30.662,12.438 34.849,11.125 39.38,11.125ZM2.693,41.562L19.474,41.562C22.162,41.562 22.787,38.844 21.38,37L12.943,26.25C11.974,25.031 10.162,25.031 9.224,26.25L0.755,37C-0.713,38.844 -0.026,41.562 2.693,41.562ZM39.38,57.125C26.662,57.125 16.349,46.844 16.349,34.062C16.349,30.969 13.88,28.5 10.787,28.5C7.724,28.5 5.224,30.969 5.224,34.031C5.224,52.969 20.505,68.25 39.38,68.25C46.318,68.25 52.693,66.156 57.974,62.656C61.13,60.594 61.193,57.031 59.537,54.688C58.005,52.562 55.037,51.406 51.537,53.531C48.068,55.781 43.88,57.125 39.38,57.125Z" style="fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<svg width="16px" height="16px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
<svg viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
|
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
|
||||||
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
|
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
|
||||||
<g transform="matrix(-1,0,0,1,71.0587,0)">
|
<g transform="matrix(-1,0,0,1,71.0587,0)">
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@ -21,7 +21,7 @@
|
|||||||
name="apple-mobile-web-app-status-bar-style"
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
content="black-translucent"
|
content="black-translucent"
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/fonts.css" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Tom Butcher</title>
|
<title>Tom Butcher</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
417
src/App.jsx
417
src/App.jsx
@ -1,11 +1,7 @@
|
|||||||
import PropTypes from "prop-types";
|
|
||||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
import { Alert } from "antd";
|
|
||||||
import { useMediaQuery } from "react-responsive";
|
import { useMediaQuery } from "react-responsive";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { Element, scroller } from "react-scroll";
|
import { Element, scroller } from "react-scroll";
|
||||||
import axios from "axios";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import Page from "./components/Page";
|
import Page from "./components/Page";
|
||||||
import { ImageProvider, useImageContext } from "./contexts/ImageContext";
|
import { ImageProvider, useImageContext } from "./contexts/ImageContext";
|
||||||
import LoadingModal from "./components/LoadingModal";
|
import LoadingModal from "./components/LoadingModal";
|
||||||
@ -16,23 +12,22 @@ import ProjectPage from "./components/Projects/ProjectPage";
|
|||||||
import ExperiencePage from "./components/Experience/ExperiencePage";
|
import ExperiencePage from "./components/Experience/ExperiencePage";
|
||||||
import { MenuProvider, useMenu } from "./contexts/MenuContext";
|
import { MenuProvider, useMenu } from "./contexts/MenuContext";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import {
|
|
||||||
SettingsProvider,
|
|
||||||
useSettingsContext,
|
|
||||||
} from "./contexts/SettingsContext";
|
|
||||||
import { BlogsProvider } from "./contexts/BlogsContext";
|
|
||||||
import { ProjectsProvider } from "./contexts/ProjectsContext";
|
|
||||||
import { CompaniesProvider } from "./contexts/CompaniesContext";
|
|
||||||
import { KeycloakProvider } from "./contexts/KeycloakContext";
|
import { KeycloakProvider } from "./contexts/KeycloakContext";
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
import { VideoProvider } from "./contexts/VideoContext";
|
||||||
import { FileProvider } from "./contexts/FileContext";
|
import { FileProvider } from "./contexts/FileContext";
|
||||||
|
import { MessageProvider } from "./contexts/MessageContext";
|
||||||
|
import { DeveloperProvider, useDeveloper } from "./contexts/DeveloperContext";
|
||||||
|
import { DeveloperMenuProvider } from "./contexts/DeveloperMenuContext";
|
||||||
|
import { ContentProvider, useContent } from "./contexts/ContentContext";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import Footer from "./components/Footer";
|
import Footer from "./components/Footer";
|
||||||
import { AccountProvider, useAccount } from "./contexts/AccountContext";
|
import { AccountProvider, useAccount } from "./contexts/AccountContext";
|
||||||
const apiUrl = import.meta.env.VITE_API_URL;
|
import AppError from "./components/AppError";
|
||||||
|
|
||||||
// Component that handles image loading after API data is fetched
|
// Component that handles image loading after API data is fetched
|
||||||
const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
|
const AppContent = () => {
|
||||||
|
const { pages, blogs, images, projects, companies, settings, isLoading } =
|
||||||
|
useContent();
|
||||||
const { loadImages } = useImageContext();
|
const { loadImages } = useImageContext();
|
||||||
const [loadedOnce, setLoadedOnce] = useState(false);
|
const [loadedOnce, setLoadedOnce] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" });
|
const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" });
|
||||||
@ -48,6 +43,7 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
|
|||||||
const [currentBlog, setCurrentBlog] = useState(null);
|
const [currentBlog, setCurrentBlog] = useState(null);
|
||||||
const [currentProject, setCurrentProject] = useState(null);
|
const [currentProject, setCurrentProject] = useState(null);
|
||||||
const [currentCompany, setCurrentCompany] = useState(null);
|
const [currentCompany, setCurrentCompany] = useState(null);
|
||||||
|
const { developerMode } = useDeveloper();
|
||||||
const [currentPageIdx, setCurrentPageIdx] = useState(0);
|
const [currentPageIdx, setCurrentPageIdx] = useState(0);
|
||||||
const [nextPageIdx, setNextPageIdx] = useState(0);
|
const [nextPageIdx, setNextPageIdx] = useState(0);
|
||||||
const [blogVisible, setBlogVisible] = useState(false);
|
const [blogVisible, setBlogVisible] = useState(false);
|
||||||
@ -72,7 +68,6 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const settings = useSettingsContext();
|
|
||||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||||
|
|
||||||
const [skipAnimation, setSkipAnimation] = useState(true);
|
const [skipAnimation, setSkipAnimation] = useState(true);
|
||||||
@ -87,13 +82,13 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
|
|||||||
}, [settings?.themes]);
|
}, [settings?.themes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading == false) {
|
if (isLoading == false) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSkipAnimation(false);
|
setSkipAnimation(false);
|
||||||
console.log("skipAnimation", loading);
|
console.log("skipAnimation", isLoading);
|
||||||
}, 500); // Reduced from 2000ms to 500ms
|
}, 500); // Reduced from 2000ms to 500ms
|
||||||
}
|
}
|
||||||
}, [loading]);
|
}, [isLoading]);
|
||||||
|
|
||||||
// Check for iOS webclip and set body className
|
// Check for iOS webclip and set body className
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -620,164 +615,195 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider currentTheme={currentTheme}>
|
<ThemeProvider currentTheme={currentTheme}>
|
||||||
<Header
|
<DeveloperMenuProvider
|
||||||
large={headerLarge}
|
developerMode={developerMode}
|
||||||
pageData={nextPage}
|
currentPage={currentPage}
|
||||||
theme={currentTheme}
|
currentSubPage={currentSubPage}
|
||||||
onMenuToggle={handleMenuToggle}
|
currentBlog={currentBlog}
|
||||||
menuToggled={menuToggled}
|
currentProject={currentProject}
|
||||||
isMobile={isMobile}
|
currentCompany={currentCompany}
|
||||||
/>
|
subPageVisible={subPageVisible}
|
||||||
<div
|
blogVisible={blogVisible}
|
||||||
className={`tb-app-container ${
|
projectVisible={projectVisible}
|
||||||
!isMobile ? "tb-app-container-desktop" : "tb-app-container-mobile"
|
experienceVisible={experienceVisible}
|
||||||
} ${
|
>
|
||||||
blogVisible == false &&
|
<Header
|
||||||
projectVisible == false &&
|
large={headerLarge}
|
||||||
subPageVisible == false &&
|
pageData={nextPage}
|
||||||
experienceVisible == false &&
|
theme={currentTheme}
|
||||||
landingPages.length > 0 &&
|
onMenuToggle={handleMenuToggle}
|
||||||
skipAnimation == false
|
menuToggled={menuToggled}
|
||||||
? "tb-visible"
|
isMobile={isMobile}
|
||||||
: "tb-hidden"
|
/>
|
||||||
}
|
<div
|
||||||
|
className={`tb-app-container ${
|
||||||
|
!isMobile ? "tb-app-container-desktop" : "tb-app-container-mobile"
|
||||||
|
} ${
|
||||||
|
blogVisible == false &&
|
||||||
|
projectVisible == false &&
|
||||||
|
subPageVisible == false &&
|
||||||
|
experienceVisible == false &&
|
||||||
|
landingPages.length > 0 &&
|
||||||
|
skipAnimation == false
|
||||||
|
? "tb-visible"
|
||||||
|
: "tb-hidden"
|
||||||
|
}
|
||||||
${skipAnimation ? "tb-skip-animation" : ""}
|
${skipAnimation ? "tb-skip-animation" : ""}
|
||||||
`}
|
`}
|
||||||
id="app-container"
|
id="app-container"
|
||||||
>
|
>
|
||||||
{landingPages.map((pageData, index) => (
|
{landingPages.map((pageData, index) => (
|
||||||
<Element
|
<Element
|
||||||
key={pageData.slug}
|
key={pageData.slug}
|
||||||
name={pageData.slug}
|
name={pageData.slug}
|
||||||
className={
|
className={
|
||||||
!isMobile
|
!isMobile
|
||||||
? "tb-page-wrapper"
|
? "tb-page-wrapper"
|
||||||
: "tb-page-wrapper tb-page-wrapper-mobile"
|
: "tb-page-wrapper tb-page-wrapper-mobile"
|
||||||
}
|
|
||||||
data-name={pageData.slug}
|
|
||||||
>
|
|
||||||
<Page
|
|
||||||
pageData={pageData}
|
|
||||||
isSubPage={false}
|
|
||||||
id={index}
|
|
||||||
particlesVisible={
|
|
||||||
pageData.notionId == currentPage.notionId &&
|
|
||||||
subPageVisible == false
|
|
||||||
}
|
}
|
||||||
/>
|
data-name={pageData.slug}
|
||||||
</Element>
|
>
|
||||||
))}
|
<Page
|
||||||
</div>
|
pageData={pageData}
|
||||||
|
isSubPage={false}
|
||||||
|
id={index}
|
||||||
|
particlesVisible={
|
||||||
|
pageData.notionId == currentPage.notionId &&
|
||||||
|
subPageVisible == false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Element>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<SubPage
|
<SubPage
|
||||||
visible={subPageVisible}
|
visible={subPageVisible}
|
||||||
mousePosition={mousePosition}
|
mousePosition={mousePosition}
|
||||||
skipAnimation={skipAnimation}
|
skipAnimation={skipAnimation}
|
||||||
>
|
>
|
||||||
<Page
|
<Page
|
||||||
pageData={currentSubPage}
|
pageData={currentSubPage}
|
||||||
isSubPage={true}
|
isSubPage={true}
|
||||||
id={currentPageIdx}
|
id={currentPageIdx}
|
||||||
showClose={true}
|
showClose={true}
|
||||||
showMenu={false}
|
showMenu={false}
|
||||||
onClose={handleSubPageClose}
|
onClose={handleSubPageClose}
|
||||||
particlesVisible={subPageVisible}
|
particlesVisible={subPageVisible}
|
||||||
|
/>
|
||||||
|
</SubPage>
|
||||||
|
|
||||||
|
<SubPage
|
||||||
|
visible={blogVisible}
|
||||||
|
mousePosition={mousePosition}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
>
|
||||||
|
<BlogPage blogData={currentBlog} />
|
||||||
|
</SubPage>
|
||||||
|
|
||||||
|
<SubPage
|
||||||
|
visible={projectVisible}
|
||||||
|
mousePosition={mousePosition}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
>
|
||||||
|
<ProjectPage projectData={currentProject} />
|
||||||
|
</SubPage>
|
||||||
|
|
||||||
|
<SubPage
|
||||||
|
visible={experienceVisible}
|
||||||
|
mousePosition={mousePosition}
|
||||||
|
skipAnimation={skipAnimation}
|
||||||
|
>
|
||||||
|
<ExperiencePage companyData={currentCompany} />
|
||||||
|
</SubPage>
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
pageData={nextPage}
|
||||||
|
theme={currentTheme}
|
||||||
|
showAccount={true}
|
||||||
|
showLinks={true}
|
||||||
|
onAccountToggle={handleAccountToggle}
|
||||||
|
accountToggled={accountToggled}
|
||||||
/>
|
/>
|
||||||
</SubPage>
|
</DeveloperMenuProvider>
|
||||||
|
|
||||||
<SubPage
|
|
||||||
visible={blogVisible}
|
|
||||||
mousePosition={mousePosition}
|
|
||||||
skipAnimation={skipAnimation}
|
|
||||||
>
|
|
||||||
<BlogPage blogData={currentBlog} />
|
|
||||||
</SubPage>
|
|
||||||
|
|
||||||
<SubPage
|
|
||||||
visible={projectVisible}
|
|
||||||
mousePosition={mousePosition}
|
|
||||||
skipAnimation={skipAnimation}
|
|
||||||
>
|
|
||||||
<ProjectPage projectData={currentProject} />
|
|
||||||
</SubPage>
|
|
||||||
|
|
||||||
<SubPage
|
|
||||||
visible={experienceVisible}
|
|
||||||
mousePosition={mousePosition}
|
|
||||||
skipAnimation={skipAnimation}
|
|
||||||
>
|
|
||||||
<ExperiencePage companyData={currentCompany} />
|
|
||||||
</SubPage>
|
|
||||||
|
|
||||||
<Footer
|
|
||||||
pageData={nextPage}
|
|
||||||
theme={currentTheme}
|
|
||||||
showAccount={true}
|
|
||||||
showLinks={true}
|
|
||||||
onAccountToggle={handleAccountToggle}
|
|
||||||
accountToggled={accountToggled}
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AppContent.propTypes = {
|
const AppProviders = () => {
|
||||||
images: PropTypes.shape({
|
const { triggerAction, registerReloadedContentHandler } = useDeveloper();
|
||||||
length: PropTypes.number,
|
const { updateContent, invalidateContent, pages, cvs, isLoading } =
|
||||||
}),
|
useContent();
|
||||||
pages: PropTypes.array.isRequired,
|
|
||||||
blogs: PropTypes.array.isRequired,
|
const mergeReloadedContent = useCallback(
|
||||||
projects: PropTypes.array.isRequired,
|
(reloadedContent) => {
|
||||||
companies: PropTypes.array.isRequired,
|
if (!reloadedContent || typeof reloadedContent !== "object") {
|
||||||
themes: PropTypes.shape({
|
return;
|
||||||
find: PropTypes.func,
|
}
|
||||||
}),
|
|
||||||
loading: PropTypes.bool,
|
console.log("reloadedContent", reloadedContent);
|
||||||
|
updateContent(reloadedContent);
|
||||||
|
},
|
||||||
|
[updateContent]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof registerReloadedContentHandler !== "function") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = registerReloadedContentHandler(mergeReloadedContent);
|
||||||
|
return unregister;
|
||||||
|
}, [registerReloadedContentHandler, mergeReloadedContent]);
|
||||||
|
|
||||||
|
const createActionHandler = useCallback(
|
||||||
|
(actionName) =>
|
||||||
|
async (params = {}) => {
|
||||||
|
const response = await triggerAction(actionName, params);
|
||||||
|
if (response?.ok) {
|
||||||
|
await invalidateContent();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
[triggerAction, invalidateContent]
|
||||||
|
);
|
||||||
|
|
||||||
|
const actions = useMemo(
|
||||||
|
() => ({
|
||||||
|
developerMode: async (params = {}) => {
|
||||||
|
const response = await triggerAction("developerMode", params);
|
||||||
|
if (response?.ok) {
|
||||||
|
await invalidateContent();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
reloadBlog: createActionHandler("reloadBlog"),
|
||||||
|
reloadProject: createActionHandler("reloadProject"),
|
||||||
|
reloadPage: createActionHandler("reloadPage"),
|
||||||
|
reloadExperience: createActionHandler("reloadExperience"),
|
||||||
|
}),
|
||||||
|
[createActionHandler, triggerAction, invalidateContent]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionProvider onAction={actions}>
|
||||||
|
<ImageProvider>
|
||||||
|
<VideoProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<AccountProvider>
|
||||||
|
<MenuProvider pages={pages} currentPageSlug="" cvs={cvs}>
|
||||||
|
<AppContent />
|
||||||
|
<LoadingModal visible={isLoading} />
|
||||||
|
</MenuProvider>
|
||||||
|
</AccountProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</VideoProvider>
|
||||||
|
</ImageProvider>
|
||||||
|
</ActionProvider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSettings = {
|
const AppContentRoot = () => {
|
||||||
themes: [],
|
const { isError, error, errorMessage } = useContent();
|
||||||
redirects: {},
|
|
||||||
branding: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchContent = async () => {
|
|
||||||
const response = await axios.get(`${apiUrl}/content`);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const actions = {};
|
|
||||||
|
|
||||||
const { data, isLoading, error, isError } = useQuery({
|
|
||||||
queryKey: ["content"],
|
|
||||||
queryFn: fetchContent,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentArray = Array.isArray(data) ? data : [];
|
|
||||||
const contentObject = !Array.isArray(data) && data ? data : {};
|
|
||||||
|
|
||||||
const pages = contentObject.pages || contentArray;
|
|
||||||
const blogs = contentObject.blogs || contentArray;
|
|
||||||
const projects = contentObject.projects || contentArray;
|
|
||||||
const companies = contentObject.companies || contentArray;
|
|
||||||
const images = contentObject.images || contentArray;
|
|
||||||
const cvs = contentObject.cvs || contentArray;
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings,
|
|
||||||
...(contentObject.settings || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorMessage = isError
|
|
||||||
? axios.isAxiosError(error)
|
|
||||||
? error.response?.data?.message ||
|
|
||||||
error.message ||
|
|
||||||
"Failed to fetch pages"
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to fetch pages"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
console.error("Error fetching content:", error);
|
console.error("Error fetching content:", error);
|
||||||
@ -785,54 +811,27 @@ const App = () => {
|
|||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return (
|
return (
|
||||||
<div className="tb-error-container">
|
<AppError
|
||||||
<Alert
|
message={errorMessage}
|
||||||
message="Error Loading Pages"
|
error={error}
|
||||||
description={errorMessage}
|
onRetry={() => window.location.reload()}
|
||||||
type="error"
|
/>
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <AppProviders />;
|
||||||
<KeycloakProvider>
|
|
||||||
<SettingsProvider settings={settings}>
|
|
||||||
<BlogsProvider initialBlogs={blogs}>
|
|
||||||
<ProjectsProvider initialProjects={projects}>
|
|
||||||
<CompaniesProvider initialCompanies={companies}>
|
|
||||||
<ActionProvider onAction={actions}>
|
|
||||||
<ImageProvider>
|
|
||||||
<VideoProvider>
|
|
||||||
<FileProvider>
|
|
||||||
<AccountProvider>
|
|
||||||
<MenuProvider
|
|
||||||
pages={pages}
|
|
||||||
currentPageSlug=""
|
|
||||||
cvs={cvs}
|
|
||||||
>
|
|
||||||
<AppContent
|
|
||||||
pages={pages}
|
|
||||||
images={images}
|
|
||||||
blogs={blogs}
|
|
||||||
projects={projects}
|
|
||||||
companies={companies}
|
|
||||||
loading={isLoading}
|
|
||||||
/>
|
|
||||||
<LoadingModal visible={isLoading} />
|
|
||||||
</MenuProvider>
|
|
||||||
</AccountProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</VideoProvider>
|
|
||||||
</ImageProvider>
|
|
||||||
</ActionProvider>
|
|
||||||
</CompaniesProvider>
|
|
||||||
</ProjectsProvider>
|
|
||||||
</BlogsProvider>
|
|
||||||
</SettingsProvider>
|
|
||||||
</KeycloakProvider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<KeycloakProvider>
|
||||||
|
<MessageProvider>
|
||||||
|
<DeveloperProvider>
|
||||||
|
<ContentProvider>
|
||||||
|
<AppContentRoot />
|
||||||
|
</ContentProvider>
|
||||||
|
</DeveloperProvider>
|
||||||
|
</MessageProvider>
|
||||||
|
</KeycloakProvider>
|
||||||
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
186
src/components/AppError.jsx
Normal file
186
src/components/AppError.jsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import axios from "axios";
|
||||||
|
import ErrorCloudIcon from "../icons/ErrorCloudIcon";
|
||||||
|
|
||||||
|
const toPlainObject = (value, depth = 0, seen = new WeakSet()) => {
|
||||||
|
if (value === null || typeof value !== "object") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return "[Circular]";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > 4) {
|
||||||
|
return "[Truncated]";
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(value);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => toPlainObject(item, depth + 1, seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value.toJSON === "function") {
|
||||||
|
try {
|
||||||
|
return value.toJSON();
|
||||||
|
} catch (error) {
|
||||||
|
return `[toJSON failed: ${error?.message ?? "unknown error"}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {};
|
||||||
|
Object.keys(value).forEach((key) => {
|
||||||
|
const item = value[key];
|
||||||
|
if (typeof item !== "function") {
|
||||||
|
output[key] = toPlainObject(item, depth + 1, seen);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAxiosError = (error) => {
|
||||||
|
const responseHeaders = error.response?.headers
|
||||||
|
? toPlainObject(error.response.headers)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const requestHeaders = error.config?.headers
|
||||||
|
? toPlainObject(error.config.headers)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "AxiosError",
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
data: toPlainObject(error.response?.data),
|
||||||
|
responseHeaders,
|
||||||
|
requestHeaders,
|
||||||
|
method: error.config?.method,
|
||||||
|
baseURL: error.config?.baseURL,
|
||||||
|
url: error.config?.url,
|
||||||
|
params: toPlainObject(error.config?.params),
|
||||||
|
timeout: error.config?.timeout,
|
||||||
|
stack: error.stack,
|
||||||
|
cause: error.cause,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNativeError = (error) => {
|
||||||
|
const base = {
|
||||||
|
type: error.name || "Error",
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
cause: error.cause,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.getOwnPropertyNames(error).forEach((key) => {
|
||||||
|
if (!(key in base)) {
|
||||||
|
base[key] = toPlainObject(error[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatErrorDetails = (error) => {
|
||||||
|
if (!error) {
|
||||||
|
return "No additional error information is available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
payload = formatAxiosError(error);
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
payload = formatNativeError(error);
|
||||||
|
} else if (typeof error === "string") {
|
||||||
|
return error;
|
||||||
|
} else {
|
||||||
|
payload = toPlainObject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
} catch (stringifyError) {
|
||||||
|
return `Unable to format error details: ${
|
||||||
|
stringifyError?.message ?? "Unknown error"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppError = ({ message, error, onRetry }) => {
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
|
||||||
|
const errorDetails = useMemo(
|
||||||
|
() => formatErrorDetails(error),
|
||||||
|
[error]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
if (typeof onRetry === "function") {
|
||||||
|
onRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = () => setShowDetails((prev) => !prev);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tb-error-container">
|
||||||
|
<div className="tb-error-content">
|
||||||
|
<div className="tb-error-icon">
|
||||||
|
<ErrorCloudIcon />
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<pre className="tb-error-details" aria-live="polite">
|
||||||
|
{errorDetails}
|
||||||
|
</pre>
|
||||||
|
) : <div className="tb-error-message">
|
||||||
|
<h1>Error Loading Pages</h1>
|
||||||
|
<hr className="tb-error-message-divider" />
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<div className="tb-error-actions">
|
||||||
|
<button
|
||||||
|
className="tb-button tb-error-button"
|
||||||
|
onClick={handleRetry}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tb-button tb-error-secondary-button"
|
||||||
|
onClick={handleToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showDetails ? "Hide" : "Info"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AppError.propTypes = {
|
||||||
|
message: PropTypes.string.isRequired,
|
||||||
|
error: PropTypes.any,
|
||||||
|
onRetry: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
AppError.defaultProps = {
|
||||||
|
error: null,
|
||||||
|
onRetry: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppError;
|
||||||
|
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import PropTypes from "prop-types";
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
import { useBlogs } from "../../contexts/BlogsContext";
|
|
||||||
import BlogListItem from "./BlogListItem.jsx";
|
import BlogListItem from "./BlogListItem.jsx";
|
||||||
|
|
||||||
const BlogList = () => {
|
const BlogList = () => {
|
||||||
const { blogs } = useBlogs();
|
const { blogs } = useContent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tb-blog-list-container">
|
<div className="tb-blog-list-container">
|
||||||
@ -16,8 +15,4 @@ const BlogList = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
BlogList.propTypes = {
|
|
||||||
blogs: PropTypes.array,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BlogList;
|
export default BlogList;
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import PropTypes from "prop-types";
|
|||||||
import { Layout } from "antd";
|
import { Layout } from "antd";
|
||||||
import ContentRenderer from "../ContentRenderer";
|
import ContentRenderer from "../ContentRenderer";
|
||||||
import { useMediaQuery } from "react-responsive";
|
import { useMediaQuery } from "react-responsive";
|
||||||
import { useSettingsContext } from "../../contexts/SettingsContext";
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
import ShareButton from "../Buttons/ShareButton";
|
import ShareButton from "../Buttons/ShareButton";
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
const BlogPage = memo(({ blogData }) => {
|
const BlogPage = memo(({ blogData }) => {
|
||||||
const settings = useSettingsContext();
|
const { settings } = useContent();
|
||||||
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
||||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||||
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
||||||
|
|||||||
@ -29,7 +29,12 @@ const AccountButton = ({
|
|||||||
accountToggled == false ? " tb-hidden" : ""
|
accountToggled == false ? " tb-hidden" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MenuButton isInverted={false} theme={theme} toggled={true} />
|
<MenuButton
|
||||||
|
as="div"
|
||||||
|
isInverted={false}
|
||||||
|
theme={theme}
|
||||||
|
toggled={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,9 +1,20 @@
|
|||||||
import { Fade as Hamburger } from "hamburger-react";
|
import { Fade as Hamburger } from "hamburger-react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
const MenuButton = ({ isInverted = false, theme, toggled, onToggle }) => {
|
const MenuButton = ({
|
||||||
|
isInverted = false,
|
||||||
|
theme,
|
||||||
|
toggled,
|
||||||
|
onToggle,
|
||||||
|
as: Component = "button",
|
||||||
|
}) => {
|
||||||
|
const isNativeButton = Component === "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className="tb-header-button tb-header-button-menu">
|
<Component
|
||||||
|
className="tb-header-button tb-header-button-menu"
|
||||||
|
{...(isNativeButton ? { type: "button" } : {})}
|
||||||
|
>
|
||||||
<Hamburger
|
<Hamburger
|
||||||
toggled={toggled}
|
toggled={toggled}
|
||||||
toggle={onToggle}
|
toggle={onToggle}
|
||||||
@ -15,7 +26,7 @@ const MenuButton = ({ isInverted = false, theme, toggled, onToggle }) => {
|
|||||||
: theme?.textColor || "#ffffff"
|
: theme?.textColor || "#ffffff"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</button>
|
</Component>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,6 +35,7 @@ MenuButton.propTypes = {
|
|||||||
theme: PropTypes.object,
|
theme: PropTypes.object,
|
||||||
toggled: PropTypes.bool,
|
toggled: PropTypes.bool,
|
||||||
onToggle: PropTypes.func,
|
onToggle: PropTypes.func,
|
||||||
|
as: PropTypes.elementType,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MenuButton;
|
export default MenuButton;
|
||||||
|
|||||||
@ -31,7 +31,12 @@ const ShareButton = ({ blogData, projectData, theme = null }) => {
|
|||||||
sharePopupVisible == false ? " tb-hidden" : ""
|
sharePopupVisible == false ? " tb-hidden" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MenuButton isInverted={false} theme={theme} toggled={true} />
|
<MenuButton
|
||||||
|
as="div"
|
||||||
|
isInverted={false}
|
||||||
|
theme={theme}
|
||||||
|
toggled={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<SharePopupMenu
|
<SharePopupMenu
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import BlogList from "./Blogs/BlogList";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
import Image from "./Image";
|
import Image from "./Image";
|
||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect, Fragment } from "react";
|
||||||
import CompaniesList from "./Experience/CompaniesList";
|
import CompaniesList from "./Experience/CompaniesList";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import RightIcon from "../icons/RightIcon";
|
import RightIcon from "../icons/RightIcon";
|
||||||
@ -14,18 +14,38 @@ import VisitIcon from "../icons/VisitIcon";
|
|||||||
|
|
||||||
const gradientHeight = 60;
|
const gradientHeight = 60;
|
||||||
|
|
||||||
const renderAnnotation = (textObject = {}, navigate) => {
|
const renderAnnotation = (textObject = {}, navigate, index) => {
|
||||||
if (textObject.bold == true) {
|
if (textObject.bold == true) {
|
||||||
return <b>{renderAnnotation({ ...textObject, bold: false }, navigate)}</b>;
|
return (
|
||||||
|
<b key={index}>
|
||||||
|
{renderAnnotation(
|
||||||
|
{ ...textObject, bold: false },
|
||||||
|
navigate,
|
||||||
|
index + "-bold"
|
||||||
|
)}
|
||||||
|
</b>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (textObject.italic == true) {
|
if (textObject.italic == true) {
|
||||||
return (
|
return (
|
||||||
<i>{renderAnnotation({ ...textObject, italic: false }, navigate)}</i>
|
<i key={index}>
|
||||||
|
{renderAnnotation(
|
||||||
|
{ ...textObject, italic: false },
|
||||||
|
navigate,
|
||||||
|
index + "-italic"
|
||||||
|
)}
|
||||||
|
</i>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (textObject.underline == true) {
|
if (textObject.underline == true) {
|
||||||
return (
|
return (
|
||||||
<u>{renderAnnotation({ ...textObject, underline: false }, navigate)}</u>
|
<u key={index}>
|
||||||
|
{renderAnnotation(
|
||||||
|
{ ...textObject, underline: false },
|
||||||
|
navigate,
|
||||||
|
index + "-underline"
|
||||||
|
)}
|
||||||
|
</u>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (textObject.link != undefined) {
|
if (textObject.link != undefined) {
|
||||||
@ -35,13 +55,18 @@ const renderAnnotation = (textObject = {}, navigate) => {
|
|||||||
<a
|
<a
|
||||||
className="tb-link"
|
className="tb-link"
|
||||||
href={url}
|
href={url}
|
||||||
|
key={index}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(url);
|
navigate(url);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="tb-link-text">
|
<span className="tb-link-text">
|
||||||
{renderAnnotation({ ...textObject, link: undefined }, navigate)}
|
{renderAnnotation(
|
||||||
|
{ ...textObject, link: undefined },
|
||||||
|
navigate,
|
||||||
|
index + "-link"
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@ -51,13 +76,18 @@ const renderAnnotation = (textObject = {}, navigate) => {
|
|||||||
<a
|
<a
|
||||||
className="tb-link"
|
className="tb-link"
|
||||||
href={url}
|
href={url}
|
||||||
|
key={index}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="tb-link-text">
|
<span className="tb-link-text">
|
||||||
{renderAnnotation({ ...textObject, link: undefined }, navigate)}
|
{renderAnnotation(
|
||||||
|
{ ...textObject, link: undefined },
|
||||||
|
navigate,
|
||||||
|
index + "-link"
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="tb-link-icon">
|
<span className="tb-link-icon">
|
||||||
<VisitIcon />
|
<VisitIcon />
|
||||||
@ -69,8 +99,8 @@ const renderAnnotation = (textObject = {}, navigate) => {
|
|||||||
return textObject.text;
|
return textObject.text;
|
||||||
};
|
};
|
||||||
const renderText = (text = [], navigate) => {
|
const renderText = (text = [], navigate) => {
|
||||||
return text.map((item) => {
|
return text.map((item, index) => {
|
||||||
return renderAnnotation(item, navigate);
|
return renderAnnotation(item, navigate, index);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -201,17 +231,17 @@ const renderContentElement = (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "blogs":
|
case "blogs":
|
||||||
return <BlogList blogs={[]} />;
|
return <BlogList blogs={[]} key={index} />;
|
||||||
case "projects":
|
case "projects":
|
||||||
return <ProjectCards projects={[]} />;
|
return <ProjectCards projects={[]} key={index} />;
|
||||||
case "companies":
|
case "companies":
|
||||||
return <CompaniesList companies={[]} />;
|
return <CompaniesList companies={[]} key={index} />;
|
||||||
case "contactForm":
|
case "contactForm":
|
||||||
return <ContactForm />;
|
return <ContactForm key={index} />;
|
||||||
case "image":
|
case "image":
|
||||||
return <Image src={element.url} alt={element.caption} />;
|
return <Image src={element.url} alt={element.caption} key={index} />;
|
||||||
case "video":
|
case "video":
|
||||||
return <Video src={element.url} alt={element.caption} />;
|
return <Video src={element.url} alt={element.caption} key={index} />;
|
||||||
case "columnFlex": {
|
case "columnFlex": {
|
||||||
const { children = [] } = element;
|
const { children = [] } = element;
|
||||||
return (
|
return (
|
||||||
@ -259,7 +289,7 @@ const renderContentElement = (
|
|||||||
case "positionsTimeline": {
|
case "positionsTimeline": {
|
||||||
const { children = [] } = element;
|
const { children = [] } = element;
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={index}>
|
||||||
<h1 className="tb-title">Positions</h1>
|
<h1 className="tb-title">Positions</h1>
|
||||||
<div className="tb-positions-timeline">
|
<div className="tb-positions-timeline">
|
||||||
{children.map((child, childIdx) =>
|
{children.map((child, childIdx) =>
|
||||||
@ -274,7 +304,7 @@ const renderContentElement = (
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "positionTimelineItem": {
|
case "positionTimelineItem": {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import CompaniesListItem from "./CompaniesListItem.jsx";
|
import CompaniesListItem from "./CompaniesListItem.jsx";
|
||||||
import { useCompanies } from "../../contexts/CompaniesContext";
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
|
|
||||||
const CompaniesList = () => {
|
const CompaniesList = () => {
|
||||||
const { companies } = useCompanies();
|
const { companies } = useContent();
|
||||||
return (
|
return (
|
||||||
<div className="tb-companies-list-container">
|
<div className="tb-companies-list-container">
|
||||||
<div className="tb-companies-list">
|
<div className="tb-companies-list">
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const CompaniesListItem = ({ companyData, key }) => {
|
const CompaniesListItem = ({ companyData }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -11,7 +11,7 @@ const CompaniesListItem = ({ companyData, key }) => {
|
|||||||
navigate(`/experience/${companyData.slug}`);
|
navigate(`/experience/${companyData.slug}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="tb-companies-list-item" key={key}>
|
<div className="tb-companies-list-item">
|
||||||
<div className="tb-companies-list-item-content">
|
<div className="tb-companies-list-item-content">
|
||||||
<p className="tb-companies-list-item-year">
|
<p className="tb-companies-list-item-year">
|
||||||
{companyData.duration?.start
|
{companyData.duration?.start
|
||||||
@ -32,7 +32,6 @@ const CompaniesListItem = ({ companyData, key }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
CompaniesListItem.propTypes = {
|
CompaniesListItem.propTypes = {
|
||||||
key: PropTypes.any,
|
|
||||||
companyData: PropTypes.object,
|
companyData: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import PropTypes from "prop-types";
|
|||||||
import { Layout } from "antd";
|
import { Layout } from "antd";
|
||||||
import ContentRenderer from "../ContentRenderer";
|
import ContentRenderer from "../ContentRenderer";
|
||||||
import { useMediaQuery } from "react-responsive";
|
import { useMediaQuery } from "react-responsive";
|
||||||
import { useSettingsContext } from "../../contexts/SettingsContext";
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
import ShareButton from "../Buttons/ShareButton";
|
import ShareButton from "../Buttons/ShareButton";
|
||||||
import VisitButton from "../Buttons/VisitButton";
|
import VisitButton from "../Buttons/VisitButton";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@ -11,7 +11,7 @@ import dayjs from "dayjs";
|
|||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
const ExperiencePage = memo(({ companyData }) => {
|
const ExperiencePage = memo(({ companyData }) => {
|
||||||
const settings = useSettingsContext();
|
const { settings } = useContent();
|
||||||
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
||||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||||
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
|||||||
import { useMediaQuery } from "react-responsive";
|
import { useMediaQuery } from "react-responsive";
|
||||||
import LinkIcon from "../icons/LinkIcon";
|
import LinkIcon from "../icons/LinkIcon";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSettingsContext } from "../contexts/SettingsContext";
|
import { useContent } from "../contexts/ContentContext";
|
||||||
import AccountButton from "./Buttons/AccountButton";
|
import AccountButton from "./Buttons/AccountButton";
|
||||||
const Footer = ({
|
const Footer = ({
|
||||||
pageData,
|
pageData,
|
||||||
@ -15,7 +15,7 @@ const Footer = ({
|
|||||||
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
|
||||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const settings = useSettingsContext();
|
const { settings } = useContent();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import axios from "axios";
|
|||||||
import CheckIcon from "../../icons/CheckIcon";
|
import CheckIcon from "../../icons/CheckIcon";
|
||||||
import Turnstile from "./Turnstile";
|
import Turnstile from "./Turnstile";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSettingsContext } from "../../contexts/SettingsContext.jsx";
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
import LoadingIcon from "../../icons/LoadingIcon.jsx";
|
import LoadingIcon from "../../icons/LoadingIcon.jsx";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
const apiUrl = import.meta.env.VITE_API_URL;
|
const apiUrl = import.meta.env.VITE_API_URL;
|
||||||
@ -11,7 +11,7 @@ const turnstileKey = import.meta.env.VITE_TURNSTILE_KEY;
|
|||||||
|
|
||||||
const ContactForm = () => {
|
const ContactForm = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const settings = useSettingsContext();
|
const { settings } = useContent();
|
||||||
const turnstileRef = useRef(null); // Ref for Turnstile
|
const turnstileRef = useRef(null); // Ref for Turnstile
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSettingsContext } from "../contexts/SettingsContext";
|
import { useContent } from "../contexts/ContentContext";
|
||||||
import LogoSvg from "../../assets/logo.svg?react";
|
import LogoSvg from "../../assets/logo.svg?react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const HeaderLogo = ({ large = false, visible = true }) => {
|
const HeaderLogo = ({ large = false, visible = true }) => {
|
||||||
const settings = useSettingsContext();
|
const { settings } = useContent();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useEffect(() => {}, [large, settings.branding]);
|
useEffect(() => {}, [large, settings.branding]);
|
||||||
|
|
||||||
|
|||||||
@ -16,10 +16,23 @@ const Image = ({ src, alt, className, loading = "lazy", ...props }) => {
|
|||||||
const [showError, setShowError] = useState(false);
|
const [showError, setShowError] = useState(false);
|
||||||
const processedSrcRef = useRef(null);
|
const processedSrcRef = useRef(null);
|
||||||
|
|
||||||
|
// Helper function to remove query parameters from a URL
|
||||||
|
const removeQueryParams = (url) => {
|
||||||
|
if (!url) return url;
|
||||||
|
if (url.includes("?")) {
|
||||||
|
// Remove query parameters if they exist
|
||||||
|
return url.split("?")[0];
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
// Find the image object that matches the src
|
// Find the image object that matches the src
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (src) {
|
if (src) {
|
||||||
const imageObj = imageObjects.find((img) => img.src === src);
|
const srcWithoutParams = removeQueryParams(src);
|
||||||
|
const imageObj = imageObjects.find(
|
||||||
|
(img) => removeQueryParams(img.src) === srcWithoutParams
|
||||||
|
);
|
||||||
setCurrentImageObj(imageObj || null);
|
setCurrentImageObj(imageObj || null);
|
||||||
// Reset processed state when src changes
|
// Reset processed state when src changes
|
||||||
if (processedSrcRef.current !== src) {
|
if (processedSrcRef.current !== src) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useMediaQuery } from "react-responsive";
|
import { useMediaQuery } from "react-responsive";
|
||||||
import DigitalIcon from "../../icons/DigitalIcon";
|
import DigitalIcon from "../../icons/DigitalIcon";
|
||||||
@ -6,6 +6,7 @@ import PrintIcon from "../../icons/PrintIcon";
|
|||||||
import DownloadFileIcon from "../../icons/DownloadFileIcon";
|
import DownloadFileIcon from "../../icons/DownloadFileIcon";
|
||||||
import WebsiteSelectorIcon from "../../icons/WebsiteSelectorIcon";
|
import WebsiteSelectorIcon from "../../icons/WebsiteSelectorIcon";
|
||||||
import CVVersionsSelectorPopup from "./CVVersionsSelectorPopup";
|
import CVVersionsSelectorPopup from "./CVVersionsSelectorPopup";
|
||||||
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
|
|
||||||
const CVDownloadPopupMenu = ({
|
const CVDownloadPopupMenu = ({
|
||||||
isVisible = false,
|
isVisible = false,
|
||||||
@ -13,6 +14,8 @@ const CVDownloadPopupMenu = ({
|
|||||||
buttonRef = null,
|
buttonRef = null,
|
||||||
cvs = [],
|
cvs = [],
|
||||||
}) => {
|
}) => {
|
||||||
|
const { contentObject } = useContent() || {};
|
||||||
|
const { files: contentFiles = [] } = contentObject || {};
|
||||||
const [shouldRender, setShouldRender] = useState(false);
|
const [shouldRender, setShouldRender] = useState(false);
|
||||||
const [isExiting, setIsExiting] = useState(false);
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
const [position, setPosition] = useState({ top: 0, right: 0 });
|
const [position, setPosition] = useState({ top: 0, right: 0 });
|
||||||
@ -38,6 +41,112 @@ const CVDownloadPopupMenu = ({
|
|||||||
[cvs]
|
[cvs]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const normalizeUrl = useCallback((rawUrl) => {
|
||||||
|
if (!rawUrl || typeof rawUrl !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
return `${parsed.origin}${parsed.pathname}`;
|
||||||
|
} catch (error) {
|
||||||
|
const [withoutQuery] = rawUrl.split("?");
|
||||||
|
return withoutQuery || rawUrl;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fileLookupByUrl = useMemo(() => {
|
||||||
|
const lookup = new Map();
|
||||||
|
console.log("contentFiles", contentFiles);
|
||||||
|
contentFiles
|
||||||
|
.filter((fileEntry) => fileEntry && typeof fileEntry === "object")
|
||||||
|
.forEach((fileEntry) => {
|
||||||
|
[fileEntry.url, fileEntry.mirrorUrl]
|
||||||
|
.map((candidate) => normalizeUrl(candidate))
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((normalized) => {
|
||||||
|
if (!lookup.has(normalized)) {
|
||||||
|
console.log("normalized", normalized);
|
||||||
|
lookup.set(normalized, fileEntry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}, [contentFiles, normalizeUrl]);
|
||||||
|
|
||||||
|
const getCvDownloadUrl = useCallback(
|
||||||
|
(cv) => {
|
||||||
|
if (!cv) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryReference = Array.isArray(cv.files) ? cv.files[0] : null;
|
||||||
|
const primaryUrl =
|
||||||
|
typeof primaryReference === "string"
|
||||||
|
? primaryReference
|
||||||
|
: primaryReference?.url || primaryReference?.mirrorUrl;
|
||||||
|
const lookupKey = normalizeUrl(primaryUrl || cv.url);
|
||||||
|
|
||||||
|
if (!lookupKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedFile = fileLookupByUrl.get(lookupKey);
|
||||||
|
console.log("lookupKey", lookupKey);
|
||||||
|
|
||||||
|
if (matchedFile) {
|
||||||
|
return matchedFile.mirrorUrl || matchedFile.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
primaryReference &&
|
||||||
|
typeof primaryReference === "object" &&
|
||||||
|
(primaryReference.mirrorUrl || primaryReference.url)
|
||||||
|
) {
|
||||||
|
return primaryReference.mirrorUrl || primaryReference.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return primaryUrl || cv.url || null;
|
||||||
|
},
|
||||||
|
[fileLookupByUrl, normalizeUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getLatestCvByDate = useCallback((options) => {
|
||||||
|
if (!Array.isArray(options) || options.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.reduce((latest, current) => {
|
||||||
|
const latestTime = latest
|
||||||
|
? new Date(latest.date || latest.updatedAt || 0).getTime()
|
||||||
|
: -Infinity;
|
||||||
|
const currentTime = new Date(
|
||||||
|
current.date || current.updatedAt || 0
|
||||||
|
).getTime();
|
||||||
|
|
||||||
|
if (Number.isNaN(currentTime)) {
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTime > latestTime) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest;
|
||||||
|
}, null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const latestDigitalCv = useMemo(
|
||||||
|
() => getLatestCvByDate(cvDigitalVersions),
|
||||||
|
[cvDigitalVersions, getLatestCvByDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestPrintCv = useMemo(
|
||||||
|
() => getLatestCvByDate(cvPrintVersions),
|
||||||
|
[cvPrintVersions, getLatestCvByDate]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDigitalVersionsSelectorButtonClick = () => {
|
const handleDigitalVersionsSelectorButtonClick = () => {
|
||||||
setCvDigitalVersionsSelectorPopupVisible(
|
setCvDigitalVersionsSelectorPopupVisible(
|
||||||
!cvDigitalVersionsSelectorPopupVisible
|
!cvDigitalVersionsSelectorPopupVisible
|
||||||
@ -98,13 +207,18 @@ const CVDownloadPopupMenu = ({
|
|||||||
};
|
};
|
||||||
}, [shouldRender, isExiting, onClose, buttonRef]);
|
}, [shouldRender, isExiting, onClose, buttonRef]);
|
||||||
|
|
||||||
const handleDownload = (option) => {
|
const handleDownloadLatest = useCallback(
|
||||||
// Handle download logic here
|
(type) => {
|
||||||
if (option.url) {
|
const targetCv = type === "digital" ? latestDigitalCv : latestPrintCv;
|
||||||
window.open(option.url, "_blank");
|
const downloadUrl = getCvDownloadUrl(targetCv);
|
||||||
}
|
|
||||||
onClose();
|
if (downloadUrl) {
|
||||||
};
|
window.open(downloadUrl, "_blank");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getCvDownloadUrl, latestDigitalCv, latestPrintCv, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldRender) return null;
|
if (!shouldRender) return null;
|
||||||
|
|
||||||
@ -132,6 +246,7 @@ const CVDownloadPopupMenu = ({
|
|||||||
<h3 className="tb-cv-title">
|
<h3 className="tb-cv-title">
|
||||||
Digital
|
Digital
|
||||||
<button
|
<button
|
||||||
|
ref={cvDigitalVersionsSelectorButtonRef}
|
||||||
className="tb-button tb-menu-popup-button tb-cv-versions-button"
|
className="tb-button tb-menu-popup-button tb-cv-versions-button"
|
||||||
onClick={handleDigitalVersionsSelectorButtonClick}
|
onClick={handleDigitalVersionsSelectorButtonClick}
|
||||||
>
|
>
|
||||||
@ -143,7 +258,12 @@ const CVDownloadPopupMenu = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="tb-button tb-menu-popup-button tb-cv-download-button">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tb-button tb-menu-popup-button tb-cv-download-button"
|
||||||
|
onClick={() => handleDownloadLatest("digital")}
|
||||||
|
disabled={!latestDigitalCv || !getCvDownloadUrl(latestDigitalCv)}
|
||||||
|
>
|
||||||
PDF
|
PDF
|
||||||
<DownloadFileIcon />
|
<DownloadFileIcon />
|
||||||
</button>
|
</button>
|
||||||
@ -157,6 +277,7 @@ const CVDownloadPopupMenu = ({
|
|||||||
<h3 className="tb-cv-title">
|
<h3 className="tb-cv-title">
|
||||||
Printable
|
Printable
|
||||||
<button
|
<button
|
||||||
|
ref={cvPrintVersionsSelectorButtonRef}
|
||||||
className="tb-button tb-menu-popup-button tb-cv-versions-button"
|
className="tb-button tb-menu-popup-button tb-cv-versions-button"
|
||||||
onClick={handlePrintVersionsSelectorButtonClick}
|
onClick={handlePrintVersionsSelectorButtonClick}
|
||||||
>
|
>
|
||||||
@ -168,7 +289,12 @@ const CVDownloadPopupMenu = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="tb-button tb-menu-popup-button tb-cv-download-button">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tb-button tb-menu-popup-button tb-cv-download-button"
|
||||||
|
onClick={() => handleDownloadLatest("print")}
|
||||||
|
disabled={!latestPrintCv || !getCvDownloadUrl(latestPrintCv)}
|
||||||
|
>
|
||||||
PDF
|
PDF
|
||||||
<DownloadFileIcon />
|
<DownloadFileIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Layout } from "antd";
|
|||||||
import ContentRenderer from "./ContentRenderer";
|
import ContentRenderer from "./ContentRenderer";
|
||||||
import ScrollIcon from "../icons/ScrollIcon";
|
import ScrollIcon from "../icons/ScrollIcon";
|
||||||
import { useMediaQuery } from "react-responsive";
|
import { useMediaQuery } from "react-responsive";
|
||||||
import { useSettingsContext } from "../contexts/SettingsContext";
|
import { useContent } from "../contexts/ContentContext";
|
||||||
import ParticlesBackground from "./ParticlesBackground";
|
import ParticlesBackground from "./ParticlesBackground";
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
@ -14,7 +14,7 @@ const Page = memo(({ pageData, particlesVisible }) => {
|
|||||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||||
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
const settings = useSettingsContext();
|
const { settings } = useContent();
|
||||||
const [delayedParticlesVisible, setDelayedParticlesVisible] = useState(false);
|
const [delayedParticlesVisible, setDelayedParticlesVisible] = useState(false);
|
||||||
|
|
||||||
const theme = useMemo(
|
const theme = useMemo(
|
||||||
@ -81,6 +81,7 @@ const Page = memo(({ pageData, particlesVisible }) => {
|
|||||||
scrollSnap={pageData?.scrollSnap}
|
scrollSnap={pageData?.scrollSnap}
|
||||||
align={pageData?.align}
|
align={pageData?.align}
|
||||||
justify={pageData?.justify}
|
justify={pageData?.justify}
|
||||||
|
scrollDistance={pageData?.scrollButtonDistance}
|
||||||
/>
|
/>
|
||||||
{pageData?.showScroll == true && <ScrollIcon />}
|
{pageData?.showScroll == true && <ScrollIcon />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import React, {
|
|||||||
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
||||||
import { loadSlim } from "@tsparticles/slim";
|
import { loadSlim } from "@tsparticles/slim";
|
||||||
import convert from "color-convert";
|
import convert from "color-convert";
|
||||||
|
import { useDeveloper } from "../contexts/DeveloperContext";
|
||||||
|
|
||||||
const ParticlesComponent = React.memo(({ id, options, particlesLoaded }) => {
|
const ParticlesComponent = React.memo(({ id, options, particlesLoaded }) => {
|
||||||
return (
|
return (
|
||||||
@ -28,6 +29,7 @@ ParticlesComponent.propTypes = {
|
|||||||
ParticlesComponent.displayName = "ParticlesComponent";
|
ParticlesComponent.displayName = "ParticlesComponent";
|
||||||
|
|
||||||
const ParticlesBackground = ({ id, theme, visible }) => {
|
const ParticlesBackground = ({ id, theme, visible }) => {
|
||||||
|
const { developerMode } = useDeveloper();
|
||||||
// Memoize colors to prevent glitching from unnecessary recalculations
|
// Memoize colors to prevent glitching from unnecessary recalculations
|
||||||
const colors = useMemo(() => {
|
const colors = useMemo(() => {
|
||||||
// Generate three transitional hues between backgroundColor and textColor
|
// Generate three transitional hues between backgroundColor and textColor
|
||||||
@ -63,6 +65,11 @@ const ParticlesBackground = ({ id, theme, visible }) => {
|
|||||||
const instanceId = useRef(
|
const instanceId = useRef(
|
||||||
id || `tsparticles-${Math.random().toString(36).substr(2, 9)}`
|
id || `tsparticles-${Math.random().toString(36).substr(2, 9)}`
|
||||||
);
|
);
|
||||||
|
const [fps, setFps] = useState(null);
|
||||||
|
const [frameTime, setFrameTime] = useState(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const rafIdRef = useRef(null);
|
||||||
|
const lastFrameTimestampRef = useRef(null);
|
||||||
|
|
||||||
// this should be run only once per application lifetime
|
// this should be run only once per application lifetime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -73,10 +80,81 @@ const ParticlesBackground = ({ id, theme, visible }) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const particlesLoaded = useCallback((container) => {
|
const updateFps = useCallback((timestamp) => {
|
||||||
console.log(container);
|
const container = containerRef.current;
|
||||||
|
let measuredFrameTime = null;
|
||||||
|
let measuredFps = null;
|
||||||
|
|
||||||
|
if (typeof timestamp === "number") {
|
||||||
|
if (lastFrameTimestampRef.current !== null) {
|
||||||
|
const delta = timestamp - lastFrameTimestampRef.current;
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
measuredFrameTime = Math.round(delta * 10) / 10;
|
||||||
|
measuredFps = Math.round(1000 / delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFrameTimestampRef.current = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container?.fps) {
|
||||||
|
let currentFps = 0;
|
||||||
|
|
||||||
|
if (typeof container.fps.getFPS === "function") {
|
||||||
|
currentFps = container.fps.getFPS();
|
||||||
|
} else if (typeof container.actualFPS === "number") {
|
||||||
|
currentFps = container.actualFPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFps > 0) {
|
||||||
|
measuredFps = Math.round(currentFps);
|
||||||
|
|
||||||
|
if (!measuredFrameTime) {
|
||||||
|
measuredFrameTime = Math.round((1000 / currentFps) * 10) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measuredFrameTime !== null) {
|
||||||
|
setFrameTime((prev) =>
|
||||||
|
prev !== measuredFrameTime ? measuredFrameTime : prev
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measuredFps !== null) {
|
||||||
|
setFps((prev) => (prev !== measuredFps ? measuredFps : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
rafIdRef.current = requestAnimationFrame(updateFps);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const particlesLoaded = useCallback(
|
||||||
|
(container) => {
|
||||||
|
containerRef.current = container;
|
||||||
|
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafIdRef.current = requestAnimationFrame(updateFps);
|
||||||
|
},
|
||||||
|
[updateFps]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
containerRef.current = null;
|
||||||
|
rafIdRef.current = null;
|
||||||
|
lastFrameTimestampRef.current = null;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
background: {
|
background: {
|
||||||
@ -131,7 +209,7 @@ const ParticlesBackground = ({ id, theme, visible }) => {
|
|||||||
density: {
|
density: {
|
||||||
enable: true,
|
enable: true,
|
||||||
},
|
},
|
||||||
value: 400,
|
value: 100,
|
||||||
},
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
value: 1,
|
value: 1,
|
||||||
@ -140,7 +218,7 @@ const ParticlesBackground = ({ id, theme, visible }) => {
|
|||||||
type: "circle",
|
type: "circle",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
value: { min: 100, max: 300 },
|
value: { min: 10, max: 150 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
detectRetina: true,
|
detectRetina: true,
|
||||||
@ -156,12 +234,39 @@ const ParticlesBackground = ({ id, theme, visible }) => {
|
|||||||
? "tb-particles tb-particles-visible"
|
? "tb-particles tb-particles-visible"
|
||||||
: "tb-particles tb-particles-hidden"
|
: "tb-particles tb-particles-hidden"
|
||||||
}
|
}
|
||||||
|
style={{
|
||||||
|
"--tb-backgroundColor": theme.backgroundColor,
|
||||||
|
"--tb-textColor": theme.textColor,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ParticlesComponent
|
<ParticlesComponent
|
||||||
id={instanceId.current}
|
id={instanceId.current}
|
||||||
options={options}
|
options={options}
|
||||||
particlesLoaded={particlesLoaded}
|
particlesLoaded={particlesLoaded}
|
||||||
/>
|
/>
|
||||||
|
{(fps !== null || frameTime !== null) && developerMode == true && (
|
||||||
|
<div
|
||||||
|
className={`tb-particles-fps-monitor ${
|
||||||
|
visible
|
||||||
|
? "tb-particles-fps-monitor-visible"
|
||||||
|
: "tb-particles-fps-monitor-hidden"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="tb-particles-fps-monitor-row">
|
||||||
|
<span className="tb-particles-fps-monitor-label">Frame</span>
|
||||||
|
<span className="tb-particles-fps-monitor-value">
|
||||||
|
{frameTime !== null ? `${frameTime.toFixed(1)} ms` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tb-particles-fps-monitor-divider" />
|
||||||
|
<div className="tb-particles-fps-monitor-row">
|
||||||
|
<span className="tb-particles-fps-monitor-label">FPS</span>
|
||||||
|
<span className="tb-particles-fps-monitor-value">
|
||||||
|
{fps !== null ? fps : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import Image from "../Image";
|
import Image from "../Image";
|
||||||
import ProjectStatus from "./ProjectStatus";
|
import ProjectStatus from "./ProjectStatus";
|
||||||
|
|
||||||
const ProjectCard = ({ projectData, key }) => {
|
const ProjectCard = ({ projectData }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -12,7 +12,7 @@ const ProjectCard = ({ projectData, key }) => {
|
|||||||
navigate(`/projects/${projectData.slug}`);
|
navigate(`/projects/${projectData.slug}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="tb-project-card" key={key}>
|
<div className="tb-project-card" >
|
||||||
<div className="tb-project-card-image-wrapper">
|
<div className="tb-project-card-image-wrapper">
|
||||||
{projectData?.image ? (
|
{projectData?.image ? (
|
||||||
<Image src={projectData?.image} alt={projectData?.name} />
|
<Image src={projectData?.image} alt={projectData?.name} />
|
||||||
@ -32,7 +32,6 @@ const ProjectCard = ({ projectData, key }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ProjectCard.propTypes = {
|
ProjectCard.propTypes = {
|
||||||
key: PropTypes.any,
|
|
||||||
projectData: PropTypes.object,
|
projectData: PropTypes.object,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import ProjectCard from "./ProjectCard";
|
import ProjectCard from "./ProjectCard";
|
||||||
|
|
||||||
import { useProjects } from "../../contexts/ProjectsContext";
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
|
|
||||||
const ProjectCards = () => {
|
const ProjectCards = () => {
|
||||||
const { projects } = useProjects();
|
const { projects } = useContent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tb-project-cards-container">
|
<div className="tb-project-cards-container">
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import { useMediaQuery } from "react-responsive";
|
|||||||
import ShareButton from "../Buttons/ShareButton";
|
import ShareButton from "../Buttons/ShareButton";
|
||||||
import VisitButton from "../Buttons/VisitButton";
|
import VisitButton from "../Buttons/VisitButton";
|
||||||
import ProjectStatus from "./ProjectStatus";
|
import ProjectStatus from "./ProjectStatus";
|
||||||
import { useSettingsContext } from "../../contexts/SettingsContext";
|
import { useContent } from "../../contexts/ContentContext";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ const ProjectPage = memo(({ projectData }) => {
|
|||||||
const isMobile = useMediaQuery({ maxWidth: 800 });
|
const isMobile = useMediaQuery({ maxWidth: 800 });
|
||||||
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
|
||||||
const contentRef = useRef(null);
|
const contentRef = useRef(null);
|
||||||
const settings = useSettingsContext();
|
const { settings } = useContent();
|
||||||
|
|
||||||
const theme = useMemo(
|
const theme = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -67,7 +68,9 @@ const ProjectPage = memo(({ projectData }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tb-project-header-meta-items tb-project-header-meta-items-right">
|
<div className="tb-project-header-meta-items tb-project-header-meta-items-right">
|
||||||
<p className="tb-project-date">{projectData?.date || "n/a"}</p>
|
<p className="tb-project-date">
|
||||||
|
{dayjs(projectData?.date).format("DD/MM/YY") || "n/a"}
|
||||||
|
</p>
|
||||||
<div className="tb-project-type-badge-wrapper">
|
<div className="tb-project-type-badge-wrapper">
|
||||||
{projectData?.status && projectData?.status !== "complete" && (
|
{projectData?.status && projectData?.status !== "complete" && (
|
||||||
<ProjectStatus status={projectData?.status} />
|
<ProjectStatus status={projectData?.status} />
|
||||||
|
|||||||
@ -61,13 +61,17 @@ export default function SubPage({
|
|||||||
|
|
||||||
const skipAnimationClass = skipAnimation ? "tb-skip-animation" : "";
|
const skipAnimationClass = skipAnimation ? "tb-skip-animation" : "";
|
||||||
|
|
||||||
|
const capturedPosition = capturedMousePositionRef.current;
|
||||||
|
const transformOrigin =
|
||||||
|
capturedPosition && (capturedPosition.x !== 0 || capturedPosition.y !== 0)
|
||||||
|
? `${capturedPosition.x}px ${capturedPosition.y}px`
|
||||||
|
: "center center";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${wrapperClass} ${animationClass} ${skipAnimationClass}`}
|
className={`${wrapperClass} ${animationClass} ${skipAnimationClass}`}
|
||||||
style={{
|
style={{
|
||||||
transformOrigin: capturedMousePositionRef.current
|
transformOrigin,
|
||||||
? `${capturedMousePositionRef.current.x}px ${capturedMousePositionRef.current.y}px`
|
|
||||||
: "center center",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Full height panel */}
|
{/* Full height panel */}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||||
|
import { useContent } from "../contexts/ContentContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import LoadingIcon from "../icons/LoadingIcon";
|
import LoadingIcon from "../icons/LoadingIcon";
|
||||||
import FullScreenIcon from "../icons/FullScreenIcon";
|
import FullScreenIcon from "../icons/FullScreenIcon";
|
||||||
@ -14,8 +15,15 @@ import Volume2Icon from "../icons/Volume2Icon";
|
|||||||
import Volume3Icon from "../icons/Volume3Icon";
|
import Volume3Icon from "../icons/Volume3Icon";
|
||||||
import MuteIcon from "../icons/MuteIcon";
|
import MuteIcon from "../icons/MuteIcon";
|
||||||
|
|
||||||
const Video = ({ src, mirrorUrl = null, className, poster, ...props }) => {
|
const Video = ({
|
||||||
|
src,
|
||||||
|
className,
|
||||||
|
poster,
|
||||||
|
mirrorUrl: mirrorUrlProp,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const { videoStates, loadVideo, getVideoUrl } = useVideoContext();
|
const { videoStates, loadVideo, getVideoUrl } = useVideoContext();
|
||||||
|
const { contentObject } = useContent();
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
@ -50,6 +58,22 @@ const Video = ({ src, mirrorUrl = null, className, poster, ...props }) => {
|
|||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
const currentVideoState = src ? videoStates[src] : null;
|
const currentVideoState = src ? videoStates[src] : null;
|
||||||
|
const videosList = useMemo(
|
||||||
|
() => (Array.isArray(contentObject?.videos) ? contentObject.videos : null),
|
||||||
|
[contentObject]
|
||||||
|
);
|
||||||
|
const mirrorUrl = useMemo(() => {
|
||||||
|
if (mirrorUrlProp) {
|
||||||
|
return mirrorUrlProp;
|
||||||
|
}
|
||||||
|
if (!src || !videosList) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const matchedVideo = videosList.find((videoItem) => videoItem?.url === src);
|
||||||
|
console.log("matchedVideo", matchedVideo);
|
||||||
|
console.log("url", src);
|
||||||
|
return matchedVideo?.mirrorUrl || null;
|
||||||
|
}, [mirrorUrlProp, src, videosList]);
|
||||||
const objectUrl = useMemo(
|
const objectUrl = useMemo(
|
||||||
() => (src ? getVideoUrl(src) : null),
|
() => (src ? getVideoUrl(src) : null),
|
||||||
[getVideoUrl, src]
|
[getVideoUrl, src]
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import PropTypes from "prop-types";
|
|
||||||
import { createContext, useContext, useState, useEffect } from "react";
|
|
||||||
|
|
||||||
// Create context
|
|
||||||
const BlogsContext = createContext({
|
|
||||||
blogs: [],
|
|
||||||
setBlogs: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provider
|
|
||||||
export const BlogsProvider = ({ children, initialBlogs = [] }) => {
|
|
||||||
const [blogs, setBlogs] = useState(initialBlogs);
|
|
||||||
|
|
||||||
// Update blogs whenever initialBlogs changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialBlogs && initialBlogs.length > 0) {
|
|
||||||
setBlogs(initialBlogs);
|
|
||||||
}
|
|
||||||
}, [initialBlogs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BlogsContext.Provider value={{ blogs, setBlogs }}>
|
|
||||||
{children}
|
|
||||||
</BlogsContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
BlogsProvider.propTypes = {
|
|
||||||
children: PropTypes.any,
|
|
||||||
initialBlogs: PropTypes.array,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for consuming
|
|
||||||
export const useBlogs = () => useContext(BlogsContext);
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import PropTypes from "prop-types";
|
|
||||||
import { createContext, useContext, useState, useEffect } from "react";
|
|
||||||
|
|
||||||
// Create context
|
|
||||||
const CompaniesContext = createContext({
|
|
||||||
companies: [],
|
|
||||||
setCompanies: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provider
|
|
||||||
export const CompaniesProvider = ({ children, initialCompanies = [] }) => {
|
|
||||||
const [companies, setCompanies] = useState(initialCompanies);
|
|
||||||
|
|
||||||
// Update companies whenever initialCompanies changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialCompanies && initialCompanies.length > 0) {
|
|
||||||
setCompanies(initialCompanies);
|
|
||||||
}
|
|
||||||
}, [initialCompanies]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CompaniesContext.Provider value={{ companies, setCompanies }}>
|
|
||||||
{children}
|
|
||||||
</CompaniesContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CompaniesProvider.propTypes = {
|
|
||||||
children: PropTypes.any,
|
|
||||||
initialCompanies: PropTypes.array,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for consuming
|
|
||||||
export const useCompanies = () => useContext(CompaniesContext);
|
|
||||||
133
src/contexts/ContentContext.jsx
Normal file
133
src/contexts/ContentContext.jsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { createContext, useContext, useMemo, useCallback } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
const defaultSettings = {
|
||||||
|
themes: [],
|
||||||
|
redirects: {},
|
||||||
|
branding: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContentContext = createContext(null);
|
||||||
|
|
||||||
|
const fetchContent = async () => {
|
||||||
|
const response = await axios.get(`${apiUrl}/content`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContentProvider = ({ children }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, error, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["content"],
|
||||||
|
queryFn: fetchContent,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const derivedContent = useMemo(() => {
|
||||||
|
const contentArray = Array.isArray(data) ? data : [];
|
||||||
|
const contentObject =
|
||||||
|
!Array.isArray(data) && data && typeof data === "object" ? data : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentArray,
|
||||||
|
contentObject,
|
||||||
|
pages: contentObject.pages || contentArray,
|
||||||
|
blogs: contentObject.blogs || contentArray,
|
||||||
|
projects: contentObject.projects || contentArray,
|
||||||
|
companies: contentObject.companies || contentArray,
|
||||||
|
images: contentObject.images || contentArray,
|
||||||
|
cvs: contentObject.cvs || contentArray,
|
||||||
|
settings: {
|
||||||
|
...defaultSettings,
|
||||||
|
...(contentObject.settings || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const errorMessage = useMemo(() => {
|
||||||
|
if (!isError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
return (
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
error?.message ||
|
||||||
|
"Failed to fetch pages"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Failed to fetch pages";
|
||||||
|
}, [isError, error]);
|
||||||
|
|
||||||
|
const updateContent = useCallback(
|
||||||
|
(reloadedContent) => {
|
||||||
|
if (!reloadedContent || typeof reloadedContent !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.setQueryData(["content"], (currentContent) => ({
|
||||||
|
...(currentContent && typeof currentContent === "object"
|
||||||
|
? currentContent
|
||||||
|
: {}),
|
||||||
|
...reloadedContent,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidateContent = useCallback(async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["content"] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
data,
|
||||||
|
...derivedContent,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
errorMessage,
|
||||||
|
updateContent,
|
||||||
|
invalidateContent,
|
||||||
|
refetch,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
data,
|
||||||
|
derivedContent,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
errorMessage,
|
||||||
|
updateContent,
|
||||||
|
invalidateContent,
|
||||||
|
refetch,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentContext.Provider value={value}>{children}</ContentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ContentProvider.propTypes = {
|
||||||
|
children: PropTypes.any,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useContent = () => {
|
||||||
|
const context = useContext(ContentContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useContent must be used within a ContentProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
415
src/contexts/DeveloperContext.jsx
Normal file
415
src/contexts/DeveloperContext.jsx
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useKeycloak } from "./KeycloakContext";
|
||||||
|
import { useMessage } from "./MessageContext";
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
const DeveloperContext = createContext({
|
||||||
|
developerMode: false,
|
||||||
|
setDeveloperMode: () => {},
|
||||||
|
enableDeveloperMode: () => {},
|
||||||
|
disableDeveloperMode: () => {},
|
||||||
|
lastAction: null,
|
||||||
|
actionStatus: "idle",
|
||||||
|
actionError: null,
|
||||||
|
actionResult: null,
|
||||||
|
triggerAction: () => Promise.resolve(null),
|
||||||
|
registerReloadedContentHandler: () => () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ACTION_DEFINITIONS = {
|
||||||
|
developerMode: { label: "Developer mode" },
|
||||||
|
reloadBlog: {
|
||||||
|
label: "blog cache",
|
||||||
|
endpoint: "/reloadBlog",
|
||||||
|
method: "POST",
|
||||||
|
requiresDeveloper: true,
|
||||||
|
},
|
||||||
|
reloadProject: {
|
||||||
|
label: "project cache",
|
||||||
|
endpoint: "/reloadProject",
|
||||||
|
method: "POST",
|
||||||
|
requiresDeveloper: true,
|
||||||
|
},
|
||||||
|
reloadPage: {
|
||||||
|
label: "page cache",
|
||||||
|
endpoint: "/reloadPage",
|
||||||
|
method: "POST",
|
||||||
|
requiresDeveloper: true,
|
||||||
|
},
|
||||||
|
reloadExperience: {
|
||||||
|
label: "experience cache",
|
||||||
|
endpoint: "/reloadExperience",
|
||||||
|
method: "POST",
|
||||||
|
requiresDeveloper: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUPPORTED_ACTIONS = new Set(Object.keys(ACTION_DEFINITIONS));
|
||||||
|
|
||||||
|
export const ACTION_CONFIG = Object.fromEntries(
|
||||||
|
Object.entries(ACTION_DEFINITIONS)
|
||||||
|
.filter(([, config]) => config.endpoint)
|
||||||
|
.map(([action, config]) => [
|
||||||
|
action,
|
||||||
|
{ endpoint: config.endpoint, method: config.method },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ACTION_LABELS = Object.fromEntries(
|
||||||
|
Object.entries(ACTION_DEFINITIONS).map(([action, config]) => [
|
||||||
|
action,
|
||||||
|
config.label,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const waitForPreferredUsername = async (
|
||||||
|
keycloakInstance,
|
||||||
|
{ maxAttempts = 40, intervalMs = 100 } = {}
|
||||||
|
) => {
|
||||||
|
if (!keycloakInstance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
const preferredUsername =
|
||||||
|
keycloakInstance?.tokenParsed?.preferred_username ??
|
||||||
|
keycloakInstance?.idTokenParsed?.preferred_username ??
|
||||||
|
null;
|
||||||
|
if (preferredUsername) {
|
||||||
|
return preferredUsername;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
"Timed out waiting for Keycloak preferred_username to become available"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeveloperProvider = ({ children }) => {
|
||||||
|
const { isAuthenticated, keycloak, loading } = useKeycloak();
|
||||||
|
const { showMessage } = useMessage();
|
||||||
|
const [developerMode, setDeveloperModeState] = useState(false);
|
||||||
|
const [actionState, setActionState] = useState({
|
||||||
|
lastAction: null,
|
||||||
|
status: "idle",
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
const processedActionsRef = useRef(new Set());
|
||||||
|
const reloadedContentHandlerRef = useRef(null);
|
||||||
|
|
||||||
|
const updateDeveloperMode = useCallback((value) => {
|
||||||
|
setDeveloperModeState(value);
|
||||||
|
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
window.sessionStorage.setItem("developerMode", "true");
|
||||||
|
} else {
|
||||||
|
window.sessionStorage.removeItem("developerMode");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkDeveloperStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!keycloak) {
|
||||||
|
updateDeveloperMode(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated || loading) {
|
||||||
|
await waitForPreferredUsername(keycloak);
|
||||||
|
}
|
||||||
|
|
||||||
|
const realmRoles = keycloak?.tokenParsed?.realm_access?.roles ?? [];
|
||||||
|
const clientRoles =
|
||||||
|
keycloak?.tokenParsed?.resource_access?.[
|
||||||
|
import.meta.env.VITE_KEYCLOAK_CLIENT_ID
|
||||||
|
]?.roles ?? [];
|
||||||
|
|
||||||
|
const allRoles = new Set([...realmRoles, ...clientRoles]);
|
||||||
|
const isDeveloper = allRoles.has("developer");
|
||||||
|
|
||||||
|
updateDeveloperMode(isDeveloper);
|
||||||
|
return isDeveloper;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Developer role check failed", error);
|
||||||
|
updateDeveloperMode(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [keycloak, isAuthenticated, loading, updateDeveloperMode]);
|
||||||
|
|
||||||
|
const ensureDeveloperAccess = useCallback(async () => {
|
||||||
|
if (developerMode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return await checkDeveloperStatus();
|
||||||
|
}, [checkDeveloperStatus, developerMode]);
|
||||||
|
|
||||||
|
const registerReloadedContentHandler = useCallback((handler) => {
|
||||||
|
if (typeof handler === "function") {
|
||||||
|
reloadedContentHandlerRef.current = handler;
|
||||||
|
} else {
|
||||||
|
reloadedContentHandlerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reloadedContentHandlerRef.current === handler) {
|
||||||
|
reloadedContentHandlerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const performEndpointAction = useCallback(
|
||||||
|
async ({ endpoint, method = "POST" }, params = {}) => {
|
||||||
|
const config = {
|
||||||
|
method,
|
||||||
|
url: `${apiUrl}/developer${endpoint}`,
|
||||||
|
withCredentials: true,
|
||||||
|
data: params,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(config);
|
||||||
|
return response?.data ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
const errorMessage =
|
||||||
|
error?.response?.data?.message ||
|
||||||
|
error?.message ||
|
||||||
|
(status ? `HTTP error ${status}` : "Request failed");
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggerAction = useCallback(
|
||||||
|
async (action, params = {}) => {
|
||||||
|
if (!SUPPORTED_ACTIONS.has(action)) {
|
||||||
|
throw new Error(`Unsupported action: ${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionDefinition = ACTION_DEFINITIONS[action];
|
||||||
|
const actionLabel = actionDefinition?.label ?? action;
|
||||||
|
|
||||||
|
setActionState({
|
||||||
|
lastAction: action,
|
||||||
|
status: "pending",
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof showMessage === "function") {
|
||||||
|
showMessage(`Reloading ${actionLabel.toLowerCase()}…`, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (actionDefinition?.requiresDeveloper) {
|
||||||
|
const allowed = await ensureDeveloperAccess();
|
||||||
|
if (!allowed) {
|
||||||
|
throw new Error("Developer access required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
if (action === "developerMode") {
|
||||||
|
const isDeveloper = await checkDeveloperStatus();
|
||||||
|
result = { developerMode: isDeveloper };
|
||||||
|
} else if (actionDefinition?.endpoint) {
|
||||||
|
result =
|
||||||
|
(await performEndpointAction(actionDefinition, params)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultContent =
|
||||||
|
result && typeof result === "object" ? result.content : null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
resultContent &&
|
||||||
|
typeof resultContent === "object" &&
|
||||||
|
typeof reloadedContentHandlerRef.current === "function"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await reloadedContentHandlerRef.current(resultContent);
|
||||||
|
} catch (handlerError) {
|
||||||
|
console.error("Reloaded content handler failed", handlerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionState({
|
||||||
|
lastAction: action,
|
||||||
|
status: "success",
|
||||||
|
error: null,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof showMessage === "function") {
|
||||||
|
const payloadMessage =
|
||||||
|
typeof result === "string"
|
||||||
|
? result
|
||||||
|
: result?.message ||
|
||||||
|
(action === "developerMode" && result
|
||||||
|
? result.developerMode
|
||||||
|
? "Developer mode enabled."
|
||||||
|
: "Developer mode disabled."
|
||||||
|
: `${actionLabel} refreshed.`);
|
||||||
|
showMessage(payloadMessage, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Action "${action}" failed`, error);
|
||||||
|
setActionState({
|
||||||
|
lastAction: action,
|
||||||
|
status: "error",
|
||||||
|
error,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof showMessage === "function") {
|
||||||
|
const errorMessage =
|
||||||
|
error?.message || error?.toString() || `${actionLabel} failed`;
|
||||||
|
showMessage(errorMessage, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
checkDeveloperStatus,
|
||||||
|
ensureDeveloperAccess,
|
||||||
|
performEndpointAction,
|
||||||
|
showMessage,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const runOnReady = async () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const action = params.get("action");
|
||||||
|
|
||||||
|
if (!action || !SUPPORTED_ACTIONS.has(action)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionKey = `${action}:${params.toString()}`;
|
||||||
|
if (processedActionsRef.current.has(actionKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !== "developerMode" && !isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedActionsRef.current.add(actionKey);
|
||||||
|
|
||||||
|
const actionParams = {};
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (key !== "action") {
|
||||||
|
actionParams[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerAction(action, actionParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
document.readyState === "complete" ||
|
||||||
|
document.readyState === "interactive"
|
||||||
|
) {
|
||||||
|
void runOnReady();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentLoaded = () => {
|
||||||
|
void runOnReady();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", handleContentLoaded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
document.removeEventListener("DOMContentLoaded", handleContentLoaded);
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, triggerAction]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedDeveloperMode = window.sessionStorage.getItem("developerMode");
|
||||||
|
if (storedDeveloperMode !== "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const validateStoredDeveloperMode = async () => {
|
||||||
|
const allowed = await checkDeveloperStatus();
|
||||||
|
if (!allowed && !cancelled) {
|
||||||
|
updateDeveloperMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void validateStoredDeveloperMode();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [checkDeveloperStatus, updateDeveloperMode]);
|
||||||
|
|
||||||
|
const enableDeveloperMode = () => updateDeveloperMode(true);
|
||||||
|
const disableDeveloperMode = () => updateDeveloperMode(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeveloperContext.Provider
|
||||||
|
value={{
|
||||||
|
developerMode,
|
||||||
|
setDeveloperMode: updateDeveloperMode,
|
||||||
|
enableDeveloperMode,
|
||||||
|
disableDeveloperMode,
|
||||||
|
lastAction: actionState.lastAction,
|
||||||
|
actionStatus: actionState.status,
|
||||||
|
actionError: actionState.error,
|
||||||
|
actionResult: actionState.result,
|
||||||
|
triggerAction,
|
||||||
|
registerReloadedContentHandler,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DeveloperContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DeveloperProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeveloper = () => useContext(DeveloperContext);
|
||||||
429
src/contexts/DeveloperMenuContext.jsx
Normal file
429
src/contexts/DeveloperMenuContext.jsx
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { createContext, useContext, useMemo, useCallback } from "react";
|
||||||
|
import { useDeveloper, ACTION_LABELS } from "./DeveloperContext";
|
||||||
|
import ReloadIcon from "../icons/ReloadIcon";
|
||||||
|
import LoadingIcon from "../icons/LoadingIcon";
|
||||||
|
|
||||||
|
const defaultTarget = {
|
||||||
|
entity: null,
|
||||||
|
slug: null,
|
||||||
|
path: null,
|
||||||
|
available: false,
|
||||||
|
visible: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeveloperMenuContext = createContext({
|
||||||
|
developerMode: false,
|
||||||
|
isVisible: false,
|
||||||
|
activeType: null,
|
||||||
|
activeEntity: null,
|
||||||
|
activeSlug: null,
|
||||||
|
activePath: null,
|
||||||
|
targets: {
|
||||||
|
page: defaultTarget,
|
||||||
|
blog: defaultTarget,
|
||||||
|
project: defaultTarget,
|
||||||
|
experience: defaultTarget,
|
||||||
|
},
|
||||||
|
currentPage: null,
|
||||||
|
currentSubPage: null,
|
||||||
|
currentBlog: null,
|
||||||
|
currentProject: null,
|
||||||
|
currentCompany: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DeveloperMenuProvider = ({
|
||||||
|
children,
|
||||||
|
developerMode = false,
|
||||||
|
currentPage = null,
|
||||||
|
currentSubPage = null,
|
||||||
|
currentBlog = null,
|
||||||
|
currentProject = null,
|
||||||
|
currentCompany = null,
|
||||||
|
subPageVisible = false,
|
||||||
|
blogVisible = false,
|
||||||
|
projectVisible = false,
|
||||||
|
experienceVisible = false,
|
||||||
|
}) => {
|
||||||
|
const pageEntity =
|
||||||
|
subPageVisible && currentSubPage?.slug ? currentSubPage : currentPage;
|
||||||
|
const pageSlug = pageEntity?.slug ?? null;
|
||||||
|
const pagePath = pageSlug ? `/${pageSlug}` : "/";
|
||||||
|
const pageAvailable = Boolean(pageEntity?.slug);
|
||||||
|
|
||||||
|
const blogSlug = currentBlog?.slug ?? null;
|
||||||
|
const blogPath = blogSlug ? `/blogs/${blogSlug}` : null;
|
||||||
|
const blogAvailable = Boolean(blogVisible && blogSlug);
|
||||||
|
|
||||||
|
const projectSlug = currentProject?.slug ?? null;
|
||||||
|
const projectPath = projectSlug ? `/projects/${projectSlug}` : null;
|
||||||
|
const projectAvailable = Boolean(projectVisible && projectSlug);
|
||||||
|
|
||||||
|
const experienceSlug = currentCompany?.slug ?? null;
|
||||||
|
const experiencePath = experienceSlug
|
||||||
|
? `/experience/${experienceSlug}`
|
||||||
|
: null;
|
||||||
|
const experienceAvailable = Boolean(experienceVisible && experienceSlug);
|
||||||
|
|
||||||
|
const targets = useMemo(
|
||||||
|
() => ({
|
||||||
|
page: {
|
||||||
|
entity: pageEntity,
|
||||||
|
slug: pageSlug,
|
||||||
|
path: pageAvailable ? pagePath : "/",
|
||||||
|
available: pageAvailable,
|
||||||
|
visible: Boolean(pageEntity),
|
||||||
|
},
|
||||||
|
blog: {
|
||||||
|
entity: currentBlog,
|
||||||
|
slug: blogSlug,
|
||||||
|
path: blogAvailable ? blogPath : null,
|
||||||
|
available: blogAvailable,
|
||||||
|
visible: blogAvailable,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
entity: currentProject,
|
||||||
|
slug: projectSlug,
|
||||||
|
path: projectAvailable ? projectPath : null,
|
||||||
|
available: projectAvailable,
|
||||||
|
visible: projectAvailable,
|
||||||
|
},
|
||||||
|
experience: {
|
||||||
|
entity: currentCompany,
|
||||||
|
slug: experienceSlug,
|
||||||
|
path: experienceAvailable ? experiencePath : null,
|
||||||
|
available: experienceAvailable,
|
||||||
|
visible: experienceAvailable,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
pageEntity,
|
||||||
|
pageSlug,
|
||||||
|
pageAvailable,
|
||||||
|
pagePath,
|
||||||
|
currentBlog,
|
||||||
|
blogSlug,
|
||||||
|
blogAvailable,
|
||||||
|
blogPath,
|
||||||
|
currentProject,
|
||||||
|
projectSlug,
|
||||||
|
projectAvailable,
|
||||||
|
projectPath,
|
||||||
|
currentCompany,
|
||||||
|
experienceSlug,
|
||||||
|
experienceAvailable,
|
||||||
|
experiencePath,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeType = useMemo(() => {
|
||||||
|
if (!developerMode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (targets.blog.visible) {
|
||||||
|
return "blog";
|
||||||
|
}
|
||||||
|
if (targets.project.visible) {
|
||||||
|
return "project";
|
||||||
|
}
|
||||||
|
if (targets.experience.visible) {
|
||||||
|
return "experience";
|
||||||
|
}
|
||||||
|
if (targets.page.visible) {
|
||||||
|
return "page";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [developerMode, targets]);
|
||||||
|
|
||||||
|
const activeTarget = activeType ? targets[activeType] : defaultTarget;
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
developerMode,
|
||||||
|
isVisible: Boolean(developerMode),
|
||||||
|
currentPage,
|
||||||
|
currentSubPage,
|
||||||
|
currentBlog,
|
||||||
|
currentProject,
|
||||||
|
currentCompany,
|
||||||
|
activeType,
|
||||||
|
activeEntity: activeTarget.entity,
|
||||||
|
activeSlug: activeTarget.slug,
|
||||||
|
activePath: activeTarget.path,
|
||||||
|
targets,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
developerMode,
|
||||||
|
currentPage,
|
||||||
|
currentSubPage,
|
||||||
|
currentBlog,
|
||||||
|
currentProject,
|
||||||
|
currentCompany,
|
||||||
|
activeType,
|
||||||
|
activeTarget.entity,
|
||||||
|
activeTarget.slug,
|
||||||
|
activeTarget.path,
|
||||||
|
targets,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeveloperMenuContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<DeveloperMenuOverlay />
|
||||||
|
</DeveloperMenuContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DeveloperMenuProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
developerMode: PropTypes.bool,
|
||||||
|
currentPage: PropTypes.object,
|
||||||
|
currentSubPage: PropTypes.object,
|
||||||
|
currentBlog: PropTypes.object,
|
||||||
|
currentProject: PropTypes.object,
|
||||||
|
currentCompany: PropTypes.object,
|
||||||
|
subPageVisible: PropTypes.bool,
|
||||||
|
blogVisible: PropTypes.bool,
|
||||||
|
projectVisible: PropTypes.bool,
|
||||||
|
experienceVisible: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeveloperMenu = () => useContext(DeveloperMenuContext);
|
||||||
|
|
||||||
|
const ACTION_TYPE_KEYS = {
|
||||||
|
page: "page",
|
||||||
|
blog: "blog",
|
||||||
|
project: "project",
|
||||||
|
experience: "experience",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeveloperMenuOverlay = () => {
|
||||||
|
const {
|
||||||
|
developerMode,
|
||||||
|
disableDeveloperMode,
|
||||||
|
triggerAction,
|
||||||
|
lastAction,
|
||||||
|
actionStatus,
|
||||||
|
actionError,
|
||||||
|
actionResult,
|
||||||
|
} = useDeveloper();
|
||||||
|
const developerMenu = useDeveloperMenu();
|
||||||
|
|
||||||
|
const isVisible =
|
||||||
|
developerMode && developerMenu?.isVisible && developerMenu?.targets;
|
||||||
|
|
||||||
|
const resolveTargetForAction = useCallback(
|
||||||
|
(action) => {
|
||||||
|
const targets = developerMenu?.targets;
|
||||||
|
if (!targets) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switch (action) {
|
||||||
|
case "reloadPage":
|
||||||
|
return targets.page?.available ? targets.page : null;
|
||||||
|
case "reloadBlog":
|
||||||
|
return targets.blog?.available ? targets.blog : null;
|
||||||
|
case "reloadProject":
|
||||||
|
return targets.project?.available ? targets.project : null;
|
||||||
|
case "reloadExperience":
|
||||||
|
return targets.experience?.available ? targets.experience : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[developerMenu?.targets]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createActionParams = useCallback(
|
||||||
|
(action) => {
|
||||||
|
const target = resolveTargetForAction(action);
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedPath = target.path ?? "/";
|
||||||
|
const notionId = target.entity?.notionId ?? null;
|
||||||
|
const params = {
|
||||||
|
path: normalizedPath,
|
||||||
|
normalizedPath,
|
||||||
|
};
|
||||||
|
if (target.slug) {
|
||||||
|
params.slug = target.slug;
|
||||||
|
}
|
||||||
|
if (notionId) {
|
||||||
|
params.notionId = notionId;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
[resolveTargetForAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleActionButtonClick = useCallback(
|
||||||
|
async (action) => {
|
||||||
|
const params = createActionParams(action);
|
||||||
|
if (params === null) {
|
||||||
|
console.warn(`No parameters available for action "${action}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await triggerAction(action, params);
|
||||||
|
},
|
||||||
|
[createActionParams, triggerAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPending = actionStatus === "pending";
|
||||||
|
|
||||||
|
const actionFeedback = useMemo(() => {
|
||||||
|
if (!lastAction || actionStatus === "idle") {
|
||||||
|
return { message: null, status: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = ACTION_LABELS[lastAction] ?? "Action";
|
||||||
|
|
||||||
|
if (actionStatus === "pending") {
|
||||||
|
return { message: `Running ${label.toLowerCase()}…`, status: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionStatus === "success") {
|
||||||
|
const payloadMessage =
|
||||||
|
typeof actionResult === "string" ? actionResult : actionResult?.message;
|
||||||
|
return {
|
||||||
|
message: payloadMessage || `${label} refreshed.`,
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionStatus === "error") {
|
||||||
|
const errorMessage =
|
||||||
|
actionError?.message || actionError?.toString() || `${label} failed`;
|
||||||
|
return { message: errorMessage, status: "error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: null, status: "idle" };
|
||||||
|
}, [lastAction, actionStatus, actionError, actionResult]);
|
||||||
|
|
||||||
|
const targets = developerMenu?.targets ?? {};
|
||||||
|
const activeType = developerMenu?.activeType ?? null;
|
||||||
|
|
||||||
|
const buildTitle = useCallback((type, target) => {
|
||||||
|
const name =
|
||||||
|
target?.entity?.name ||
|
||||||
|
target?.entity?.title ||
|
||||||
|
target?.entity?.slug ||
|
||||||
|
target?.slug;
|
||||||
|
if (target?.available && name) {
|
||||||
|
return `Reload cache for ${type} "${name}"`;
|
||||||
|
}
|
||||||
|
if (target?.available) {
|
||||||
|
return `Reload current ${type} cache`;
|
||||||
|
}
|
||||||
|
return `Navigate to a ${type} to enable`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const developerButtons = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
action: "reloadPage",
|
||||||
|
label: "Page",
|
||||||
|
isEnabled: Boolean(targets.page?.available),
|
||||||
|
isActive: activeType === ACTION_TYPE_KEYS.page,
|
||||||
|
title: buildTitle("page", targets.page),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "reloadBlog",
|
||||||
|
label: "Blog",
|
||||||
|
isEnabled: Boolean(targets.blog?.available),
|
||||||
|
isActive: activeType === ACTION_TYPE_KEYS.blog,
|
||||||
|
title: buildTitle("blog", targets.blog),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "reloadProject",
|
||||||
|
label: "Project",
|
||||||
|
isEnabled: Boolean(targets.project?.available),
|
||||||
|
isActive: activeType === ACTION_TYPE_KEYS.project,
|
||||||
|
title: buildTitle("project", targets.project),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "reloadExperience",
|
||||||
|
label: "Experience",
|
||||||
|
isEnabled: Boolean(targets.experience?.available),
|
||||||
|
isActive: activeType === ACTION_TYPE_KEYS.experience,
|
||||||
|
title: buildTitle("experience", targets.experience),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[targets, activeType, buildTitle]
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryDeveloperButton = useMemo(() => {
|
||||||
|
if (!developerButtons.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const activeButton = developerButtons.find((button) => button.isActive);
|
||||||
|
if (activeButton) {
|
||||||
|
return activeButton;
|
||||||
|
}
|
||||||
|
const enabledButton = developerButtons.find((button) => button.isEnabled);
|
||||||
|
if (enabledButton) {
|
||||||
|
return enabledButton;
|
||||||
|
}
|
||||||
|
return developerButtons[0];
|
||||||
|
}, [developerButtons]);
|
||||||
|
|
||||||
|
const isPrimaryActionPending =
|
||||||
|
primaryDeveloperButton &&
|
||||||
|
isPending &&
|
||||||
|
lastAction === primaryDeveloperButton.action;
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="tb-developer-popup"
|
||||||
|
role="region"
|
||||||
|
aria-label="Developer controls"
|
||||||
|
>
|
||||||
|
<div className="tb-developer-popup-container">
|
||||||
|
<div className="tb-developer-popup-actions">
|
||||||
|
{primaryDeveloperButton && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tb-button tb-menu-popup-button`}
|
||||||
|
onClick={() =>
|
||||||
|
handleActionButtonClick(primaryDeveloperButton.action)
|
||||||
|
}
|
||||||
|
disabled={!primaryDeveloperButton.isEnabled || isPending}
|
||||||
|
title={primaryDeveloperButton.title}
|
||||||
|
aria-pressed={primaryDeveloperButton.isActive}
|
||||||
|
aria-busy={isPrimaryActionPending}
|
||||||
|
>
|
||||||
|
{isPrimaryActionPending ? (
|
||||||
|
<>
|
||||||
|
{primaryDeveloperButton.label}
|
||||||
|
<LoadingIcon />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{primaryDeveloperButton.label}
|
||||||
|
<ReloadIcon />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="tb-developer-popup-actions-divider" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tb-button tb-menu-popup-button tb-developer-exit-button"
|
||||||
|
onClick={disableDeveloperMode}
|
||||||
|
disabled={isPending}
|
||||||
|
title="Disable developer mode"
|
||||||
|
>
|
||||||
|
Exit Developer Mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -191,7 +191,14 @@ const imageCache = new ImageCache();
|
|||||||
export const ImageProvider = ({ children }) => {
|
export const ImageProvider = ({ children }) => {
|
||||||
const [imageObjects, setImageObjects] = useState([]);
|
const [imageObjects, setImageObjects] = useState([]);
|
||||||
const [allImagesLoaded, setAllImagesLoaded] = useState(false);
|
const [allImagesLoaded, setAllImagesLoaded] = useState(false);
|
||||||
|
const removeQueryParams = useCallback((url) => {
|
||||||
|
if (!url) return url;
|
||||||
|
if (url.includes("?")) {
|
||||||
|
// Remove query parameters if they exist
|
||||||
|
return url.split("?")[0];
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}, []);
|
||||||
const loadImages = useCallback((images) => {
|
const loadImages = useCallback((images) => {
|
||||||
// images: [{ blurHash, url, mirrorUrl? }]
|
// images: [{ blurHash, url, mirrorUrl? }]
|
||||||
// Only initialize image objects with metadata, don't load them yet
|
// Only initialize image objects with metadata, don't load them yet
|
||||||
@ -216,7 +223,10 @@ export const ImageProvider = ({ children }) => {
|
|||||||
const loadIndividualImage = useCallback(
|
const loadIndividualImage = useCallback(
|
||||||
async (url) => {
|
async (url) => {
|
||||||
console.log(`[ImageProvider] loadIndividualImage called for: ${url}`);
|
console.log(`[ImageProvider] loadIndividualImage called for: ${url}`);
|
||||||
const imageObj = imageObjects.find((img) => img.src === url);
|
const urlWithoutParams = removeQueryParams(url);
|
||||||
|
const imageObj = imageObjects.find(
|
||||||
|
(img) => removeQueryParams(img.src) === urlWithoutParams
|
||||||
|
);
|
||||||
|
|
||||||
// If image object doesn't exist, we can't load it
|
// If image object doesn't exist, we can't load it
|
||||||
if (!imageObj) {
|
if (!imageObj) {
|
||||||
@ -235,15 +245,18 @@ export const ImageProvider = ({ children }) => {
|
|||||||
return; // Already loading or loaded
|
return; // Already loading or loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if image is already cached
|
// Use the imageObj.src (which might not have query params) for cache and state operations
|
||||||
if (imageCache.isCached(url)) {
|
const cacheKey = imageObj.src;
|
||||||
|
|
||||||
|
// Check if image is already cached (use the image object's src, which might not have query params)
|
||||||
|
if (imageCache.isCached(cacheKey)) {
|
||||||
console.log(
|
console.log(
|
||||||
`[ImageProvider] Image found in cache, updating state: ${url}`
|
`[ImageProvider] Image found in cache, updating state: ${cacheKey}`
|
||||||
);
|
);
|
||||||
const base64 = imageCache.get(url);
|
const base64 = imageCache.get(cacheKey);
|
||||||
setImageObjects((prev) =>
|
setImageObjects((prev) =>
|
||||||
prev.map((img) =>
|
prev.map((img) =>
|
||||||
img.src === url
|
removeQueryParams(img.src) === urlWithoutParams
|
||||||
? { ...img, blob: base64, loadingState: "loaded" }
|
? { ...img, blob: base64, loadingState: "loaded" }
|
||||||
: img
|
: img
|
||||||
)
|
)
|
||||||
@ -252,19 +265,21 @@ export const ImageProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[ImageProvider] Starting to load individual image: ${url}`);
|
console.log(`[ImageProvider] Starting to load individual image: ${url}`);
|
||||||
// Update loading state
|
// Update loading state using normalized comparison
|
||||||
setImageObjects((prev) =>
|
setImageObjects((prev) =>
|
||||||
prev.map((img) =>
|
prev.map((img) =>
|
||||||
img.src === url ? { ...img, loadingState: "loading" } : img
|
removeQueryParams(img.src) === urlWithoutParams
|
||||||
|
? { ...img, loadingState: "loading" }
|
||||||
|
: img
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mirrorUrl = imageObj.mirrorUrl;
|
const mirrorUrl = imageObj.mirrorUrl;
|
||||||
const base64 = await imageCache.loadImage(url, mirrorUrl);
|
const base64 = await imageCache.loadImage(cacheKey, mirrorUrl);
|
||||||
setImageObjects((prev) =>
|
setImageObjects((prev) =>
|
||||||
prev.map((img) =>
|
prev.map((img) =>
|
||||||
img.src === url
|
removeQueryParams(img.src) === urlWithoutParams
|
||||||
? { ...img, blob: base64, loadingState: "loaded" }
|
? { ...img, blob: base64, loadingState: "loaded" }
|
||||||
: img
|
: img
|
||||||
)
|
)
|
||||||
@ -273,12 +288,14 @@ export const ImageProvider = ({ children }) => {
|
|||||||
console.error(`[ImageProvider] Failed to load image: ${url}`, error);
|
console.error(`[ImageProvider] Failed to load image: ${url}`, error);
|
||||||
setImageObjects((prev) =>
|
setImageObjects((prev) =>
|
||||||
prev.map((img) =>
|
prev.map((img) =>
|
||||||
img.src === url ? { ...img, loadingState: "error" } : img
|
removeQueryParams(img.src) === urlWithoutParams
|
||||||
|
? { ...img, loadingState: "error" }
|
||||||
|
: img
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[imageObjects]
|
[imageObjects, removeQueryParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -9,9 +9,9 @@ import Keycloak from "keycloak-js";
|
|||||||
|
|
||||||
// Initialize Keycloak
|
// Initialize Keycloak
|
||||||
const keycloak = new Keycloak({
|
const keycloak = new Keycloak({
|
||||||
url: "https://auth.tombutcher.work", // Your Keycloak server
|
url: import.meta.env.VITE_KEYCLOAK_URL, // Your Keycloak server
|
||||||
realm: "master", // Your Keycloak realm
|
realm: import.meta.env.VITE_KEYCLOAK_REALM, // Your Keycloak realm
|
||||||
clientId: "2025-web-client", // Your Keycloak client ID
|
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, // Your Keycloak client ID
|
||||||
});
|
});
|
||||||
|
|
||||||
const KeycloakContext = createContext(null);
|
const KeycloakContext = createContext(null);
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export const MenuProvider = ({
|
|||||||
!menuPopupRef.current.contains(event.target)
|
!menuPopupRef.current.contains(event.target)
|
||||||
) {
|
) {
|
||||||
// Don't close if clicking on CV download popup or its button
|
// Don't close if clicking on CV download popup or its button
|
||||||
const cvPopup = document.querySelector(".tb-cv-download-popup");
|
const cvPopup = document.querySelector(".tb-cv-popup");
|
||||||
const websiteSelectorPopup = document.querySelector(
|
const websiteSelectorPopup = document.querySelector(
|
||||||
".tb-website-selector-popup"
|
".tb-website-selector-popup"
|
||||||
);
|
);
|
||||||
|
|||||||
160
src/contexts/MessageContext.jsx
Normal file
160
src/contexts/MessageContext.jsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
const DEFAULT_AUTO_HIDE_MS = 5000;
|
||||||
|
|
||||||
|
const MessageContext = createContext({
|
||||||
|
isVisible: false,
|
||||||
|
message: null,
|
||||||
|
icon: null,
|
||||||
|
showMessage: () => {},
|
||||||
|
hideMessage: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MessageProvider = ({
|
||||||
|
children,
|
||||||
|
defaultAutoHideDelay = DEFAULT_AUTO_HIDE_MS,
|
||||||
|
}) => {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
message: null,
|
||||||
|
icon: null,
|
||||||
|
isVisible: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hideTimeoutRef = useRef(null);
|
||||||
|
const clearHideTimeout = useCallback(() => {
|
||||||
|
if (hideTimeoutRef.current) {
|
||||||
|
window.clearTimeout(hideTimeoutRef.current);
|
||||||
|
hideTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hideMessage = useCallback(() => {
|
||||||
|
clearHideTimeout();
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isVisible: false,
|
||||||
|
}));
|
||||||
|
}, [clearHideTimeout]);
|
||||||
|
|
||||||
|
const showMessage = useCallback(
|
||||||
|
(messageText, icon = null, autoHide = true) => {
|
||||||
|
if (!messageText) {
|
||||||
|
console.warn("showMessage called without message text");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHideTimeout();
|
||||||
|
|
||||||
|
setState({
|
||||||
|
message: messageText,
|
||||||
|
icon,
|
||||||
|
isVisible: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedDelay =
|
||||||
|
typeof autoHide === "number" && autoHide > 0
|
||||||
|
? autoHide
|
||||||
|
: defaultAutoHideDelay;
|
||||||
|
|
||||||
|
if (autoHide && typeof window !== "undefined") {
|
||||||
|
hideTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isVisible: false,
|
||||||
|
}));
|
||||||
|
hideTimeoutRef.current = null;
|
||||||
|
}, resolvedDelay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearHideTimeout, defaultAutoHideDelay]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearHideTimeout();
|
||||||
|
};
|
||||||
|
}, [clearHideTimeout]);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
isVisible: state.isVisible,
|
||||||
|
message: state.message,
|
||||||
|
icon: state.icon,
|
||||||
|
showMessage,
|
||||||
|
hideMessage,
|
||||||
|
}),
|
||||||
|
[state.isVisible, state.message, state.icon, showMessage, hideMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
<MessageOverlay
|
||||||
|
message={state.message}
|
||||||
|
icon={state.icon}
|
||||||
|
isVisible={state.isVisible}
|
||||||
|
onDismiss={hideMessage}
|
||||||
|
/>
|
||||||
|
</MessageContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
defaultAutoHideDelay: PropTypes.number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMessage = () => useContext(MessageContext);
|
||||||
|
|
||||||
|
const MessageOverlay = ({ message, icon, isVisible, onDismiss }) => {
|
||||||
|
const shouldRender = Boolean(message);
|
||||||
|
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
"tb-message-popup",
|
||||||
|
isVisible ? "tb-message-popup-visible" : "tb-message-popup-hidden",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-hidden={!isVisible}
|
||||||
|
>
|
||||||
|
<div className="tb-message-popup-content">
|
||||||
|
{icon ? <span className="tb-message-popup-icon">{icon}</span> : null}
|
||||||
|
<span className="tb-message-popup-text">{message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tb-message-popup-dismiss"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss message"
|
||||||
|
>
|
||||||
|
{"\u00d7"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageOverlay.propTypes = {
|
||||||
|
message: PropTypes.string,
|
||||||
|
icon: PropTypes.node,
|
||||||
|
isVisible: PropTypes.bool.isRequired,
|
||||||
|
onDismiss: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import PropTypes from "prop-types";
|
|
||||||
import { createContext, useContext, useState, useEffect } from "react";
|
|
||||||
|
|
||||||
// Create context
|
|
||||||
const ProjectsContext = createContext({
|
|
||||||
projects: [],
|
|
||||||
setProjects: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provider
|
|
||||||
export const ProjectsProvider = ({ children, initialProjects = [] }) => {
|
|
||||||
const [projects, setProjects] = useState(initialProjects);
|
|
||||||
|
|
||||||
// Update projects whenever initialProjects changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialProjects && initialProjects.length > 0) {
|
|
||||||
setProjects(initialProjects);
|
|
||||||
}
|
|
||||||
}, [initialProjects]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProjectsContext.Provider value={{ projects, setProjects }}>
|
|
||||||
{children}
|
|
||||||
</ProjectsContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ProjectsProvider.propTypes = {
|
|
||||||
children: PropTypes.any,
|
|
||||||
initialProjects: PropTypes.array,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for consuming
|
|
||||||
export const useProjects = () => useContext(ProjectsContext);
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
const SettingsContext = createContext(null);
|
|
||||||
|
|
||||||
export const SettingsProvider = ({ settings: initialSettings, children }) => {
|
|
||||||
const [settings, setSettings] = useState(initialSettings);
|
|
||||||
|
|
||||||
// Update internal state whenever the prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSettings(initialSettings);
|
|
||||||
}, [initialSettings]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContext.Provider value={settings}>
|
|
||||||
{children}
|
|
||||||
</SettingsContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
SettingsProvider.propTypes = {
|
|
||||||
settings: PropTypes.object.isRequired,
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSettingsContext = () => {
|
|
||||||
const settings = useContext(SettingsContext);
|
|
||||||
if (!settings) {
|
|
||||||
throw new Error("useSettingsContext must be used inside SettingsProvider");
|
|
||||||
}
|
|
||||||
return settings;
|
|
||||||
};
|
|
||||||
7
src/icons/ErrorCloudIcon.jsx
Normal file
7
src/icons/ErrorCloudIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import ErrorCloudIconSvg from "../../assets/errorcloudicon.svg?react";
|
||||||
|
|
||||||
|
const ErrorCloudIcon = () => {
|
||||||
|
return <ErrorCloudIconSvg />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorCloudIcon;
|
||||||
7
src/icons/ReloadIcon.jsx
Normal file
7
src/icons/ReloadIcon.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import ReloadIconSvg from "../../assets/reloadicon.svg?react";
|
||||||
|
|
||||||
|
const ReloadIcon = () => {
|
||||||
|
return <ReloadIconSvg />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReloadIcon;
|
||||||
@ -46,6 +46,65 @@
|
|||||||
transition: opacity 1s ease-in-out;
|
transition: opacity 1s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-particles-fps-monitor {
|
||||||
|
position: fixed;
|
||||||
|
top: 90px;
|
||||||
|
right: var(--tb-page-mobile-padding);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--tb-textColor) 25%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--tb-backgroundColor) 18%, transparent);
|
||||||
|
color: var(--tb-textColor);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
box-shadow: 0 16px 36px
|
||||||
|
color-mix(in srgb, var(--tb-backgroundColor) 25%, transparent);
|
||||||
|
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-particles-fps-monitor-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-particles-fps-monitor-divider {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 1px;
|
||||||
|
background: color-mix(in srgb, var(--tb-textColor) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-particles-fps-monitor-label {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-particles-fps-monitor-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-particles-fps-monitor-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-particles-fps-monitor-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
.tb-particles-visible {
|
.tb-particles-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@ -55,7 +114,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tb-particles-background {
|
.tb-particles-background {
|
||||||
backdrop-filter: blur(40px);
|
backdrop-filter: blur(45px);
|
||||||
|
-webkit-backdrop-filter: blur(45px);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -83,6 +143,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
|
width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tb-menu-popup-button,
|
.tb-menu-popup-button,
|
||||||
@ -191,6 +252,109 @@
|
|||||||
transform: scale(0.5);
|
transform: scale(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-error-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-message {
|
||||||
|
padding: 20px 30px;
|
||||||
|
background-color: #b00000;
|
||||||
|
border: 1px solid #ff0000;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 25px;
|
||||||
|
box-shadow: 0px 8.5px 20px 0px #000000;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-message h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-message-divider {
|
||||||
|
width: 50%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: #ff0000;
|
||||||
|
margin: 17px 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-message p {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-button {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #b00000;
|
||||||
|
border: 1px solid #ff0000;
|
||||||
|
box-shadow: 0px 8.5px 20px 0px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-button:not(:disabled):hover {
|
||||||
|
background-color: #b00000;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-secondary-button {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #b000004e;
|
||||||
|
border: 1px solid #ff000072;
|
||||||
|
box-shadow: 0px 8.5px 20px 0px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-secondary-button:not(:disabled):hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #b000004e;
|
||||||
|
border: 1px solid #ff000052;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-details {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 320px;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.85);
|
||||||
|
border: 1px solid rgba(255, 0, 0, 0.45);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0px 8.5px 24px 0px rgba(0, 0, 0, 0.45);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-error-icon {
|
||||||
|
fill: #b00000;
|
||||||
|
width: 150px;
|
||||||
|
stroke: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
/* Menu Components */
|
/* Menu Components */
|
||||||
.tb-menu-popup {
|
.tb-menu-popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -314,6 +478,199 @@
|
|||||||
justify-content: end;
|
justify-content: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Developer Popup */
|
||||||
|
.tb-developer-popup {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--tb-header-footer-vertical-padding) + 24px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--tb-backgroundColor) 55%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
box-shadow: -1px 3px 50px 4px var(--tb-backgroundColor);
|
||||||
|
border-radius: 30px;
|
||||||
|
z-index: 15;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom right,
|
||||||
|
color-mix(in srgb, var(--tb-textColor) 15%, transparent),
|
||||||
|
color-mix(in srgb, var(--tb-textColor) 10%, transparent),
|
||||||
|
color-mix(in srgb, var(--tb-textColor) 15%, transparent)
|
||||||
|
);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: xor;
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0 13px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-actions .tb-button {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-actions-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: color-mix(in srgb, var(--tb-textColor) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
color: color-mix(in srgb, var(--tb-textColor) 70%, transparent);
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-status-success {
|
||||||
|
color: color-mix(in srgb, var(--tb-textColor) 85%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-status-error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-popup-status-pending {
|
||||||
|
color: color-mix(in srgb, var(--tb-textColor) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-developer-exit-button {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Popup */
|
||||||
|
.tb-message-popup {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--tb-header-footer-vertical-padding) + 24px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -12px) scale(0.98);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--tb-backgroundColor) 55%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0px 16px 40px -12px color-mix(in srgb, var(--tb-backgroundColor)
|
||||||
|
80%, transparent);
|
||||||
|
border-radius: 28px;
|
||||||
|
z-index: 20;
|
||||||
|
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(
|
||||||
|
130deg,
|
||||||
|
color-mix(in srgb, var(--tb-textColor) 14%, transparent),
|
||||||
|
color-mix(in srgb, var(--tb-textColor) 8%, transparent),
|
||||||
|
color-mix(in srgb, var(--tb-textColor) 12%, transparent)
|
||||||
|
);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: xor;
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px 10px 18px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
color: color-mix(in srgb, var(--tb-textColor) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup-text {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--tb-textColor);
|
||||||
|
max-width: min(460px, 75vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup-dismiss {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--tb-textColor);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup-dismiss:hover,
|
||||||
|
.tb-message-popup-dismiss:focus-visible {
|
||||||
|
color: color-mix(in srgb, var(--tb-textColor) 90%, transparent);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-message-popup-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -12px) scale(0.98);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Account Menu Popup */
|
/* Account Menu Popup */
|
||||||
.tb-account-menu-popup {
|
.tb-account-menu-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -1557,22 +1914,20 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(50% - 17.5px);
|
top: calc(50% - 17.5px);
|
||||||
font-size: 16px;
|
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
box-shadow: -1px 3px 50px 4px var(--tb-backgroundColor);
|
box-shadow: -1px 3px 35px 4px var(--tb-backgroundColor);
|
||||||
background-color: color-mix(
|
background-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--tb-backgroundColor) 50%,
|
var(--tb-backgroundColor) 50%,
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
font-size: 2rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 35px;
|
width: 40px;
|
||||||
height: 35px;
|
height: 40px;
|
||||||
line-height: 0.1px;
|
line-height: 0.1px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease, opacity 0.3s ease,
|
transition: background-color 0.3s ease, transform 0.2s ease, opacity 0.3s ease,
|
||||||
@ -1580,6 +1935,7 @@
|
|||||||
border: none;
|
border: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tb-scroll-btn::before {
|
.tb-scroll-btn::before {
|
||||||
@ -1608,6 +1964,7 @@
|
|||||||
.tb-scroll-btn > svg {
|
.tb-scroll-btn > svg {
|
||||||
transition: fill 0.3s ease;
|
transition: fill 0.3s ease;
|
||||||
fill: var(--tb-textColor);
|
fill: var(--tb-textColor);
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tb-scroll-btn:hover {
|
.tb-scroll-btn:hover {
|
||||||
@ -1615,16 +1972,28 @@
|
|||||||
transform: translateY(-50%) scale(1.075);
|
transform: translateY(-50%) scale(1.075);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-scroll-btn:active {
|
||||||
|
transform: translateY(-50%) scale(0.975);
|
||||||
|
}
|
||||||
|
|
||||||
.tb-scroll-btn:hover > svg {
|
.tb-scroll-btn:hover > svg {
|
||||||
fill: var(--tb-backgroundColor);
|
fill: var(--tb-backgroundColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tb-scroll-btn.tb-left {
|
.tb-scroll-btn.tb-left {
|
||||||
left: 25px;
|
left: var(--tb-header-footer-horizontal-padding, 25px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tb-scroll-btn.tb-right {
|
.tb-scroll-btn.tb-right {
|
||||||
right: 25px;
|
right: var(--tb-header-footer-horizontal-padding, 25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-page-content-mobile .tb-scroll-btn.tb-left {
|
||||||
|
left: var(--tb-page-mobile-padding, 25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-page-content-mobile .tb-scroll-btn.tb-right {
|
||||||
|
right: var(--tb-page-mobile-padding, 25px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tb-scroll-btn.tb-hidden {
|
.tb-scroll-btn.tb-hidden {
|
||||||
@ -2069,11 +2438,19 @@
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-project-content h1.tb-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tb-project-content-mobile h1.tb-title {
|
.tb-project-content-mobile h1.tb-title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-project-content-mobile h1.tb-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tb-project-content h2.tb-title {
|
.tb-project-content h2.tb-title {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -2081,11 +2458,19 @@
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-project-content h2.tb-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tb-project-content-mobile h2.tb-title {
|
.tb-project-content-mobile h2.tb-title {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tb-project-content-mobile h2.tb-title:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tb-project-content p.tb-paragraph {
|
.tb-project-content p.tb-paragraph {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@ -47,6 +47,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
background-color: #151515;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page component styles */
|
/* Page component styles */
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
plugins: [react(), svgo(), svgr()],
|
plugins: [react(), svgo(), svgr()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
allowedHosts: ["thehideout.tombutcher.work"],
|
allowedHosts: ["dev.tombutcher.work"],
|
||||||
},
|
},
|
||||||
base: "/",
|
base: "/",
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user