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_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
|
||||
@ -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
|
||||
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"?>
|
||||
<!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
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"?>
|
||||
<!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 |
@ -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>
|
||||
|
||||
217
src/App.jsx
217
src/App.jsx
@ -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,6 +615,18 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
|
||||
|
||||
return (
|
||||
<ThemeProvider currentTheme={currentTheme}>
|
||||
<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}
|
||||
@ -717,109 +724,74 @@ const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
|
||||
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 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;
|
||||
|
||||
if (isError) {
|
||||
console.error("Error fetching content:", error);
|
||||
const mergeReloadedContent = useCallback(
|
||||
(reloadedContent) => {
|
||||
if (!reloadedContent || typeof reloadedContent !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className="tb-error-container">
|
||||
<Alert
|
||||
message="Error Loading Pages"
|
||||
description={errorMessage}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
</div>
|
||||
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 (
|
||||
<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}
|
||||
/>
|
||||
<MenuProvider pages={pages} currentPageSlug="" cvs={cvs}>
|
||||
<AppContent />
|
||||
<LoadingModal visible={isLoading} />
|
||||
</MenuProvider>
|
||||
</AccountProvider>
|
||||
@ -827,12 +799,39 @@ const App = () => {
|
||||
</VideoProvider>
|
||||
</ImageProvider>
|
||||
</ActionProvider>
|
||||
</CompaniesProvider>
|
||||
</ProjectsProvider>
|
||||
</BlogsProvider>
|
||||
</SettingsProvider>
|
||||
</KeycloakProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const AppContentRoot = () => {
|
||||
const { isError, error, errorMessage } = useContent();
|
||||
|
||||
if (isError) {
|
||||
console.error("Error fetching content:", error);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<AppError
|
||||
message={errorMessage}
|
||||
error={error}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AppProviders />;
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<KeycloakProvider>
|
||||
<MessageProvider>
|
||||
<DeveloperProvider>
|
||||
<ContentProvider>
|
||||
<AppContentRoot />
|
||||
</ContentProvider>
|
||||
</DeveloperProvider>
|
||||
</MessageProvider>
|
||||
</KeycloakProvider>
|
||||
);
|
||||
|
||||
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 { 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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 }) => {
|
||||
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(() => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@ -47,6 +47,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background-color: #151515;
|
||||
}
|
||||
|
||||
/* Page component styles */
|
||||
|
||||
@ -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: "/",
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user