commit 302416c83ee120a98ba2ad5c902cbdceea48a667 Author: Tom Butcher Date: Sun Nov 9 18:00:07 2025 +0000 Initial Commit diff --git a/.env b/.env new file mode 100644 index 0000000..1c9e060 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +VITE_API_URL=https://api.thehideoutltd.com +VITE_TURNSTILE_KEY=0x4AAAAAAB2uebWFPXaK8spB diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..419f505 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +VITE_API_URL=https://thehideout.tombutcher.work/api +VITE_TURNSTILE_KEY=0x4AAAAAAB2dBq6i8m4kYzDm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6380609 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +### react ### +.DS_* +*.log +logs +**/*.backup.* +**/*.back.* + +node_modules +bower_components + +*.sublime* + +*.wranger + +dist + +psd +thumb \ No newline at end of file diff --git a/assets/airbnblogo.svg b/assets/airbnblogo.svg new file mode 100644 index 0000000..04ec50d --- /dev/null +++ b/assets/airbnblogo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/atsymbolicon.svg b/assets/atsymbolicon.svg new file mode 100644 index 0000000..2dc0540 --- /dev/null +++ b/assets/atsymbolicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/bookingcomlogo.svg b/assets/bookingcomlogo.svg new file mode 100644 index 0000000..87c1e0c --- /dev/null +++ b/assets/bookingcomlogo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/checkicon.svg b/assets/checkicon.svg new file mode 100644 index 0000000..2c94976 --- /dev/null +++ b/assets/checkicon.svg @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/assets/closeicon.svg b/assets/closeicon.svg new file mode 100644 index 0000000..865ec30 --- /dev/null +++ b/assets/closeicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/digitalicon.svg b/assets/digitalicon.svg new file mode 100644 index 0000000..cf019ec --- /dev/null +++ b/assets/digitalicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/downloadfileicon.svg b/assets/downloadfileicon.svg new file mode 100644 index 0000000..77bd72d --- /dev/null +++ b/assets/downloadfileicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/dropdownicon.svg b/assets/dropdownicon.svg new file mode 100644 index 0000000..daaa9cf --- /dev/null +++ b/assets/dropdownicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/exitfullscreenicon.svg b/assets/exitfullscreenicon.svg new file mode 100644 index 0000000..b54d6c8 --- /dev/null +++ b/assets/exitfullscreenicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/fullscreen.svg b/assets/fullscreen.svg new file mode 100644 index 0000000..b3aebb4 --- /dev/null +++ b/assets/fullscreen.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/fullscreenicon.svg b/assets/fullscreenicon.svg new file mode 100644 index 0000000..d3c831d --- /dev/null +++ b/assets/fullscreenicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/iconloading.svg b/assets/iconloading.svg new file mode 100644 index 0000000..3d142aa --- /dev/null +++ b/assets/iconloading.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/lefticon.svg b/assets/lefticon.svg new file mode 100644 index 0000000..697f1cb --- /dev/null +++ b/assets/lefticon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/linkicon.svg b/assets/linkicon.svg new file mode 100644 index 0000000..9191f4f --- /dev/null +++ b/assets/linkicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/loadingicon.svg b/assets/loadingicon.svg new file mode 100644 index 0000000..8d18014 --- /dev/null +++ b/assets/loadingicon.svg @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/assets/loggedouticon.svg b/assets/loggedouticon.svg new file mode 100644 index 0000000..9d9e237 --- /dev/null +++ b/assets/loggedouticon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..21e4fc9 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/menuicon.svg b/assets/menuicon.svg new file mode 100644 index 0000000..6a4653f --- /dev/null +++ b/assets/menuicon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + diff --git a/assets/mloading.svg b/assets/mloading.svg new file mode 100644 index 0000000..6bb5416 --- /dev/null +++ b/assets/mloading.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/muteicon.svg b/assets/muteicon.svg new file mode 100644 index 0000000..13f25fa --- /dev/null +++ b/assets/muteicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/oloading.svg b/assets/oloading.svg new file mode 100644 index 0000000..3e013cf --- /dev/null +++ b/assets/oloading.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/padlockicon.svg b/assets/padlockicon.svg new file mode 100644 index 0000000..27cb3b8 --- /dev/null +++ b/assets/padlockicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/pauseicon.svg b/assets/pauseicon.svg new file mode 100644 index 0000000..96f8c72 --- /dev/null +++ b/assets/pauseicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/personicon.svg b/assets/personicon.svg new file mode 100644 index 0000000..86e0a69 --- /dev/null +++ b/assets/personicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/playicon.svg b/assets/playicon.svg new file mode 100644 index 0000000..2719296 --- /dev/null +++ b/assets/playicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/printicon.svg b/assets/printicon.svg new file mode 100644 index 0000000..790be7b --- /dev/null +++ b/assets/printicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/righticon.svg b/assets/righticon.svg new file mode 100644 index 0000000..4121ecc --- /dev/null +++ b/assets/righticon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/scrollicon.svg b/assets/scrollicon.svg new file mode 100644 index 0000000..7caafe1 --- /dev/null +++ b/assets/scrollicon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/assets/shareicon.svg b/assets/shareicon.svg new file mode 100644 index 0000000..99cda22 --- /dev/null +++ b/assets/shareicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/tloading.svg b/assets/tloading.svg new file mode 100644 index 0000000..ba9f01b --- /dev/null +++ b/assets/tloading.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/usericon.svg b/assets/usericon.svg new file mode 100644 index 0000000..25e1ae9 --- /dev/null +++ b/assets/usericon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/visiticon.svg b/assets/visiticon.svg new file mode 100644 index 0000000..f508561 --- /dev/null +++ b/assets/visiticon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/volume1icon.svg b/assets/volume1icon.svg new file mode 100644 index 0000000..4394216 --- /dev/null +++ b/assets/volume1icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/volume2icon.svg b/assets/volume2icon.svg new file mode 100644 index 0000000..b448c8c --- /dev/null +++ b/assets/volume2icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/volume3icon.svg b/assets/volume3icon.svg new file mode 100644 index 0000000..bff1a5c --- /dev/null +++ b/assets/volume3icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/website2024.svg b/assets/website2024.svg new file mode 100644 index 0000000..9babf03 --- /dev/null +++ b/assets/website2024.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/website2025.svg b/assets/website2025.svg new file mode 100644 index 0000000..58220f8 --- /dev/null +++ b/assets/website2025.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/website2026.svg b/assets/website2026.svg new file mode 100644 index 0000000..a1a236b --- /dev/null +++ b/assets/website2026.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/websiteselectoricon.svg b/assets/websiteselectoricon.svg new file mode 100644 index 0000000..6693345 --- /dev/null +++ b/assets/websiteselectoricon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..238d2e4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import globals from 'globals' +import react from 'eslint-plugin-react' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/index.html b/index.html new file mode 100644 index 0000000..5a10e78 --- /dev/null +++ b/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + Tom Butcher + + + +
+ + + diff --git a/main.jsx b/main.jsx new file mode 100644 index 0000000..637e43b --- /dev/null +++ b/main.jsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; + +createRoot(document.getElementById("root")).render( + + + , +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d4bb019 --- /dev/null +++ b/package.json @@ -0,0 +1,80 @@ +{ + "name": "thehideout-ui", + "version": "1.0.0", + "type": "module", + "private": true, + "homepage": "https://thehideout.uk", + "dependencies": { + "@ant-design/icons": "^6.0.0", + "@marsidev/react-turnstile": "^1.3.1", + "@tanstack/react-query": "^5.90.7", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.9.1", + "antd": "^5.24.6", + "axios": "^1.6.0", + "blurhash": "^2.0.5", + "color-convert": "^3.1.2", + "dayjs": "^1.11.19", + "hamburger-react": "^2.5.2", + "keycloak-js": "^26.1.4", + "overlayscrollbars": "^2.12.0", + "overlayscrollbars-react": "^0.5.6", + "rc-slider": "^11.1.9", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-ga4": "^2.1.0", + "react-icons": "^5.5.0", + "react-particles": "^2.12.2", + "react-responsive": "^10.0.1", + "react-router-dom": "^7.5.0", + "react-scripts": "^5.0.1", + "react-scroll": "^1.9.3", + "react-turnstile": "^1.1.4", + "sass": "^1.86.3", + "simplebar": "^6.3.2", + "simplebar-react": "^3.3.2", + "slider": "^1.0.4", + "vite": "^6.2.5", + "vite-plugin-svgo": "^2.0.0", + "vite-plugin-svgr": "^4.5.0", + "web-vitals": "^4.2.4" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "npm run build && vite preview", + "deploy": "npm run build && wrangler pages deploy ./dist --skip-caching" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.1", + "@vitejs/plugin-react": "^4.3.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0" + } +} diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg new file mode 100644 index 0000000..0a80e63 --- /dev/null +++ b/public/assets/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/assets/favicon192.png b/public/assets/favicon192.png new file mode 100644 index 0000000..a2e2e3b Binary files /dev/null and b/public/assets/favicon192.png differ diff --git a/public/assets/grain-texture.png b/public/assets/grain-texture.png new file mode 100644 index 0000000..fbd0cec Binary files /dev/null and b/public/assets/grain-texture.png differ diff --git a/public/assets/grain.svg b/public/assets/grain.svg new file mode 100644 index 0000000..00a34e2 --- /dev/null +++ b/public/assets/grain.svg @@ -0,0 +1,6 @@ +\ + \ + \ + \ + \ + \ No newline at end of file diff --git a/public/data/pages.json b/public/data/pages.json new file mode 100644 index 0000000..12c274b --- /dev/null +++ b/public/data/pages.json @@ -0,0 +1,131 @@ +{ + "pages": [ + { + "id": 1, + "slug": "home", + "name": "Home", + "content": [ + { + "type": "title1", + "text": "turning properties into unforgettable stays." + }, + { + "type": "divider" + }, + { + "type": "paragraph", + "text": "we take the stress out of hosting and property management. from professional guest communication and seamless check-ins to maintenance and cleaning, we handle every detail so your property shines and your guests feel at home. enjoy peace of mind while maximising your property's potential. we make hosting effortless and rewarding." + } + ], + "theme": "dark", + "image1": "https://images.pexels.com/photos/20666872/pexels-photo-20666872.jpeg", + "image2": null, + "showScroll": true + }, + { + "id": 2, + "slug": "about", + "name": "About Us", + "content": [ + { + "type": "title2", + "text": "Who we are" + }, + { + "type": "paragraph", + "text": "We are a creative team dedicated to building amazing web experiences. Our passion for design and development drives everything we do." + }, + { + "type": "divider" + }, + { + "type": "title3", + "text": "What We Do" + }, + { + "type": "paragraph", + "text": "From concept to completion, we craft digital solutions that not only meet your needs but exceed your expectations." + } + ], + "theme": "light", + "image1": "https://images.pexels.com/photos/5825527/pexels-photo-5825527.jpeg", + "image2": "https://images.pexels.com/photos/8580720/pexels-photo-8580720.jpeg", + "showScroll": false + }, + { + "id": 3, + "slug": "services", + "name": "Our Services", + "content": [ + { + "type": "title1", + "text": "What We Offer" + }, + { + "type": "title2", + "text": "Web Development" + }, + { + "type": "paragraph", + "text": "Custom websites and web applications built with modern technologies and best practices." + }, + { + "type": "title2", + "text": "Mobile Apps" + }, + { + "type": "paragraph", + "text": "Native and cross-platform mobile applications for iOS and Android devices." + }, + { + "type": "divider" + }, + { + "type": "title3", + "text": "Ready to Get Started?" + }, + { + "type": "title4", + "text": "Let's build something amazing together." + } + ], + "theme": "dark", + "image1": "https://images.pexels.com/photos/5490353/pexels-photo-5490353.jpeg", + "image2": null, + "showScroll": false + }, + { + "id": 4, + "slug": "contact", + "name": "Contact", + "theme": "light", + "image1": "https://images.pexels.com/photos/32168965/pexels-photo-32168965.jpeg", + "image2": "https://images.pexels.com/photos/20927256/pexels-photo-20927256.jpeg", + "showScroll": false + }, + { + "id": 5, + "slug": "thank-you", + "title": "Thank You", + "content": "Thank you for visiting The Hideout. We hope you enjoyed the smooth scrolling experience and look forward to working with you soon!", + "theme": "dark", + "image1": "https://images.pexels.com/photos/14750394/pexels-photo-14750394.jpeg", + "image2": null, + "showScroll": false + } + ], + "themes": [ + { + "name": "dark", + "backgroundColor": "#830B0D", + "textColor": "#ffffff", + "logo": "/assets/logo-light.svg" + }, + { + "name": "light", + "backgroundColor": "#ffffff", + "textColor": "#5E0809", + "logo": "/assets/logo-dark.svg" + } + ] +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..8dccbff --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,18 @@ +{ + "short_name": "Tom Butcher", + "name": "TOM BUTCHER", + "icons": [ + { + "src": "https://cdn.tombutcher.work/favicon/favicon192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "https://cdn.tombutcher.work/favicon/favicon512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": "/", + "display": "standalone" +} diff --git a/public/silent-check-sso.html b/public/silent-check-sso.html new file mode 100644 index 0000000..ad5f88b --- /dev/null +++ b/public/silent-check-sso.html @@ -0,0 +1,13 @@ + + + + + + Silent Check SSO + + + + + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..bc7cccd --- /dev/null +++ b/readme.md @@ -0,0 +1,119 @@ + +# 2026 tombutcher.work UI + +This is the front-end web application for **tombutcher.work** ([tombutcher.work](https://tombutcher.work)), built with **React.js** and hosted on **Cloudflare Pages**. The website showcases my personal work. + +## Tech Stack + +- **React 19**: Modern JavaScript library for building user interfaces +- **Vite**: Fast build tool and development server +- **Cloudflare Pages**: Static site hosting with global CDN +- **Cloudflare Turnstile**: Bot protection for form submissions +- **Keycloak**: Authentication and authorization + +## Features + +- Dynamic content delivery: pages, blogs, projects, companies, media, and CV assets load at runtime via React Query from the configured `VITE_API_URL`, so updates ship without redeploys. +- Immersive navigation: smooth-scrolling landing sections, animated sub-pages, and deep-link routing keep transitions fast while preserving stateful context. +- Adaptive theming: global theme context syncs typography and color tokens across devices, reacting to breakpoint changes for desktop, tablet, and mobile layouts. +- Account-aware UI: Keycloak SSO bootstraps user sessions for gated experiences while keeping the rest of the site public-by-default. +- Engagement tooling: Cloudflare Turnstile-secured contact form and multi-version CV download menu (digital vs. print) streamline outreach and asset sharing. + +## Prerequisites + +Before getting started, make sure you have the following installed: + +- [Node.js](https://nodejs.org/) (v18 or higher recommended) +- [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) (for deployment) +- [Git](https://git-scm.com/) + +## Getting Started + +### 1. Clone the Repository + +Clone this repository to your local machine: + +```bash +git clone https://git.tombutcher.work/tom/thehideout-ui.git +cd thehideout-ui +``` + +### 2. Install Dependencies + +Install the necessary dependencies: + +```bash +npm install +``` + +### 3. Configure Environment + +Ensure any necessary environment variables are set up for: +- Cloudflare Turnstile site keys +- Keycloak configuration +- API endpoints + +### 4. Running Locally + +To start the development server locally: + +```bash +npm run dev +``` + +The website will be available at `http://localhost:5173` (Vite default port). + +### 5. Building for Production + +To create a production build: + +```bash +npm run build +``` + +The optimized build will be output to the `./dist` directory. + +### 6. Preview Production Build + +To preview the production build locally: + +```bash +npm run preview +``` + +## Deployment + +### Deploy to Cloudflare Pages + +Deploy using Wrangler CLI: + +```bash +npm run deploy +``` + +This will build the project and deploy it to Cloudflare Pages. + +## Project Structure + +``` +thehideout-ui/ +├── src/ +│ ├── components/ # Reusable React components +│ ├── contexts/ # React context providers +│ ├── icons/ # Custom icon components +│ ├── utils/ # Utility functions +│ ├── App.jsx # Main application component +│ └── main.jsx # Application entry point +├── public/ # Static assets +├── dist/ # Production build output +└── assets/ # Additional assets +``` + +## Resources + +- [React](https://react.dev/) +- [Vite](https://vite.dev/) +- [Ant Design](https://ant.design/) +- [Cloudflare Pages](https://developers.cloudflare.com/pages/) +- [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..a970633 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,838 @@ +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"; +import { ActionProvider } from "./contexts/ActionContext"; +import SubPage from "./components/SubPage"; +import BlogPage from "./components/Blogs/BlogPage"; +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 Header from "./components/Header"; +import Footer from "./components/Footer"; +import { AccountProvider, useAccount } from "./contexts/AccountContext"; +const apiUrl = import.meta.env.VITE_API_URL; + +// Component that handles image loading after API data is fetched +const AppContent = ({ pages, blogs, images, projects, companies, loading }) => { + const { loadImages } = useImageContext(); + const [loadedOnce, setLoadedOnce] = useState(false); + const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" }); + const [currentSubPage, setCurrentSubPage] = useState({ + pageType: "subPage", + name: "", + slug: "", + theme: "", + notionId: "", + }); + const [nextPage, setNextPage] = useState(null); + const [currentTheme, setCurrentTheme] = useState(); + const [currentBlog, setCurrentBlog] = useState(null); + const [currentProject, setCurrentProject] = useState(null); + const [currentCompany, setCurrentCompany] = useState(null); + const [currentPageIdx, setCurrentPageIdx] = useState(0); + const [nextPageIdx, setNextPageIdx] = useState(0); + const [blogVisible, setBlogVisible] = useState(false); + const [projectVisible, setProjectVisible] = useState(false); + const [experienceVisible, setExperienceVisible] = useState(false); + const [subPageVisible, setSubPageVisible] = useState(false); + const [menuToggled, setMenuToggled] = useState(false); + const [accountToggled, setAccountToggled] = useState(false); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); + const [headerLarge, setHeaderLarge] = useState(false); + const [pageLoaded, setPageLoaded] = useState(false); + const locationRef = useRef(); + const currentPageTypeRef = useRef(currentPage.pageType); + const previousPageRef = useRef(null); + const currentPageRef = useRef(currentPage); + const landingPages = useMemo( + () => pages.filter((page) => page.pageType == "landingPage"), + [pages] + ); + const { menuVisible, setMenuVisible, setActiveSlug } = useMenu(); + const { accountVisible, setAccountVisible } = useAccount(); + + const navigate = useNavigate(); + const location = useLocation(); + const settings = useSettingsContext(); + const isMobile = useMediaQuery({ maxWidth: 800 }); + + const [skipAnimation, setSkipAnimation] = useState(true); + + // Memoize theme lookups to prevent creating new objects on every render + const themeMap = useMemo(() => { + const map = new Map(); + settings?.themes?.forEach((theme) => { + map.set(theme.name, theme); + }); + return map; + }, [settings?.themes]); + + useEffect(() => { + if (loading == false) { + setTimeout(() => { + setSkipAnimation(false); + console.log("skipAnimation", loading); + }, 500); // Reduced from 2000ms to 500ms + } + }, [loading]); + + // Check for iOS webclip and set body className + useEffect(() => { + if (window.navigator.standalone === true) { + document.body.classList.add("tb-ios-webclip"); + } + }, []); + + // Initialize image objects (metadata only, no loading) when they become available + useEffect(() => { + if (!loadedOnce && images && images.length > 0) { + loadImages(images); + setLoadedOnce(true); + } + }, [images, loadImages, loadedOnce]); + + // Handle page load and header animation + useEffect(() => { + if (!pageLoaded && pages.length > 0) { + setPageLoaded(true); + } + if (nextPageIdx == 0 && isMobile == true) { + setHeaderLarge(false); + const timer1 = setTimeout(() => { + setHeaderLarge(true); + const timer2 = setTimeout(() => { + setHeaderLarge(false); + }, 3000); // 3 second + + return () => clearTimeout(timer2); + }, 1000); // 1s wait + + return () => clearTimeout(timer1); + } + if (nextPageIdx == 0 && isMobile == false) { + setHeaderLarge(true); + return; + } + if (nextPageIdx != 0) { + setHeaderLarge(false); + return; + } + }, [pageLoaded, pages.length, nextPageIdx, isMobile]); + + // Track mouse position for transform origin + useEffect(() => { + const handleMouseMove = (event) => { + setMousePosition({ x: event.clientX, y: event.clientY }); + }; + + window.addEventListener("mousemove", handleMouseMove); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + }; + }, []); + + const setPageTitle = useCallback((name) => { + document.title = name ? `${name} - Tom Butcher` : "Tom Butcher"; + }, []); + + const handleBlogsRoute = useCallback(() => { + const blogSlug = location.pathname.split("/blogs/")[1]; + if (blogSlug != "") { + const blog = blogs.find((p) => p.slug === blogSlug); + if (blog) { + // Set previousPage when navigating to a blog + if (currentPageRef.current.pageType !== "subPage") { + previousPageRef.current = { ...currentPageRef.current }; + } + + setHeaderLarge(false); + if (currentPageTypeRef.current == "blogPage") { + setBlogVisible(false); + setTimeout(() => { + setCurrentBlog(blog); + setBlogVisible(true); + }, 100); // Reduced from 500ms to 100ms + } else { + setBlogVisible(false); + setCurrentBlog(blog); + // Small delay to ensure DOM updates before making visible + requestAnimationFrame(() => { + setBlogVisible(true); + }); + } + currentPageTypeRef.current = "blogPage"; + setPageTitle(blog.name); + } else { + navigate(settings.redirects["404"]); + setCurrentBlog(null); // blog not found + setBlogVisible(false); + } + return; // exit early since we're on a blog page + } + }, [ + blogs, + location.pathname, + settings.redirects, + navigate, + currentPageRef, + previousPageRef, + setCurrentBlog, + setBlogVisible, + setPageTitle, + ]); + + const handleCompaniesRoute = useCallback(() => { + const companySlug = location.pathname.split("/experience/")[1]; + if (companySlug != "") { + const company = companies.find((c) => c.slug === companySlug); + if (company) { + // Set previousPage when navigating to a company + if (currentPageRef.current.pageType !== "subPage") { + previousPageRef.current = { ...currentPageRef.current }; + } + + setHeaderLarge(false); + if (currentPageTypeRef.current == "companyPage") { + setExperienceVisible(false); + setTimeout(() => { + setCurrentCompany(company); + setExperienceVisible(true); + }, 100); // Reduced from 500ms to 100ms + } else { + setExperienceVisible(false); + setCurrentCompany(company); + // Small delay to ensure DOM updates before making visible + requestAnimationFrame(() => { + setExperienceVisible(true); + }); + } + currentPageTypeRef.current = "companyPage"; + setPageTitle(company.name); + } else { + navigate(settings.redirects["404"]); + setCurrentCompany(null); // company not found + setExperienceVisible(false); + } + return; // exit early since we're on a company page + } + }, [ + companies, + location.pathname, + settings.redirects, + navigate, + currentPageRef, + previousPageRef, + setCurrentCompany, + setExperienceVisible, + setPageTitle, + ]); + + const handleProjectsRoute = useCallback(() => { + const projectSlug = location.pathname.split("/projects/")[1]; + if (projectSlug != "") { + const project = projects.find((p) => p.slug === projectSlug); + if (project) { + // Set previousPage when navigating to a project + if (currentPageRef.current.pageType !== "subPage") { + previousPageRef.current = { ...currentPageRef.current }; + } + + setHeaderLarge(false); + if (currentPageTypeRef.current == "projectPage") { + setProjectVisible(false); + setTimeout(() => { + setCurrentProject(project); + setProjectVisible(true); + }, 100); // Reduced from 500ms to 100ms + } else { + setProjectVisible(false); + setCurrentProject(project); + // Small delay to ensure DOM updates before making visible + requestAnimationFrame(() => { + setProjectVisible(true); + }); + } + currentPageTypeRef.current = "projectPage"; + setPageTitle(project.name); + } else { + navigate(settings.redirects["404"]); + setCurrentProject(null); // project not found + setProjectVisible(false); + } + return; // exit early since we're on a project page + } + }, [ + projects, + location.pathname, + settings.redirects, + navigate, + currentPageRef, + previousPageRef, + setCurrentProject, + setProjectVisible, + setPageTitle, + ]); + + // Handle direct URL navigation + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + locationRef.current = location.pathname; + if (Object.keys(settings).length == 0) { + return; + } + console.log("location.pathname", location.pathname); + // Check if the URL matches /blogs/:slug + if (location.pathname.startsWith("/blogs/")) { + handleBlogsRoute(); + return; + } + // Check if the URL matches /projects/:slug + if (location.pathname.startsWith("/projects/")) { + handleProjectsRoute(); + return; + } + // Check if the URL matches /companies/:slug + if (location.pathname.startsWith("/experience/")) { + handleCompaniesRoute(); + return; + } + setBlogVisible(false); + setProjectVisible(false); + setExperienceVisible(false); + if (pages.length > 0 && location.pathname !== "/") { + const slug = location.pathname.slice(1); // Remove leading slash + const page = pages.find((p) => p.slug === slug); + + if (page) { + // Set previousPage when navigating to a subpage + if ( + page.pageType === "subPage" && + currentPageRef.current.pageType !== "subPage" + ) { + previousPageRef.current = { ...currentPageRef.current }; + } + setNextPage(page); + setCurrentPage(page); + + setNextPageIdx(pages.indexOf(page)); + setCurrentPageIdx(pages.indexOf(page)); + setPageTitle(page.name); + + if (page.pageType === "landingPage") { + // Ensure DOM has rendered before attempting to scroll + setSubPageVisible(false); + requestAnimationFrame(() => { + scroller.scrollTo(slug, { + duration: 0, + delay: 0, + containerId: "app-container", + }); + }); + } else { + if (currentPage.pageType == page.pageType) { + setSubPageVisible(false); + setCurrentSubPage(page); + // Small delay to ensure DOM updates before making visible + // Use double requestAnimationFrame for Edge compatibility + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setSubPageVisible(true); + }); + }); + } else { + setSubPageVisible(false); + setCurrentSubPage(page); + // Small delay to ensure DOM updates before making visible + // Use double requestAnimationFrame for Edge compatibility + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setSubPageVisible(true); + }); + }); + } + } + } else { + navigate(settings.redirects["404"]); + } + } else if ( + pages.length > 0 && + location.pathname === "/" && + settings?.redirects?.index != null + ) { + console.log("settings.redirects", settings.redirects); + const indexPage = pages.find((p) => p.slug === settings.redirects.index); + // Default to index page + setNextPage(indexPage); + setCurrentPage(indexPage); + + setNextPageIdx(0); + setCurrentPageIdx(0); + setPageTitle(indexPage.name); + navigate(`/${indexPage.slug}`, { replace: true }); + } + }, [ + location.pathname, + pages, + settings?.redirects?.index, + navigate, + settings, + blogs, + projects, + companies, + currentPage.pageType, + currentBlog, + currentProject, + currentCompany, + handleBlogsRoute, + handleProjectsRoute, + handleCompaniesRoute, + setPageTitle, + ]); + + // Set up scroll spy to update URL when scrolling + useEffect(() => { + if (currentPage.pageType == "subPage") { + return; + } + if (pages.length > 0) { + let scrollTimeout; + + const handleScrollSpy = () => { + const container = document.getElementById("app-container"); + if (!container) return; + + const scrollTop = container.scrollTop; + const containerHeight = container.clientHeight; + const viewportCenter = scrollTop + containerHeight / 2; + + // Find which page is currently in the center of the viewport + let currentPageIndex = 0; + let minDistance = Infinity; + + pages.forEach((page, index) => { + const element = document.querySelector(`[data-name="${page.slug}"]`); + if (element) { + const elementTop = element.offsetTop; + //const elementBottom = elementTop + element.offsetHeight; + const elementCenter = elementTop + element.offsetHeight / 2; + + const distance = Math.abs(viewportCenter - elementCenter); + + if (distance < minDistance) { + minDistance = distance; + currentPageIndex = index; + setMenuVisible(false); + setAccountVisible(false); + setNextPage(page); + setNextPageIdx(index); + if ( + blogVisible == false && + projectVisible == false && + experienceVisible == false + ) { + setCurrentTheme(themeMap.get(page.theme)); + } + } + } + }); + + // Debounce both current page state and URL updates to reduce frequency + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + if (pages[currentPageIndex] && pages[currentPageIndex].slug) { + // Update current page state for Images component + if (currentPageIdx !== currentPageIndex) { + setCurrentPage(pages[currentPageIndex]); + setNextPage(pages[currentPageIndex]); + setNextPageIdx(currentPageIndex); + setCurrentPageIdx(currentPageIndex); + setPageTitle(pages[currentPageIndex].name); + + const newPath = `/${pages[currentPageIndex].slug}`; + if (locationRef.current !== newPath) { + navigate(newPath, { replace: true }); + } + } + } + }, 100); // Debounce for 100ms + }; + + const container = document.getElementById("app-container"); + if (container) { + container.addEventListener("scroll", handleScrollSpy, { + passive: true, + }); + return () => { + container.removeEventListener("scroll", handleScrollSpy); + clearTimeout(scrollTimeout); + }; + } + } + }, [ + pages, + navigate, + currentPageIdx, + currentPage.pageType, + themeMap, + setMenuVisible, + setNextPage, + setNextPageIdx, + setCurrentTheme, + setBlogVisible, + setProjectVisible, + setExperienceVisible, + setPageTitle, + blogVisible, + projectVisible, + experienceVisible, + ]); + + // Update currentPageRef whenever currentPage changes + useEffect(() => { + currentPageRef.current = currentPage; + currentPageTypeRef.current = currentPage.pageType; + }, [currentPage]); + + // Update menu active slug when current page changes + useEffect(() => { + if (setActiveSlug && currentPage?.slug) { + setActiveSlug(currentPage.slug); + } + }, [currentPage?.slug, setActiveSlug]); + + // Set body background color to match current page's theme + useEffect(() => { + const theme = + blogVisible == true + ? settings?.globalThemes?.blog + : projectVisible == true + ? themeMap.get(currentProject?.theme) || settings?.globalThemes?.project + : experienceVisible == true + ? themeMap.get(currentCompany?.theme) || + settings?.globalThemes?.experience + : themeMap.get(currentPage?.theme) || settings?.globalThemes?.page; + setCurrentTheme(theme); + }, [ + nextPage, + themeMap, + blogVisible, + projectVisible, + experienceVisible, + settings?.globalThemes?.blog, + settings?.globalThemes?.project, + settings?.globalThemes?.experience, + settings?.globalThemes?.page, + currentPage?.theme, + currentProject?.theme, + currentCompany?.theme, + ]); + + useEffect(() => { + setMenuToggled( + subPageVisible || + menuVisible || + blogVisible || + projectVisible || + experienceVisible + ); + if (subPageVisible == true) { + setMenuVisible(false); + } + if (blogVisible == true) { + setMenuVisible(false); + } + if (projectVisible == true) { + setMenuVisible(false); + } + if (experienceVisible == true) { + setMenuVisible(false); + } + }, [ + subPageVisible, + menuVisible, + setMenuVisible, + blogVisible, + projectVisible, + experienceVisible, + ]); + + useEffect(() => { + if (accountVisible == false) { + setAccountToggled(false); + } + }, [accountVisible, setAccountToggled]); + // Handle subpage close with smart navigation + const handleSubPageClose = () => { + currentPageTypeRef.current = "landingPage"; + // Check if there's a previous page stored in ref + if (previousPageRef.current && previousPageRef.current.slug) { + // Navigate to the previous page's slug + navigate(`/${previousPageRef.current.slug}`); + } else { + // No previous page, redirect to home + navigate("/"); + } + }; + + const handleMenuToggle = (toggled) => { + setMenuToggled(toggled); + if ( + toggled == false && + (subPageVisible == true || + blogVisible == true || + projectVisible == true || + experienceVisible == true) + ) { + handleSubPageClose(); + return; + } + setMenuVisible(toggled); + }; + + const handleAccountToggle = (toggled) => { + setAccountToggled(toggled); + setAccountVisible(toggled); + }; + + useEffect(() => { + console.log("currentCompany", currentCompany); + }, [currentCompany]); + + return ( + +
+
0 && + skipAnimation == false + ? "tb-visible" + : "tb-hidden" + } + ${skipAnimation ? "tb-skip-animation" : ""} + `} + id="app-container" + > + {landingPages.map((pageData, index) => ( + + + + ))} +
+ + + + + + + + + + + + + + + + + +