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:
Tom Butcher 2025-11-15 19:32:09 +00:00
parent cc6582923a
commit 8ef109b8e7
47 changed files with 2933 additions and 825 deletions

7
.env
View File

@ -1,2 +1,5 @@
VITE_API_URL=https://api.thehideoutltd.com
VITE_TURNSTILE_KEY=0x4AAAAAAB2uebWFPXaK8spB
VITE_API_URL=https://api2026.tombutcher.work
VITE_TURNSTILE_KEY=0x4AAAAAAA_bc3QTrE68whtg
VITE_KEYCLOAK_URL=https://auth.tombutcher.work
VITE_KEYCLOAK_REALM=master
VITE_KEYCLOAK_CLIENT_ID=2026-web-client

View File

@ -1,2 +1,5 @@
VITE_API_URL=https://thehideout.tombutcher.work/api
VITE_TURNSTILE_KEY=0x4AAAAAAB2dBq6i8m4kYzDm
VITE_API_URL=https://dev.tombutcher.work/api
VITE_TURNSTILE_KEY=0x4AAAAAAA_bc3QTrE68whtg
VITE_KEYCLOAK_URL=https://auth.tombutcher.work
VITE_KEYCLOAK_REALM=master
VITE_KEYCLOAK_CLIENT_ID=dev-web-client

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -1,6 +1,6 @@
<?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="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)">
<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;"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

10
assets/reloadicon.svg Normal file
View 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

View File

@ -1,6 +1,6 @@
<?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="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)">
<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)">

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -21,7 +21,7 @@
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link rel="stylesheet" href="/fonts.css" />
<link rel="manifest" href="/manifest.json" />
<title>Tom Butcher</title>
</head>

View File

@ -1,11 +1,7 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { Alert } from "antd";
import { useMediaQuery } from "react-responsive";
import { useNavigate, useLocation } from "react-router-dom";
import { Element, scroller } from "react-scroll";
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
import Page from "./components/Page";
import { ImageProvider, useImageContext } from "./contexts/ImageContext";
import LoadingModal from "./components/LoadingModal";
@ -16,23 +12,22 @@ import ProjectPage from "./components/Projects/ProjectPage";
import ExperiencePage from "./components/Experience/ExperiencePage";
import { MenuProvider, useMenu } from "./contexts/MenuContext";
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 { VideoProvider } from "./contexts/VideoContext";
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 Footer from "./components/Footer";
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
const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
const AppContent = () => {
const { pages, blogs, images, projects, companies, settings, isLoading } =
useContent();
const { loadImages } = useImageContext();
const [loadedOnce, setLoadedOnce] = useState(false);
const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" });
@ -48,6 +43,7 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
const [currentBlog, setCurrentBlog] = useState(null);
const [currentProject, setCurrentProject] = useState(null);
const [currentCompany, setCurrentCompany] = useState(null);
const { developerMode } = useDeveloper();
const [currentPageIdx, setCurrentPageIdx] = useState(0);
const [nextPageIdx, setNextPageIdx] = useState(0);
const [blogVisible, setBlogVisible] = useState(false);
@ -72,7 +68,6 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
const navigate = useNavigate();
const location = useLocation();
const settings = useSettingsContext();
const isMobile = useMediaQuery({ maxWidth: 800 });
const [skipAnimation, setSkipAnimation] = useState(true);
@ -87,13 +82,13 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
}, [settings?.themes]);
useEffect(() => {
if (loading == false) {
if (isLoading == false) {
setTimeout(() => {
setSkipAnimation(false);
console.log("skipAnimation", loading);
console.log("skipAnimation", isLoading);
}, 500); // Reduced from 2000ms to 500ms
}
}, [loading]);
}, [isLoading]);
// Check for iOS webclip and set body className
useEffect(() => {
@ -620,164 +615,195 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
return (
<ThemeProvider currentTheme={currentTheme}>
<Header
large={headerLarge}
pageData={nextPage}
theme={currentTheme}
onMenuToggle={handleMenuToggle}
menuToggled={menuToggled}
isMobile={isMobile}
/>
<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"
}
<DeveloperMenuProvider
developerMode={developerMode}
currentPage={currentPage}
currentSubPage={currentSubPage}
currentBlog={currentBlog}
currentProject={currentProject}
currentCompany={currentCompany}
subPageVisible={subPageVisible}
blogVisible={blogVisible}
projectVisible={projectVisible}
experienceVisible={experienceVisible}
>
<Header
large={headerLarge}
pageData={nextPage}
theme={currentTheme}
onMenuToggle={handleMenuToggle}
menuToggled={menuToggled}
isMobile={isMobile}
/>
<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" : ""}
`}
id="app-container"
>
{landingPages.map((pageData, index) => (
<Element
key={pageData.slug}
name={pageData.slug}
className={
!isMobile
? "tb-page-wrapper"
: "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
id="app-container"
>
{landingPages.map((pageData, index) => (
<Element
key={pageData.slug}
name={pageData.slug}
className={
!isMobile
? "tb-page-wrapper"
: "tb-page-wrapper tb-page-wrapper-mobile"
}
/>
</Element>
))}
</div>
data-name={pageData.slug}
>
<Page
pageData={pageData}
isSubPage={false}
id={index}
particlesVisible={
pageData.notionId == currentPage.notionId &&
subPageVisible == false
}
/>
</Element>
))}
</div>
<SubPage
visible={subPageVisible}
mousePosition={mousePosition}
skipAnimation={skipAnimation}
>
<Page
pageData={currentSubPage}
isSubPage={true}
id={currentPageIdx}
showClose={true}
showMenu={false}
onClose={handleSubPageClose}
particlesVisible={subPageVisible}
<SubPage
visible={subPageVisible}
mousePosition={mousePosition}
skipAnimation={skipAnimation}
>
<Page
pageData={currentSubPage}
isSubPage={true}
id={currentPageIdx}
showClose={true}
showMenu={false}
onClose={handleSubPageClose}
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>
<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}
/>
</DeveloperMenuProvider>
</ThemeProvider>
);
};
AppContent.propTypes = {
images: PropTypes.shape({
length: PropTypes.number,
}),
pages: PropTypes.array.isRequired,
blogs: PropTypes.array.isRequired,
projects: PropTypes.array.isRequired,
companies: PropTypes.array.isRequired,
themes: PropTypes.shape({
find: PropTypes.func,
}),
loading: PropTypes.bool,
const AppProviders = () => {
const { triggerAction, registerReloadedContentHandler } = useDeveloper();
const { updateContent, invalidateContent, pages, cvs, isLoading } =
useContent();
const mergeReloadedContent = useCallback(
(reloadedContent) => {
if (!reloadedContent || typeof reloadedContent !== "object") {
return;
}
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 = {
themes: [],
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;
const AppContentRoot = () => {
const { isError, error, errorMessage } = useContent();
if (isError) {
console.error("Error fetching content:", error);
@ -785,54 +811,27 @@ const App = () => {
if (errorMessage) {
return (
<div className="tb-error-container">
<Alert
message="Error Loading Pages"
description={errorMessage}
type="error"
showIcon
/>
</div>
<AppError
message={errorMessage}
error={error}
onRetry={() => window.location.reload()}
/>
);
}
return (
<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>
);
return <AppProviders />;
};
const App = () => (
<KeycloakProvider>
<MessageProvider>
<DeveloperProvider>
<ContentProvider>
<AppContentRoot />
</ContentProvider>
</DeveloperProvider>
</MessageProvider>
</KeycloakProvider>
);
export default App;

186
src/components/AppError.jsx Normal file
View 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;

View File

@ -1,9 +1,8 @@
import PropTypes from "prop-types";
import { useBlogs } from "../../contexts/BlogsContext";
import { useContent } from "../../contexts/ContentContext";
import BlogListItem from "./BlogListItem.jsx";
const BlogList = () => {
const { blogs } = useBlogs();
const { blogs } = useContent();
return (
<div className="tb-blog-list-container">
@ -16,8 +15,4 @@ const BlogList = () => {
);
};
BlogList.propTypes = {
blogs: PropTypes.array,
};
export default BlogList;

View File

@ -3,13 +3,13 @@ import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "../ContentRenderer";
import { useMediaQuery } from "react-responsive";
import { useSettingsContext } from "../../contexts/SettingsContext";
import { useContent } from "../../contexts/ContentContext";
import ShareButton from "../Buttons/ShareButton";
const { Content } = Layout;
const BlogPage = memo(({ blogData }) => {
const settings = useSettingsContext();
const { settings } = useContent();
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });

View File

@ -29,7 +29,12 @@ const AccountButton = ({
accountToggled == false ? " tb-hidden" : ""
}`}
>
<MenuButton isInverted={false} theme={theme} toggled={true} />
<MenuButton
as="div"
isInverted={false}
theme={theme}
toggled={true}
/>
</div>
</button>
);

View File

@ -1,9 +1,20 @@
import { Fade as Hamburger } from "hamburger-react";
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 (
<button className="tb-header-button tb-header-button-menu">
<Component
className="tb-header-button tb-header-button-menu"
{...(isNativeButton ? { type: "button" } : {})}
>
<Hamburger
toggled={toggled}
toggle={onToggle}
@ -15,7 +26,7 @@ const MenuButton = ({ isInverted = false, theme, toggled, onToggle }) => {
: theme?.textColor || "#ffffff"
}
/>
</button>
</Component>
);
};
@ -24,6 +35,7 @@ MenuButton.propTypes = {
theme: PropTypes.object,
toggled: PropTypes.bool,
onToggle: PropTypes.func,
as: PropTypes.elementType,
};
export default MenuButton;

View File

@ -31,7 +31,12 @@ const ShareButton = ({ blogData, projectData, theme = null }) => {
sharePopupVisible == false ? " tb-hidden" : ""
}`}
>
<MenuButton isInverted={false} theme={theme} toggled={true} />
<MenuButton
as="div"
isInverted={false}
theme={theme}
toggled={true}
/>
</div>
</button>
<SharePopupMenu

View File

@ -4,7 +4,7 @@ import BlogList from "./Blogs/BlogList";
import { useNavigate } from "react-router-dom";
import SimpleBar from "simplebar-react";
import Image from "./Image";
import { useRef, useState, useEffect } from "react";
import { useRef, useState, useEffect, Fragment } from "react";
import CompaniesList from "./Experience/CompaniesList";
import dayjs from "dayjs";
import RightIcon from "../icons/RightIcon";
@ -14,18 +14,38 @@ import VisitIcon from "../icons/VisitIcon";
const gradientHeight = 60;
const renderAnnotation = (textObject = {}, navigate) => {
const renderAnnotation = (textObject = {}, navigate, index) => {
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) {
return (
<i>{renderAnnotation({ ...textObject, italic: false }, navigate)}</i>
<i key={index}>
{renderAnnotation(
{ ...textObject, italic: false },
navigate,
index + "-italic"
)}
</i>
);
}
if (textObject.underline == true) {
return (
<u>{renderAnnotation({ ...textObject, underline: false }, navigate)}</u>
<u key={index}>
{renderAnnotation(
{ ...textObject, underline: false },
navigate,
index + "-underline"
)}
</u>
);
}
if (textObject.link != undefined) {
@ -35,13 +55,18 @@ const renderAnnotation = (textObject = {}, navigate) => {
<a
className="tb-link"
href={url}
key={index}
onClick={(e) => {
e.preventDefault();
navigate(url);
}}
>
<span className="tb-link-text">
{renderAnnotation({ ...textObject, link: undefined }, navigate)}
{renderAnnotation(
{ ...textObject, link: undefined },
navigate,
index + "-link"
)}
</span>
</a>
);
@ -51,13 +76,18 @@ const renderAnnotation = (textObject = {}, navigate) => {
<a
className="tb-link"
href={url}
key={index}
onClick={(e) => {
e.preventDefault();
window.open(url, "_blank");
}}
>
<span className="tb-link-text">
{renderAnnotation({ ...textObject, link: undefined }, navigate)}
{renderAnnotation(
{ ...textObject, link: undefined },
navigate,
index + "-link"
)}
</span>
<span className="tb-link-icon">
<VisitIcon />
@ -69,8 +99,8 @@ const renderAnnotation = (textObject = {}, navigate) => {
return textObject.text;
};
const renderText = (text = [], navigate) => {
return text.map((item) => {
return renderAnnotation(item, navigate);
return text.map((item, index) => {
return renderAnnotation(item, navigate, index);
});
};
@ -201,17 +231,17 @@ const renderContentElement = (
/>
);
case "blogs":
return <BlogList blogs={[]} />;
return <BlogList blogs={[]} key={index} />;
case "projects":
return <ProjectCards projects={[]} />;
return <ProjectCards projects={[]} key={index} />;
case "companies":
return <CompaniesList companies={[]} />;
return <CompaniesList companies={[]} key={index} />;
case "contactForm":
return <ContactForm />;
return <ContactForm key={index} />;
case "image":
return <Image src={element.url} alt={element.caption} />;
return <Image src={element.url} alt={element.caption} key={index} />;
case "video":
return <Video src={element.url} alt={element.caption} />;
return <Video src={element.url} alt={element.caption} key={index} />;
case "columnFlex": {
const { children = [] } = element;
return (
@ -259,7 +289,7 @@ const renderContentElement = (
case "positionsTimeline": {
const { children = [] } = element;
return (
<>
<Fragment key={index}>
<h1 className="tb-title">Positions</h1>
<div className="tb-positions-timeline">
{children.map((child, childIdx) =>
@ -274,7 +304,7 @@ const renderContentElement = (
)
)}
</div>
</>
</Fragment>
);
}
case "positionTimelineItem": {

View File

@ -1,9 +1,9 @@
import PropTypes from "prop-types";
import CompaniesListItem from "./CompaniesListItem.jsx";
import { useCompanies } from "../../contexts/CompaniesContext";
import { useContent } from "../../contexts/ContentContext";
const CompaniesList = () => {
const { companies } = useCompanies();
const { companies } = useContent();
return (
<div className="tb-companies-list-container">
<div className="tb-companies-list">

View File

@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
const CompaniesListItem = ({ companyData, key }) => {
const CompaniesListItem = ({ companyData }) => {
const navigate = useNavigate();
return (
<div
@ -11,7 +11,7 @@ const CompaniesListItem = ({ companyData, key }) => {
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">
<p className="tb-companies-list-item-year">
{companyData.duration?.start
@ -32,7 +32,6 @@ const CompaniesListItem = ({ companyData, key }) => {
};
CompaniesListItem.propTypes = {
key: PropTypes.any,
companyData: PropTypes.object,
};

View File

@ -3,7 +3,7 @@ import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "../ContentRenderer";
import { useMediaQuery } from "react-responsive";
import { useSettingsContext } from "../../contexts/SettingsContext";
import { useContent } from "../../contexts/ContentContext";
import ShareButton from "../Buttons/ShareButton";
import VisitButton from "../Buttons/VisitButton";
import dayjs from "dayjs";
@ -11,7 +11,7 @@ import dayjs from "dayjs";
const { Content } = Layout;
const ExperiencePage = memo(({ companyData }) => {
const settings = useSettingsContext();
const { settings } = useContent();
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });

View File

@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
import LinkIcon from "../icons/LinkIcon";
import { useNavigate } from "react-router-dom";
import { useSettingsContext } from "../contexts/SettingsContext";
import { useContent } from "../contexts/ContentContext";
import AccountButton from "./Buttons/AccountButton";
const Footer = ({
pageData,
@ -15,7 +15,7 @@ const Footer = ({
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const navigate = useNavigate();
const settings = useSettingsContext();
const { settings } = useContent();
return (
<>
<div

View File

@ -3,7 +3,7 @@ import axios from "axios";
import CheckIcon from "../../icons/CheckIcon";
import Turnstile from "./Turnstile";
import { useNavigate } from "react-router-dom";
import { useSettingsContext } from "../../contexts/SettingsContext.jsx";
import { useContent } from "../../contexts/ContentContext";
import LoadingIcon from "../../icons/LoadingIcon.jsx";
import { useEffect } from "react";
const apiUrl = import.meta.env.VITE_API_URL;
@ -11,7 +11,7 @@ const turnstileKey = import.meta.env.VITE_TURNSTILE_KEY;
const ContactForm = () => {
const navigate = useNavigate();
const settings = useSettingsContext();
const { settings } = useContent();
const turnstileRef = useRef(null); // Ref for Turnstile
const [formData, setFormData] = useState({

View File

@ -1,11 +1,11 @@
import PropTypes from "prop-types";
import { useEffect } from "react";
import { useSettingsContext } from "../contexts/SettingsContext";
import { useContent } from "../contexts/ContentContext";
import LogoSvg from "../../assets/logo.svg?react";
import { useNavigate } from "react-router-dom";
const HeaderLogo = ({ large = false, visible = true }) => {
const settings = useSettingsContext();
const { settings } = useContent();
const navigate = useNavigate();
useEffect(() => {}, [large, settings.branding]);

View File

@ -16,10 +16,23 @@ const Image = ({ src, alt, className, loading = "lazy", ...props }) => {
const [showError, setShowError] = useState(false);
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
useEffect(() => {
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);
// Reset processed state when src changes
if (processedSrcRef.current !== src) {

View File

@ -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 { useMediaQuery } from "react-responsive";
import DigitalIcon from "../../icons/DigitalIcon";
@ -6,6 +6,7 @@ import PrintIcon from "../../icons/PrintIcon";
import DownloadFileIcon from "../../icons/DownloadFileIcon";
import WebsiteSelectorIcon from "../../icons/WebsiteSelectorIcon";
import CVVersionsSelectorPopup from "./CVVersionsSelectorPopup";
import { useContent } from "../../contexts/ContentContext";
const CVDownloadPopupMenu = ({
isVisible = false,
@ -13,6 +14,8 @@ const CVDownloadPopupMenu = ({
buttonRef = null,
cvs = [],
}) => {
const { contentObject } = useContent() || {};
const { files: contentFiles = [] } = contentObject || {};
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [position, setPosition] = useState({ top: 0, right: 0 });
@ -38,6 +41,112 @@ const CVDownloadPopupMenu = ({
[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 = () => {
setCvDigitalVersionsSelectorPopupVisible(
!cvDigitalVersionsSelectorPopupVisible
@ -98,13 +207,18 @@ const CVDownloadPopupMenu = ({
};
}, [shouldRender, isExiting, onClose, buttonRef]);
const handleDownload = (option) => {
// Handle download logic here
if (option.url) {
window.open(option.url, "_blank");
}
onClose();
};
const handleDownloadLatest = useCallback(
(type) => {
const targetCv = type === "digital" ? latestDigitalCv : latestPrintCv;
const downloadUrl = getCvDownloadUrl(targetCv);
if (downloadUrl) {
window.open(downloadUrl, "_blank");
onClose();
}
},
[getCvDownloadUrl, latestDigitalCv, latestPrintCv, onClose]
);
if (!shouldRender) return null;
@ -132,6 +246,7 @@ const CVDownloadPopupMenu = ({
<h3 className="tb-cv-title">
Digital
<button
ref={cvDigitalVersionsSelectorButtonRef}
className="tb-button tb-menu-popup-button tb-cv-versions-button"
onClick={handleDigitalVersionsSelectorButtonClick}
>
@ -143,7 +258,12 @@ const CVDownloadPopupMenu = ({
</p>
</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
<DownloadFileIcon />
</button>
@ -157,6 +277,7 @@ const CVDownloadPopupMenu = ({
<h3 className="tb-cv-title">
Printable
<button
ref={cvPrintVersionsSelectorButtonRef}
className="tb-button tb-menu-popup-button tb-cv-versions-button"
onClick={handlePrintVersionsSelectorButtonClick}
>
@ -168,7 +289,12 @@ const CVDownloadPopupMenu = ({
</p>
</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
<DownloadFileIcon />
</button>

View File

@ -4,7 +4,7 @@ import { Layout } from "antd";
import ContentRenderer from "./ContentRenderer";
import ScrollIcon from "../icons/ScrollIcon";
import { useMediaQuery } from "react-responsive";
import { useSettingsContext } from "../contexts/SettingsContext";
import { useContent } from "../contexts/ContentContext";
import ParticlesBackground from "./ParticlesBackground";
const { Content } = Layout;
@ -14,7 +14,7 @@ const Page = memo(({ pageData, particlesVisible }) => {
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
const contentRef = useRef(null);
const settings = useSettingsContext();
const { settings } = useContent();
const [delayedParticlesVisible, setDelayedParticlesVisible] = useState(false);
const theme = useMemo(
@ -81,6 +81,7 @@ const Page = memo(({ pageData, particlesVisible }) => {
scrollSnap={pageData?.scrollSnap}
align={pageData?.align}
justify={pageData?.justify}
scrollDistance={pageData?.scrollButtonDistance}
/>
{pageData?.showScroll == true && <ScrollIcon />}
</div>

View File

@ -9,6 +9,7 @@ import React, {
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import convert from "color-convert";
import { useDeveloper } from "../contexts/DeveloperContext";
const ParticlesComponent = React.memo(({ id, options, particlesLoaded }) => {
return (
@ -28,6 +29,7 @@ ParticlesComponent.propTypes = {
ParticlesComponent.displayName = "ParticlesComponent";
const ParticlesBackground = ({ id, theme, visible }) => {
const { developerMode } = useDeveloper();
// Memoize colors to prevent glitching from unnecessary recalculations
const colors = useMemo(() => {
// Generate three transitional hues between backgroundColor and textColor
@ -63,6 +65,11 @@ const ParticlesBackground = ({ id, theme, visible }) => {
const instanceId = useRef(
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
useEffect(() => {
@ -73,10 +80,81 @@ const ParticlesBackground = ({ id, theme, visible }) => {
});
}, []);
const particlesLoaded = useCallback((container) => {
console.log(container);
const updateFps = useCallback((timestamp) => {
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(
() => ({
background: {
@ -131,7 +209,7 @@ const ParticlesBackground = ({ id, theme, visible }) => {
density: {
enable: true,
},
value: 400,
value: 100,
},
opacity: {
value: 1,
@ -140,7 +218,7 @@ const ParticlesBackground = ({ id, theme, visible }) => {
type: "circle",
},
size: {
value: { min: 100, max: 300 },
value: { min: 10, max: 150 },
},
},
detectRetina: true,
@ -156,12 +234,39 @@ const ParticlesBackground = ({ id, theme, visible }) => {
? "tb-particles tb-particles-visible"
: "tb-particles tb-particles-hidden"
}
style={{
"--tb-backgroundColor": theme.backgroundColor,
"--tb-textColor": theme.textColor,
}}
>
<ParticlesComponent
id={instanceId.current}
options={options}
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>
)}
</>

View File

@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import Image from "../Image";
import ProjectStatus from "./ProjectStatus";
const ProjectCard = ({ projectData, key }) => {
const ProjectCard = ({ projectData }) => {
const navigate = useNavigate();
return (
<div
@ -12,7 +12,7 @@ const ProjectCard = ({ projectData, key }) => {
navigate(`/projects/${projectData.slug}`);
}}
>
<div className="tb-project-card" key={key}>
<div className="tb-project-card" >
<div className="tb-project-card-image-wrapper">
{projectData?.image ? (
<Image src={projectData?.image} alt={projectData?.name} />
@ -32,7 +32,6 @@ const ProjectCard = ({ projectData, key }) => {
};
ProjectCard.propTypes = {
key: PropTypes.any,
projectData: PropTypes.object,
};

View File

@ -1,9 +1,9 @@
import ProjectCard from "./ProjectCard";
import { useProjects } from "../../contexts/ProjectsContext";
import { useContent } from "../../contexts/ContentContext";
const ProjectCards = () => {
const { projects } = useProjects();
const { projects } = useContent();
return (
<div className="tb-project-cards-container">

View File

@ -6,7 +6,8 @@ import { useMediaQuery } from "react-responsive";
import ShareButton from "../Buttons/ShareButton";
import VisitButton from "../Buttons/VisitButton";
import ProjectStatus from "./ProjectStatus";
import { useSettingsContext } from "../../contexts/SettingsContext";
import { useContent } from "../../contexts/ContentContext";
import dayjs from "dayjs";
const { Content } = Layout;
@ -15,7 +16,7 @@ const ProjectPage = memo(({ projectData }) => {
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
const contentRef = useRef(null);
const settings = useSettingsContext();
const { settings } = useContent();
const theme = useMemo(
() =>
@ -67,7 +68,9 @@ const ProjectPage = memo(({ projectData }) => {
</div>
</div>
<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">
{projectData?.status && projectData?.status !== "complete" && (
<ProjectStatus status={projectData?.status} />

View File

@ -61,13 +61,17 @@ export default function SubPage({
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 (
<div
className={`${wrapperClass} ${animationClass} ${skipAnimationClass}`}
style={{
transformOrigin: capturedMousePositionRef.current
? `${capturedMousePositionRef.current.x}px ${capturedMousePositionRef.current.y}px`
: "center center",
transformOrigin,
}}
>
{/* Full height panel */}

View File

@ -1,5 +1,6 @@
import PropTypes from "prop-types";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { useContent } from "../contexts/ContentContext";
import { useVideoContext } from "../contexts/VideoContext";
import LoadingIcon from "../icons/LoadingIcon";
import FullScreenIcon from "../icons/FullScreenIcon";
@ -14,8 +15,15 @@ import Volume2Icon from "../icons/Volume2Icon";
import Volume3Icon from "../icons/Volume3Icon";
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 { contentObject } = useContent();
const containerRef = useRef(null);
const videoRef = useRef(null);
@ -50,6 +58,22 @@ const Video = ({ src, mirrorUrl = null, className, poster, ...props }) => {
const [isFullscreen, setIsFullscreen] = useState(false);
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(
() => (src ? getVideoUrl(src) : null),
[getVideoUrl, src]

View File

@ -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);

View File

@ -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);

View 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;
};

View 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);

View 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>
);
};

View File

@ -191,7 +191,14 @@ const imageCache = new ImageCache();
export const ImageProvider = ({ children }) => {
const [imageObjects, setImageObjects] = useState([]);
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) => {
// images: [{ blurHash, url, mirrorUrl? }]
// Only initialize image objects with metadata, don't load them yet
@ -216,7 +223,10 @@ export const ImageProvider = ({ children }) => {
const loadIndividualImage = useCallback(
async (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 (!imageObj) {
@ -235,15 +245,18 @@ export const ImageProvider = ({ children }) => {
return; // Already loading or loaded
}
// Check if image is already cached
if (imageCache.isCached(url)) {
// Use the imageObj.src (which might not have query params) for cache and state operations
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(
`[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) =>
prev.map((img) =>
img.src === url
removeQueryParams(img.src) === urlWithoutParams
? { ...img, blob: base64, loadingState: "loaded" }
: img
)
@ -252,19 +265,21 @@ export const ImageProvider = ({ children }) => {
}
console.log(`[ImageProvider] Starting to load individual image: ${url}`);
// Update loading state
// Update loading state using normalized comparison
setImageObjects((prev) =>
prev.map((img) =>
img.src === url ? { ...img, loadingState: "loading" } : img
removeQueryParams(img.src) === urlWithoutParams
? { ...img, loadingState: "loading" }
: img
)
);
try {
const mirrorUrl = imageObj.mirrorUrl;
const base64 = await imageCache.loadImage(url, mirrorUrl);
const base64 = await imageCache.loadImage(cacheKey, mirrorUrl);
setImageObjects((prev) =>
prev.map((img) =>
img.src === url
removeQueryParams(img.src) === urlWithoutParams
? { ...img, blob: base64, loadingState: "loaded" }
: img
)
@ -273,12 +288,14 @@ export const ImageProvider = ({ children }) => {
console.error(`[ImageProvider] Failed to load image: ${url}`, error);
setImageObjects((prev) =>
prev.map((img) =>
img.src === url ? { ...img, loadingState: "error" } : img
removeQueryParams(img.src) === urlWithoutParams
? { ...img, loadingState: "error" }
: img
)
);
}
},
[imageObjects]
[imageObjects, removeQueryParams]
);
useEffect(() => {

View File

@ -9,9 +9,9 @@ import Keycloak from "keycloak-js";
// Initialize Keycloak
const keycloak = new Keycloak({
url: "https://auth.tombutcher.work", // Your Keycloak server
realm: "master", // Your Keycloak realm
clientId: "2025-web-client", // Your Keycloak client ID
url: import.meta.env.VITE_KEYCLOAK_URL, // Your Keycloak server
realm: import.meta.env.VITE_KEYCLOAK_REALM, // Your Keycloak realm
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, // Your Keycloak client ID
});
const KeycloakContext = createContext(null);

View File

@ -63,7 +63,7 @@ export const MenuProvider = ({
!menuPopupRef.current.contains(event.target)
) {
// 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(
".tb-website-selector-popup"
);

View 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,
};

View File

@ -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);

View File

@ -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;
};

View 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
View File

@ -0,0 +1,7 @@
import ReloadIconSvg from "../../assets/reloadicon.svg?react";
const ReloadIcon = () => {
return <ReloadIconSvg />;
};
export default ReloadIcon;

View File

@ -46,6 +46,65 @@
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 {
opacity: 1;
}
@ -55,7 +114,8 @@
}
.tb-particles-background {
backdrop-filter: blur(40px);
backdrop-filter: blur(45px);
-webkit-backdrop-filter: blur(45px);
position: absolute;
z-index: 1;
top: 0;
@ -83,6 +143,7 @@
position: relative;
transform-origin: center center;
transform: scale(1);
width: max-content;
}
.tb-menu-popup-button,
@ -191,6 +252,109 @@
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 */
.tb-menu-popup {
position: absolute;
@ -314,6 +478,199 @@
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 */
.tb-account-menu-popup {
position: fixed;
@ -1557,22 +1914,20 @@
font-weight: 400;
position: absolute;
top: calc(50% - 17.5px);
font-size: 16px;
transform: translateY(-50%);
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(
in srgb,
var(--tb-backgroundColor) 50%,
transparent
);
font-size: 2rem;
cursor: pointer;
z-index: 10;
padding: 0;
width: 35px;
height: 35px;
width: 40px;
height: 40px;
line-height: 0.1px;
border-radius: 50%;
transition: background-color 0.3s ease, transform 0.2s ease, opacity 0.3s ease,
@ -1580,6 +1935,7 @@
border: none;
opacity: 1;
visibility: visible;
padding: 10px;
}
.tb-scroll-btn::before {
@ -1608,6 +1964,7 @@
.tb-scroll-btn > svg {
transition: fill 0.3s ease;
fill: var(--tb-textColor);
margin-bottom: 2px;
}
.tb-scroll-btn:hover {
@ -1615,16 +1972,28 @@
transform: translateY(-50%) scale(1.075);
}
.tb-scroll-btn:active {
transform: translateY(-50%) scale(0.975);
}
.tb-scroll-btn:hover > svg {
fill: var(--tb-backgroundColor);
}
.tb-scroll-btn.tb-left {
left: 25px;
left: var(--tb-header-footer-horizontal-padding, 25px);
}
.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 {
@ -2069,11 +2438,19 @@
line-height: 1.2;
}
.tb-project-content h1.tb-title:first-child {
margin-top: 0;
}
.tb-project-content-mobile h1.tb-title {
font-size: 28px;
line-height: 1.2;
}
.tb-project-content-mobile h1.tb-title:first-child {
margin-top: 0;
}
.tb-project-content h2.tb-title {
font-size: 32px;
font-weight: 600;
@ -2081,11 +2458,19 @@
line-height: 1.2;
}
.tb-project-content h2.tb-title:first-child {
margin-top: 0;
}
.tb-project-content-mobile h2.tb-title {
font-size: 26px;
line-height: 1.2;
}
.tb-project-content-mobile h2.tb-title:first-child {
margin-top: 0;
}
.tb-project-content p.tb-paragraph {
font-weight: 500;
margin: 0;

View File

@ -47,6 +47,7 @@
align-items: center;
justify-content: center;
padding: 20px;
background-color: #151515;
}
/* Page component styles */

View File

@ -8,7 +8,7 @@ export default defineConfig({
plugins: [react(), svgo(), svgr()],
server: {
host: "0.0.0.0",
allowedHosts: ["thehideout.tombutcher.work"],
allowedHosts: ["dev.tombutcher.work"],
},
base: "/",
});

917
yarn.lock

File diff suppressed because it is too large Load Diff