Initial Commit

This commit is contained in:
Tom Butcher 2025-03-25 22:20:19 +00:00
commit 221decc8bc
27 changed files with 23649 additions and 0 deletions

33
.eslintrc.json Normal file
View File

@ -0,0 +1,33 @@
{
"env": {
"browser": true,
"node": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": ["react", "react-hooks"],
"rules": {
"no-console": "off",
"semi": ["error", "always"],
"quotes": ["error", "double"],
"no-unused-vars": "warn",
"react/prop-types": "off",
"react/react-in-jsx-scope": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
build/
.DS_Store
.env
*.log

BIN
logo.afdesign Normal file

Binary file not shown.

21330
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@ -0,0 +1,60 @@
{
"name": "personal-site",
"version": "0.1.0",
"private": true,
"homepage": "https://tombutcher.work",
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.8.1",
"antd": "^5.24.2",
"framer-motion": "^12.4.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-particles": "^2.12.2",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.2.0",
"react-scripts": "^5.0.1",
"react-tsparticles": "^2.12.2",
"react-turnstile": "^1.1.4",
"sass": "^1.85.1",
"web-vitals": "^4.2.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
},
"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": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.37.4",
"gh-pages": "^6.3.0",
"globals": "^16.0.0"
}
}

131
public/fonts.css Normal file
View File

@ -0,0 +1,131 @@
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 900;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 700;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 800;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: normal;
font-weight: 200;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 500;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 400;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 600;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 300;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 100;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.otf")
format("opentype");
}
@font-face {
font-family: "Grold Rounded Slim";
font-style: normal;
font-weight: normal;
src:
url("https://cdn.tombutcher.work/fonts/Grold-Rounded-Slim-Bold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/Grold-Rounded-Slim-Bold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/Grold-Rounded-Slim-Bold-Subset.otf")
format("truetype");
}

511
public/global.css Normal file
View File

@ -0,0 +1,511 @@
* {
font-family: "SF-Pro-Rounded";
}
body,
html {
height: 100%;
touch-action: none; /* Disable touch actions (like scrolling) */
scroll-behavior: smooth;
}
body {
overflow: hidden;
}
p,
span {
font-weight: 500;
font-size: 16px;
line-height: 1;
letter-spacing: 0.04em;
}
:root {
--unit-100vh: 100vh;
}
@supports (height: 100dvh) {
:root {
--unit-100vh: 100dvh;
}
}
.ant-typography,
h1,
h2 {
font-family: "Grold Rounded Slim";
color: white;
text-transform: uppercase;
}
h3 {
font-family: "SF-Pro-Rounded" !important;
font-weight: 700 !important;
}
footer {
letter-spacing: 1.3px;
font-weight: 700;
}
a {
color: white;
}
a:hover {
color: white;
}
h1 {
font-size: 50px;
}
.tblogo {
padding-bottom: 15px;
}
.tbview {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 5px 25px;
padding-bottom: 10px;
}
.tbbutton {
font-weight: 600;
border-radius: 15px;
background: none;
border: none;
box-shadow: none;
color: white;
font-size: 16px;
padding: 0;
}
.tbbutton:hover {
background: none !important;
color: white !important;
box-shadow: none;
}
.tbnav span {
font-size: 30px;
text-transform: uppercase;
}
.tbnav {
background: none;
border: none;
box-shadow: none;
color: white;
letter-spacing: 2px;
line-height: 1.2;
border-radius: 25px;
padding: 6px 15px;
padding-top: 9px;
height: auto;
}
.tbmobilenav span {
font-family: "Grold Rounded Slim";
font-size: 45px;
text-transform: uppercase;
}
.tbmobilenav {
font-family: "Grold Rounded Slim";
font-size: 45px;
text-transform: uppercase;
background: none;
border: none;
box-shadow: none;
color: white;
letter-spacing: 2px;
line-height: 1.2;
border-radius: 25px;
padding: 0px 0px;
height: auto;
display: flex;
justify-content: flex-start;
}
.tbmobilenav:hover {
color: #ffffff !important;
background: none !important;
box-shadow: none !important;
}
.tbnav Span {
font-family: "Grold Rounded Slim";
}
.tbmobilenav Span {
text-align: end;
line-height: 0.95;
font-family: "Grold Rounded Slim";
}
.tbnav:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: white !important;
box-shadow:
6px 6px 12px #c5c5c52b,
-6px -6px 12px #ffffff30;
}
.tbcard {
width: 70%;
height: 70%;
border-radius: 0;
border: none;
background: none;
color: white;
}
.tbcard .ant-card-head {
padding: 0 !important;
color: white;
min-height: 38px;
border-bottom: 1px solid #ffffff;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 1.3px;
}
.tbcard .ant-card-head .ant-card-head-title {
text-align: left;
}
.tbcard .ant-card-body {
padding: 24px 0px;
overflow-y: auto;
overflow: visible;
}
.tbcard input {
background: none;
color: #ffffff;
border-color: #ffffff00;
}
.tbcard input:focus {
border-color: #ffffff !important;
background: none;
}
.tbcard input:focus-within {
border-color: #ffffff !important;
background: none !important;
}
.tbcard input:active {
border-color: #ffffff;
background: rgba(255, 255, 255, 0.1) !important;
}
.tbcard input:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: white !important;
box-shadow:
6px 6px 12px #c5c5c52b,
-6px -6px 12px #ffffff30;
border-color: #ffffff00;
}
.tbcard input.ant-input-status-error {
background: none !important;
border-color: #ffffff00;
}
.tbcard input::placeholder {
color: #ffffffb0;
}
.tbcard .ant-card-body button {
background: none;
border-color: #ffffff00;
color: #ffffff;
box-shadow: none;
}
.tbcard .ant-card-body button:hover {
background: rgba(255, 255, 255, 0.1) !important;
color: white !important;
box-shadow:
6px 6px 12px #c5c5c52b,
-6px -6px 12px #ffffff30;
}
.ant-card-actions Span {
text-transform: uppercase;
font-weight: 700;
letter-spacing: 1.3px;
}
.tbcard .ant-card-body button:active {
background: rgba(255, 255, 255, 0.1) !important;
color: white !important;
box-shadow:
6px 6px 12px #c5c5c52b,
-6px -6px 12px #ffffff30;
}
.tbcard .ant-card-actions {
background: none;
min-height: 40px;
justify-content: center;
flex-direction: column;
align-items: end;
}
.tbcard .ant-card-actions li {
width: unset !important;
margin: 0;
}
.tbcard .ant-card-actions .anticon {
color: #ffffff !important;
font-size: 16px !important;
}
.tbcard .ant-card-actions Button:hover {
box-shadow: none;
background: none;
}
.tbsocial {
color: white;
}
.tbsocial:hover {
color: white;
}
.ant-modal-mask {
backdrop-filter: blur(3px);
}
.tbturnstile {
width: unset !important;
}
.tbturnstile .ant-modal-content {
background: none;
border: none;
box-shadow: none;
}
.tbblogfilters {
border-bottom: 1px solid #f0f0f0;
padding-bottom: 5px;
}
.tbtag {
font-family: "SF-Pro-Rounded";
color: #ffffff;
font-size: 14px;
border-radius: 10px;
}
.tbdisabledtag {
background: rgba(255, 255, 255, 0.1) !important;
border-color: transparent;
color: #ffffff;
border-radius: 10px;
}
.tbtag:hover {
background: rgba(255, 255, 255, 0.1) !important;
box-shadow: 6px 6px 12px #c5c5c52b;
color: #ffffff !important;
}
.tbtag.ant-tag-checkable-checked {
color: #ffffff !important;
background: none;
border: 1px solid #ffffff;
}
.tbblogbox {
width: 100%;
padding: 10px 15px;
padding-bottom: 15px;
border-radius: 8px;
border: 1px solid #e8e8e8;
transition: box-shadow 0.3s;
cursor: pointer;
}
.tbblogtitle {
line-height: 0.9;
margin: 10px 0;
}
.tbblogcontent h1 {
line-height: 0.6;
font-size: 2.3em;
margin-bottom: 0.5em;
margin-top: 1em;
}
.tbblogcontent p,
.tbblogcontent ul,
.tbblogcontent ol {
margin-bottom: 1em;
margin-top: 1em;
}
.tbblogcontent ul p,
.tbblogcontent ul ul,
.tbblogcontent ul ol {
margin-bottom: 0em;
margin-top: 0em;
}
.tbblogcontent ol p,
.tbblogcontent ol ul,
.tbblogcontent ol ol {
margin-bottom: 0em;
margin-top: 0em;
}
.tbblogcontent ul,
.tbblogcontent ol {
padding-inline-start: 30px;
}
.tbblogcontent h2 {
line-height: 0.6;
margin-bottom: 0.5em;
margin-top: 1em;
font-size: 1.6em;
}
.tbblogcontent li {
font-size: 16px;
font-weight: 500;
}
.tbblogcontent hr {
height: 1px;
border: none;
background-color: rgba(255, 255, 255, 0.25);
margin: 10px 0px;
}
.tbblogcontent .tbcallout {
background-color: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 10px;
font-size: 16px;
line-height: 1.2;
letter-spacing: 0.04em;
display: flex;
overflow-wrap: anywhere;
white-space: normal;
margin-bottom: 1em;
}
.tbblogcontent .tbcallout .tbcalloutcontent {
flex-grow: 1;
}
.tbblogcontent .tbcallout div:first-child {
margin-top: 0px;
}
.tbblogcontent .tbcallout div p:last-child,
.tbblogcontent .tbcallout div div:last-child,
.tbblogcontent .tbcallout div ul:last-child,
.tbblogcontent .tbcallout div ol:last-child {
margin-bottom: 0px;
}
.tbblogcontent .tbcallout div p:first-child,
.tbblogcontent .tbcallout div h1:first-child,
.tbblogcontent .tbcallout div h2:first-child,
.tbblogcontent .tbcallout div ul:first-child,
.tbblogcontent .tbcallout div ol:first-child {
margin-top: 4px;
}
.tbblogcontent .tbcallout .tbemoji {
font-size: 24px;
}
.tbblogcontent .tbcallout .tbemoji,
.tbblogcontent .tbcallout .tbicon {
margin-right: 10px;
}
.tbblogcontent .tbquote {
padding: 5px 10px;
border-left: 1px solid #ffffff;
}
.tbblogcontent img {
max-height: 260px;
max-width: 100%;
border-radius: 10px;
}
.tbblogcontent .tbimage {
border-radius: 10px;
margin-bottom: 0.5em;
margin-top: 0.5em;
}
.tbblogcontent .tbimagecaption {
color: rgba(255, 255, 255, 0.5);
}
.tbblogcontent i.tbemoji {
font-style: normal;
font-size: 20px;
line-height: 1;
}
.tbblogcontent img.tbicon {
width: 24px;
height: 24px;
border-radius: 3px;
}
.tbblogcontent div.tbicon {
width: 24px;
height: 24px;
}
.tbblogcontent .tbtable {
margin: 0.5em 0;
}
.tbblogcontent .tbtable.tbcolumnheader tr:first-child {
background-color: rgba(255, 255, 255, 0.1);
font-weight: 600;
}
.tbblogcontent .tbtable.tbrowheader td:first-child {
background-color: rgba(255, 255, 255, 0.1);
font-weight: 600;
}
.tbblogcontent td {
border: 1px solid #ffffff;
padding: 8px 12px;
}
.tbblogcontent .tbcolumnlist {
display: flex;
}
.tbblogcontent .tbcolumnlist .tbcolumn:first-child {
margin-left: 0px;
}
.tbblogcontent .tbcolumnlist .tbcolumn {
flex: 1 1 0px;
margin-left: 15px;
}

28
public/index.html Normal file
View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link
rel="icon"
href="https://cdn.tombutcher.work/favicon/favicon.ico"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="description" content="Tom Butcher's Personal Website" />
<link
rel="apple-touch-icon"
href="https://cdn.tombutcher.work/favicon/favicon192.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="stylesheet" href="%PUBLIC_URL%/fonts.css?e=e.css" />
<link rel="stylesheet" href="%PUBLIC_URL%/global.css?t=test" />
<title>TOM BUTCHER</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

20
public/manifest.json Normal file
View File

@ -0,0 +1,20 @@
{
"short_name": "TomButcher",
"name": "TOM BUTCHER",
"icons": [
{
"src": "favicon192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "favicon512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/",
"display": "standalone",
"theme_color": "#E800B6",
"background_color": "#2B0BFF"
}

0
public/notion-icons.css Normal file
View File

278
src/App.jsx Normal file
View File

@ -0,0 +1,278 @@
import React, { useState, useEffect } from "react";
import { Layout, Typography, Space } from "antd";
import { useLocation, useNavigate } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import ParticlesBackground from "./components/ParticlesBackground";
import { useMediaQuery } from "react-responsive";
import MainView from "./views/MainView";
import NotFoundView from "./views/NotFoundView";
import CVView from "./views/CVView";
import ContactView from "./views/ContactView";
import SocialsView from "./views/SocialsView";
import ExperienceView from "./views/ExperienceView";
import BlogsView from "./views/BlogsView";
import BlogView from "./views/BlogView";
import CacheReloadView from "./utils/CacheReloadView";
import { LinkOutlined } from "@ant-design/icons";
const { Content } = Layout;
const { Footer } = Layout;
const { Title } = Typography;
const App = () => {
const location = useLocation();
const navigate = useNavigate();
const isMobile = useMediaQuery({ maxWidth: 600 });
const getQueryParam = (param) => {
const searchParams = new URLSearchParams(location.search);
return searchParams.get(param) || "index";
};
const [currentView, setCurrentView] = useState(getQueryParam("to"));
const [referrer, setReferrer] = useState(getQueryParam("r"));
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
const initHandlers = () => {
// Add event listener to detect when input is focused
const handleFocus = () => {
setIsKeyboardOpen(true);
// Allow scrolling when input is focused
document.body.style.overflow = "auto";
};
const handleBlur = () => {
setIsKeyboardOpen(false);
// Smooth scroll to top when input is blurred
window.scrollTo({
top: 0,
behavior: "smooth",
});
setTimeout(() => {
document.body.style.overflow = "hidden";
}, 500);
};
// Attach event listeners
document.querySelectorAll("input, textarea").forEach((input) => {
input.addEventListener("focus", handleFocus);
input.addEventListener("blur", handleBlur);
});
return () => {
// Clean up event listeners
document.querySelectorAll("input, textarea").forEach((input) => {
input.removeEventListener("focus", handleFocus);
input.removeEventListener("blur", handleBlur);
});
};
};
useEffect(() => {
const newReferrer = getQueryParam("r");
if (newReferrer != "*") {
setReferrer(newReferrer); // Only update if 'r' exists
}
setCurrentView(getQueryParam("to"));
}, [location.search]);
useEffect(() => {
const queryParams = new URLSearchParams();
// Add the "to" parameter to the query
queryParams.set("to", currentView);
// If the referrer exists, add it to the query as well
if (referrer) {
queryParams.set("r", referrer);
}
// Navigate with the updated query string
navigate(`?${queryParams.toString()}`);
// Initialize event handlers
const cleanup = initHandlers();
// Initial body setup to prevent scrollbars
document.body.style.overflow = "hidden";
document.body.style.margin = "0";
document.body.style.padding = "0";
return () => {
cleanup();
document.body.style.overflow = "auto"; // Reset on unmount
};
}, [currentView, referrer, navigate]);
const fadeVariants = {
initial: {
clipPath: "polygon(0 0, 0 0, 0 100%, 0% 100%)",
opacity: 0,
},
animate: {
clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
opacity: 1,
transition: {
duration: 0.7,
ease: "easeInOut",
},
},
exit: {
clipPath: "polygon(100% 0, 100% 0, 100% 100%, 100% 100%)",
opacity: 0,
transition: {
duration: 0.7,
ease: "easeInOut",
},
},
};
// Render different views based on currentView state
const renderView = () => {
if (currentView.startsWith("blog-")) {
const blogSlug = currentView.replace("blog-", "");
return <BlogView setCurrentView={setCurrentView} slug={blogSlug} />;
}
switch (currentView) {
case "index":
return <MainView setCurrentView={setCurrentView} />;
case "cv":
return <CVView setCurrentView={setCurrentView} />;
case "experience":
return <ExperienceView setCurrentView={setCurrentView} />;
case "blogs":
return <BlogsView setCurrentView={setCurrentView} />;
case "contact":
return <ContactView setCurrentView={setCurrentView} />;
case "socials":
return (
<SocialsView setCurrentView={setCurrentView} referrer={referrer} />
);
case "cacheReload":
return <CacheReloadView setCurrentView={setCurrentView} />;
default:
return <NotFoundView setCurrentView={setCurrentView} />;
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
overflow: "hidden",
}}
>
<ParticlesBackground />
<div
style={{
background: "rgba(255, 255, 255, 0.0)",
backdropFilter: "blur(50px)",
width: "100%",
height: "100%",
top: 0,
left: 0,
position: "absolute",
display: "inline",
}}
></div>
<Layout
style={{
height: "100%",
display: "flex",
flexDirection: "column",
background: "transparent",
}}
>
{/* Content */}
<Content
style={{
position: "relative",
flex: 1,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<AnimatePresence mode="wait">
<motion.div
key={currentView}
variants={fadeVariants}
initial="initial"
animate="animate"
exit="exit"
style={{
width: "100%",
height: "100%",
overflow: "auto",
position: "absolute",
top: 0,
left: 0,
}}
>
{renderView()}
</motion.div>
</AnimatePresence>
</Content>
{/* Footer */}
<motion.div
initial={{ paddingLeft: "20px" }}
animate={{
paddingLeft: currentView === "index" && isMobile ? "50px" : "20px",
}}
transition={{ duration: 0.5 }}
style={{
position: "fixed",
bottom: "0",
left: isMobile ? "0" : "50%",
transform: isMobile ? "unset" : "translateX(-50%)",
}}
>
<Footer
style={{
zIndex: 1,
textAlign: isMobile ? "left" : "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
background: "transparent",
color: "#FFFFFF",
padding: "20px 25px",
paddingLeft: "0px",
paddingTop: "0px",
fontSize: "16px",
}}
>
<Space>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentView("index");
}}
>
© {new Date().getFullYear()}{" "}
</a>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentView("socials");
}}
>
<LinkOutlined />
</a>
</Space>
</Footer>
</motion.div>
</Layout>
</div>
);
};
export default App;

View File

@ -0,0 +1,117 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import "./Auth.css";
const ParticlesComponent = React.memo(({ options, particlesLoaded }) => {
return (
<Particles
id="tsparticles"
options={options}
particlesLoaded={particlesLoaded}
/>
);
});
ParticlesComponent.displayName = "ParticlesComponent";
const AuthParticles = () => {
const [init, setInit] = useState(false);
// this should be run only once per application lifetime
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => {
setInit(true);
});
}, []);
const particlesLoaded = useCallback((container) => {
console.log(container);
}, []);
const options = useMemo(
() => ({
background: {
color: {
value: "#141414",
},
},
fpsLimit: 120,
interactivity: {
events: {
onClick: {
enable: true,
mode: "push",
},
onHover: {
enable: true,
mode: "repulse",
},
},
modes: {
push: {
quantity: 4,
},
repulse: {
distance: 200,
duration: 0.4,
},
},
},
particles: {
color: {
value: "#ffffff",
},
links: {
color: "#ffffff",
distance: 150,
enable: true,
opacity: 0.5,
width: 1,
},
move: {
direction: "none",
enable: true,
outModes: {
default: "bounce",
},
random: false,
speed: 1,
straight: false,
},
number: {
density: {
enable: true,
},
value: 160,
},
opacity: {
value: 0.5,
},
shape: {
type: "circle",
},
size: {
value: { min: 1, max: 5 },
},
},
detectRetina: true,
}),
[],
);
return (
<>
{init && (
<ParticlesComponent
options={options}
particlesLoaded={particlesLoaded}
/>
)}
</>
);
};
export default AuthParticles;

View File

@ -0,0 +1,122 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
const ParticlesComponent = React.memo(({ options, particlesLoaded }) => {
return (
<Particles
id="tsparticles"
options={options}
particlesLoaded={particlesLoaded}
/>
);
});
ParticlesComponent.displayName = "ParticlesComponent";
const ParticlesBackground = () => {
const [init, setInit] = useState(false);
// this should be run only once per application lifetime
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => {
setInit(true);
});
}, []);
const particlesLoaded = useCallback((container) => {
console.log(container);
}, []);
const options = useMemo(
() => ({
background: {
color: {
value: "#FF00A1",
},
},
fpsLimit: 120,
interactivity: {
events: {
onClick: {
enable: false,
mode: "push",
},
onHover: {
enable: true,
mode: "repulse",
},
},
modes: {
push: {
quantity: 4,
},
repulse: {
distance: 100,
duration: 5,
},
},
},
particles: {
color: {
value: [
"#FF00A1",
"#0310FF",
"#2DE2FF",
"#6E00FF",
"#0310FF",
"#FF00A1",
],
},
links: {
color: "#ffffff",
distance: 150,
enable: true,
opacity: 0.0,
width: 0,
},
move: {
direction: "none",
enable: true,
outModes: {
default: "out",
},
random: true,
speed: 1,
straight: false,
},
number: {
density: {
enable: true,
},
value: 400,
},
opacity: {
value: 1,
},
shape: {
type: "circle",
},
size: {
value: { min: 100, max: 300 },
},
},
detectRetina: true,
}),
[],
);
return (
<>
{init && (
<ParticlesComponent
options={options}
particlesLoaded={particlesLoaded}
/>
)}
</>
);
};
export default ParticlesBackground;

View File

@ -0,0 +1,28 @@
import { useState } from "react";
import { Modal } from "antd";
import Turnstile from "react-turnstile";
const TurnstileModal = ({ open, onClose, onSuccess }) => {
const [turnstileToken, setTurnstileToken] = useState("");
const handleVerify = (token) => {
setTurnstileToken(token);
onClose(); // Close modal after verification
onSuccess(token); // Notify parent component
};
return (
<Modal
open={open}
footer={null}
onCancel={onClose}
closeIcon={null}
centered
className="tbturnstile"
>
<Turnstile sitekey="0x4AAAAAAA_bc3QTrE68whtg" onVerify={handleVerify} />
</Modal>
);
};
export default TurnstileModal;

15
src/index.js Normal file
View File

@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "antd/dist/reset.css"; // Import Ant Design styles
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
,
</React.StrictMode>,
);

View File

@ -0,0 +1,72 @@
import React, { useState, useEffect } from "react";
import { useMediaQuery } from "react-responsive";
import { Card, Flex, message } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
const CacheReloadView = ({ setCurrentView }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const isMobile = useMediaQuery({ maxWidth: 600 });
// Fetch blogs from API
useEffect(() => {
const reloadCache = async () => {
try {
const response = await fetch(
"http://192.168.68.53:8787/api/utils/cache",
{ method: "POST" },
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
window.close();
setLoading(false);
} catch (err) {
console.error("Error reloading cache:", err);
setError(err.message);
setLoading(false);
message.error("Failed to reload cache!");
}
};
reloadCache();
}, []);
return (
<div className="tbview">
<Card
className={"tbcard"}
title="Reload Cache"
style={{
width: isMobile ? "100%" : "70%",
height: isMobile ? "100%" : "70%",
display: "flex",
flexDirection: "column",
}}
bodyStyle={{
flex: 1,
overflowY: "hidden", // Changed from auto to hidden
display: "flex",
flexDirection: "column",
}}
>
{loading ? (
<Flex justify="center" align="center" style={{ padding: "20px" }}>
<LoadingOutlined style={{ fontSize: 48, color: "white" }} spin />
</Flex>
) : error ? (
<Flex justify="center" align="center">
<p>Error to reload cache: {error}</p>
</Flex>
) : (
<Flex justify="center" align="center">
<p>Cache Reloaded!</p>
</Flex>
)}
</Card>
</div>
);
};
export default CacheReloadView;

137
src/views/BlogView.jsx Normal file
View File

@ -0,0 +1,137 @@
import React, { useState, useEffect } from "react";
import { useMediaQuery } from "react-responsive";
import { Tag, Button, Card, Flex, message } from "antd";
import { ArrowLeftOutlined, LoadingOutlined } from "@ant-design/icons";
const BlogView = ({ setCurrentView, slug }) => {
const [blog, setBlog] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const isMobile = useMediaQuery({ maxWidth: 600 });
// Fetch blog content from API
useEffect(() => {
const fetchBlog = async () => {
try {
const response = await fetch(
`https://web.tombutcher.work/api/view/blog?b=${slug}`,
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
setBlog(data);
setLoading(false);
} catch (err) {
console.error(`Error fetching blog with slug ${slug}:`, err);
setError(err.message);
setLoading(false);
message.error("Failed to load blog content");
}
};
if (slug) {
fetchBlog();
} else {
setError("No blog slug provided");
setLoading(false);
}
}, [slug]);
// Format date
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
return (
<div className="tbview">
<Card
className={"tbcard"}
title={"Blog"}
style={{
width: isMobile ? "100%" : "70%",
height: isMobile ? "100%" : "70%",
display: "flex",
flexDirection: "column",
}}
bodyStyle={{
flex: 1,
overflowY: "auto",
padding: "0",
margin: "20px 0",
}}
actions={[
<Button
className={"tbbutton"}
key="back"
onClick={() => setCurrentView("blogs")}
icon={<ArrowLeftOutlined />}
>
Back
</Button>,
]}
>
{loading ? (
<Flex justify="center" align="center" style={{ padding: "20px" }}>
<LoadingOutlined style={{ fontSize: 48, color: "white" }} spin />
</Flex>
) : error ? (
<Flex justify="center" align="center">
<p>Error loading blog: {error}</p>
</Flex>
) : blog ? (
<div style={{ padding: isMobile ? "0 0" : "0 20px" }}>
<Flex vertical gap={0} style={{ padding: "0px 0" }}>
<Flex justify="space-between" align="center">
<h1 className="tbblogtitle">{blog.title}</h1>
{!isMobile ? (
<span style={{ fontSize: "1.17em", color: "#ffffff80" }}>
{formatDate(blog.last_edited)}
</span>
) : null}
</Flex>
<p style={{ fontSize: "16px", fontStyle: "italic" }}>
{blog.description}
</p>
<Flex justify="space-between" align="center">
{!isMobile ? (
<span style={{ fontSize: "14px", color: "#ffffff80" }}>
By {blog.author}
</span>
) : null}
<Flex gap={4}>
{blog.tags &&
blog.tags.map((tag) => (
<Tag className="tbdisabledtag" key={`blog-view-${tag}`}>
{tag}
</Tag>
))}
</Flex>
</Flex>
<div
className="tbblogcontent"
dangerouslySetInnerHTML={{ __html: blog.content }}
style={{ marginTop: "20px" }}
/>
</Flex>
</div>
) : (
<Flex justify="center" align="center">
<p>Blog not found</p>
</Flex>
)}
</Card>
</div>
);
};
export default BlogView;

213
src/views/BlogsView.jsx Normal file
View File

@ -0,0 +1,213 @@
import React, { useState, useEffect } from "react";
import { useMediaQuery } from "react-responsive";
import { Tag, Button, Card, Flex, message } from "antd";
import { LoadingOutlined, ArrowLeftOutlined } from "@ant-design/icons";
const BlogsView = ({ setCurrentView }) => {
const [blogs, setBlogs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedTags, setSelectedTags] = useState([]);
const isMobile = useMediaQuery({ maxWidth: 600 });
// Fetch blogs from API
useEffect(() => {
const fetchBlogs = async () => {
try {
const response = await fetch(
"https://web.tombutcher.work/api/list/blogs",
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
setBlogs(data);
setLoading(false);
} catch (err) {
console.error("Error fetching blogs:", err);
setError(err.message);
setLoading(false);
message.error("Failed to load blogs");
}
};
fetchBlogs();
}, []);
// Get all unique tags from blogs
const getAllTags = () => {
const tagsSet = new Set();
blogs.forEach((blog) => {
blog.tags.forEach((tag) => tagsSet.add(tag));
});
return Array.from(tagsSet);
};
// Handle tag selection
const handleTagChange = (tag, checked) => {
const nextSelectedTags = checked
? [...selectedTags, tag]
: selectedTags.filter((t) => t !== tag);
setSelectedTags(nextSelectedTags);
};
// Filter blogs based on selected tags
const filteredBlogs =
selectedTags.length > 0
? blogs.filter((blog) =>
blog.tags.some((tag) => selectedTags.includes(tag)),
)
: blogs;
// Format date
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
return (
<div className="tbview">
<Card
className={"tbcard"}
title="Blog"
style={{
width: isMobile ? "100%" : "70%",
height: isMobile ? "100%" : "70%",
display: "flex",
flexDirection: "column",
}}
bodyStyle={{
flex: 1,
overflowY: "hidden", // Changed from auto to hidden
display: "flex",
flexDirection: "column",
}}
actions={[
<Button
className={"tbbutton"}
key="back"
onClick={() => setCurrentView("index")}
icon={<ArrowLeftOutlined />}
>
Back
</Button>,
]}
>
{loading ? (
<Flex justify="center" align="center" style={{ padding: "20px" }}>
<LoadingOutlined style={{ fontSize: 48, color: "white" }} spin />
</Flex>
) : error ? (
<Flex justify="center" align="center">
<p>Error loading blogs: {error}</p>
</Flex>
) : (
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
{/* Tags section with center alignment and no background */}
<Flex
justify="center"
wrap
className="tbblogfilters"
style={{
flexShrink: 0, // Prevents tags from shrinking
}}
>
{getAllTags().map((tag) => (
<Tag.CheckableTag
key={tag}
checked={selectedTags.includes(tag)}
onChange={(checked) => handleTagChange(tag, checked)}
className="tbtag"
style={{
margin: "4px",
background: "transparent",
}}
>
{tag}
</Tag.CheckableTag>
))}
</Flex>
{/* Blog list with scrolling */}
<Flex
style={{
margin: isMobile ? "0" : "0 10%",
flexDirection: "column",
gap: "20px",
padding: isMobile ? "12px 0px" : "12px 24px",
overflowY: "auto", // Add scroll to this container only
flexGrow: 1, // Allow this container to grow and take available space
}}
>
{filteredBlogs.length > 0 ? (
filteredBlogs.map((blog) => (
<Flex
key={blog.slug}
vertical
onClick={() => setCurrentView(`blog-${blog.slug}`)}
className="tbblogbox"
>
<Flex justify="space-between" align="center">
<h3 style={{ marginBottom: "0px" }}>{blog.title}</h3>
<span style={{ fontSize: "1.17em", color: "#ffffff80" }}>
{formatDate(blog.last_edited)}
</span>
</Flex>
<p style={{ marginBottom: "8px" }}>{blog.description}</p>
<Flex justify="space-between" align="center">
<span style={{ fontSize: "14px", color: "#ffffff80" }}>
By {blog.author}
</span>
<Flex gap={"small"}>
{blog.tags.map((tag) => (
<Tag
key={`${blog.slug}-${tag}`}
className="tbdisabledtag"
style={{ margin: "0" }}
>
{tag}
</Tag>
))}
</Flex>
</Flex>
</Flex>
))
) : (
<p>No blogs match the selected tags</p>
)}
</Flex>
</div>
)}
</Card>
</div>
);
};
// CSS for hover effect
const blogCardStyles = `
.blog-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.tbtag {
background: transparent !important;
}
.tbdisabledtag {
background: transparent !important;
}
`;
export default BlogsView;

115
src/views/CVView.jsx Normal file
View File

@ -0,0 +1,115 @@
import React, { useState } from "react";
import { useMediaQuery } from "react-responsive";
import { Form, Input, Button, Card, Flex } from "antd";
import {
FilePdfOutlined,
CloudDownloadOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
const CVView = ({ setCurrentView }) => {
const onFinish = (values) => {
console.log("Form values:", values);
};
const isMobile = useMediaQuery({ maxWidth: 600 });
const [isEmailFocused, setIsEmailFocused] = useState(false); // Track focus state
return (
<div className="tbview">
<Card
className={"tbcard"}
title="CV"
style={{
width: isMobile ? "100%" : "70%",
height: isMobile ? "100%" : "70%",
}}
actions={[
<Button
className={"tbbutton"}
key="back"
onClick={() => setCurrentView("index")}
icon={<ArrowLeftOutlined />}
>
Back
</Button>,
]}
>
<Flex style={{ margin: isMobile ? "0px 0px" : "0px 10%" }} vertical>
<Flex
gap="middle"
style={{ borderBottom: "1px solid #ffffff", padding: "10px 0px" }}
>
<FilePdfOutlined style={{ fontSize: "64px" }} />
<div style={{ width: "100%" }}>
<h3 style={{ marginBottom: "0px" }}>Curriculum Vitae 2025</h3>
<p style={{ marginBottom: "2px" }}>
Enter your email address to download:
</p>
<Form
layout="vertical"
onFinish={onFinish}
style={{ paddingBottom: "5px" }}
>
<Form.Item
name="email"
rules={[
{ required: true, message: "Please enter your email" },
]}
style={{ marginBottom: "0" }}
>
<Flex gap="small">
<Input
type="email"
placeholder="example@example.com"
style={{ flex: 1 }}
onFocus={() => setIsEmailFocused(true)} // Set focused state to true
onBlur={() => setIsEmailFocused(false)} // Set focused state to false
/>
<Button type="primary" htmlType="submit">
<CloudDownloadOutlined />
</Button>
</Flex>
</Form.Item>
</Form>
</div>
</Flex>
<Flex gap="middle" style={{ padding: "10px 0px" }}>
<FilePdfOutlined style={{ fontSize: "64px" }} />
<div style={{ width: "100%" }}>
<h3 style={{ marginBottom: "0px" }}>Curriculum Vitae 2024</h3>
<p style={{ marginBottom: "2px" }}>
Enter your email address to download:
</p>
<Form
layout="vertical"
onFinish={onFinish}
style={{ paddingBottom: "5px" }}
>
<Form.Item
name="email"
rules={[
{ required: true, message: "Please enter your email" },
]}
style={{ marginBottom: "0" }}
>
<Flex gap="small">
<Input
type="email"
placeholder="example@example.com"
style={{ flex: 1 }}
onFocus={() => setIsEmailFocused(true)} // Set focused state to true
onBlur={() => setIsEmailFocused(false)} // Set focused state to false
/>
<Button type="primary" htmlType="submit">
<CloudDownloadOutlined />
</Button>
</Flex>
</Form.Item>
</Form>
</div>
</Flex>
</Flex>
</Card>
</div>
);
};
export default CVView;

103
src/views/ContactView.jsx Normal file
View File

@ -0,0 +1,103 @@
import React, { useState } from "react";
import { useMediaQuery } from "react-responsive";
import { Form, Input, Button, Card, Flex, message } from "antd";
import TurnstileModal from "../components/TurnstileModal";
import { ArrowRightOutlined, ArrowLeftOutlined } from "@ant-design/icons";
const ContactView = ({ setCurrentView }) => {
const isMobile = useMediaQuery({ maxWidth: 600 });
const [isEmailFocused, setIsEmailFocused] = useState(false); // Track focus state
const [turnstileOpen, setTurnstileOpen] = useState(false);
const [email, setEmail] = useState("");
const handleSubmit = async (token) => {
console.log(token);
if (!token) {
setTurnstileOpen(true); // Open Turnstile modal
return;
}
try {
const response = await fetch("https://web.tombutcher.work/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, token }),
});
const data = await response.json();
if (data.success) {
message.success("Message sent successfully!");
} else {
message.error("Verification failed. Try again.");
}
} catch (error) {
message.error("Error sending request.");
}
};
return (
<div className="tbview">
<Card
className={"tbcard"}
title="Contact"
style={{
width: isMobile ? "100%" : "70%",
height: isMobile ? "100%" : "70%",
}}
actions={[
<Button
className={"tbbutton"}
key="back"
onClick={() => setCurrentView("index")}
icon={<ArrowLeftOutlined />}
>
Back
</Button>,
]}
>
<div style={{ minWidth: isMobile ? "100%" : "70%" }}>
<p>Enter your email below and I will be in contact:</p>
<Form
layout="vertical"
onFinish={() => {
handleSubmit();
}}
style={{ paddingBottom: "5px" }}
>
<Form.Item
name="email"
rules={[{ required: true, message: "Please enter your email" }]}
style={{ marginBottom: "8px" }}
>
<Flex gap="small">
<Input
type="email"
placeholder="example@example.com"
style={{ flex: 1 }}
onFocus={() => setIsEmailFocused(true)} // Set focused state to true
onBlur={() => setIsEmailFocused(false)} // Set focused state to false
/>
<Button type="primary" htmlType="submit">
<ArrowRightOutlined />
</Button>
</Flex>
</Form.Item>
</Form>
<p>
I&apos;m often busy, but I&apos;ll make sure to get back to you
within 48 hours!
</p>
<p style={{ marginBottom: "0px", color: "rgb(255 255 255 / 50%)" }}>
By clicking the submit button, you consent to your email address and
certain browser-related details (such as an approximate geographical
location based on your IP) being sent exclusively to me via email.
</p>
</div>
</Card>
<TurnstileModal
open={turnstileOpen}
onClose={() => setTurnstileOpen(false)}
onSuccess={handleSubmit} // Sends token to handleSubmit
/>
</div>
);
};
export default ContactView;

View File

@ -0,0 +1,63 @@
import React from "react";
import { useMediaQuery } from "react-responsive";
import { Button, Card, Flex } from "antd";
import { ArrowLeftOutlined } from "@ant-design/icons";
const ExperienceView = ({ setCurrentView }) => {
const isMobile = useMediaQuery({ maxWidth: 600 });
return (
<div className="tbview">
<Card
className={"tbcard"}
title="Experience"
style={{
width: isMobile ? "100%" : "70%",
height: isMobile ? "100%" : "70%",
}}
actions={[
<Button
className={"tbbutton"}
key="back"
onClick={() => setCurrentView("index")}
icon={<ArrowLeftOutlined />}
>
Back
</Button>,
]}
>
<Flex
vertical
gap="45px"
style={{ margin: "0 10%", paddingBottom: "15px" }}
>
<img
src={`https://cdn.tombutcher.work/logos/nucleus-logo.svg`}
alt="Nucleus Logo"
height={"80px"}
/>
<img
src={`https://cdn.tombutcher.work/logos/greeneking-logo.svg`}
alt="Greene King Logo"
height={"60px"}
/>
<img
src={`https://cdn.tombutcher.work/logos/thelondonoffice-logo.svg`}
alt="The London Office Logo"
height={"55px"}
/>
<img
src={`https://cdn.tombutcher.work/logos/revolution-logo.svg`}
alt="Revolution Logo"
height={"65px"}
/>
<img
src={`https://cdn.tombutcher.work/logos/istore-logo.svg`}
alt="Revolution Logo"
height={"40px"}
/>
</Flex>
</Card>
</div>
);
};
export default ExperienceView;

90
src/views/MainView.jsx Normal file
View File

@ -0,0 +1,90 @@
// views/MainView.jsx
import React from "react";
import { Typography, Button, Flex, Space } from "antd";
import { ReactComponent as LogoHorizontal } from "./logo-horizontal.svg";
import { ReactComponent as LogoVertical } from "./logo-vertical.svg";
import { useMediaQuery } from "react-responsive";
const { Paragraph } = Typography;
const MainView = ({ setCurrentView }) => {
const isMobile = useMediaQuery({ maxWidth: 600 });
return (
<Flex
justify="center"
align={isMobile ? "flex-start" : "center"}
vertical
style={{ height: "100%", padding: "24px 50px" }}
>
<div style={{ textAlign: "center", maxWidth: "800px", margin: "0 0" }}>
<div className={"tblogo"}>
{isMobile ? (
<LogoVertical height={180} />
) : (
<LogoHorizontal height={60} />
)}
</div>
{isMobile ? (
<Flex
justify="left"
align="left"
vertical
style={{ marginTop: "12.5px" }}
>
<Button
className="tbmobilenav"
style={{ textAlign: "left" }}
onClick={() => setCurrentView("experience")}
>
Experience
</Button>
<Button
className="tbmobilenav"
style={{ textAlign: "left" }}
onClick={() => setCurrentView("blogs")}
>
Blog
</Button>
<Button
className="tbmobilenav"
style={{ textAlign: "left" }}
onClick={() => setCurrentView("cv")}
>
CV
</Button>
<Button
className="tbmobilenav"
style={{ textAlign: "left" }}
onClick={() => setCurrentView("contact")}
>
Contact
</Button>
</Flex>
) : (
<Flex justify="center" align="center">
<Button
className={"tbnav"}
onClick={() => setCurrentView("experience")}
>
Experience
</Button>
<Button className={"tbnav"} onClick={() => setCurrentView("blogs")}>
Blog
</Button>
<Button className={"tbnav"} onClick={() => setCurrentView("cv")}>
CV
</Button>
<Button
className={"tbnav"}
onClick={() => setCurrentView("contact")}
>
Contact
</Button>
</Flex>
)}
</div>
</Flex>
);
};
export default MainView;

View File

@ -0,0 +1,18 @@
import React from "react";
import { Button, Flex } from "antd";
import { useMediaQuery } from "react-responsive";
const NotFoundView = ({ setCurrentView }) => {
return (
<div style={{ textAlign: "center", maxWidth: "800px", margin: "0 auto" }}>
<h1>404 Not found</h1>
<Flex justify="center" align="center">
<Button className="tbnav" onClick={() => setCurrentView("index")}>
Home
</Button>
</Flex>
</div>
);
};
export default NotFoundView;

98
src/views/SocialsView.jsx Normal file
View File

@ -0,0 +1,98 @@
import React, { useState } from "react";
import { useMediaQuery } from "react-responsive";
import { Flex, Spin } from "antd";
import { ReactComponent as LogoHorizontal } from "./logo-horizontal.svg";
import { ReactComponent as LogoVertical } from "./logo-vertical.svg";
import { LoadingOutlined } from "@ant-design/icons";
import { useEffect } from "react";
import * as FaIcons from "react-icons/fa6"; // Import all FA6 icons dynamically
const SocialsView = ({ setCurrentView, referrer }) => {
const isMobile = useMediaQuery({ maxWidth: 600 });
const [socials, setSocials] = useState([]);
const [loading, setLoading] = useState(true); // Loading state
// Fetch social media links from the API
useEffect(() => {
const fetchSocials = async () => {
try {
const response = await fetch(
`http://192.168.68.53:8787/api/list/socials?r=${referrer}`,
);
const data = await response.json();
setSocials(data);
} catch (error) {
console.error("Error fetching socials:", error);
} finally {
setLoading(false); // Stop loading once data is fetched
}
};
fetchSocials();
}, [referrer]);
return (
<Flex
justify="center"
align={isMobile ? "flex-start" : "center"}
vertical
style={{ height: "100%", padding: "24px 50px" }}
>
<div style={{ textAlign: "center", maxWidth: "900px", margin: "0 0" }}>
<div className={"tblogo"}>
{isMobile ? (
<LogoVertical height={180} />
) : (
<LogoHorizontal height={60} />
)}
</div>
<Flex
gap="20px"
align={"center"}
justify={isMobile ? "start" : "center"}
style={{
margin: isMobile ? "12.5px 0" : "12.5px 10px",
paddingBottom: "15px",
}}
wrap
>
{/* Show loading spinner while data is being fetched */}
{loading ? (
<Spin
indicator={
<LoadingOutlined
style={{ fontSize: 48, color: "white" }}
spin
/>
}
/>
) : (
<Flex
gap="20px"
align={"center"}
justify={isMobile ? "start" : "center"}
style={{
margin: isMobile ? "12.5px 0" : "12.5px 10px",
paddingBottom: "15px",
}}
wrap
>
{/* Dynamically render social media icons */}
{socials.map(({ name, icon, url }) => {
const IconComponent = FaIcons[icon]; // Map string to actual icon component
return (
IconComponent && (
<a key={name} href={url} className="tbsocial">
<IconComponent style={{ fontSize: "64px" }} />
</a>
)
);
})}
</Flex>
)}
</Flex>
</div>
</Flex>
);
};
export default SocialsView;

View File

@ -0,0 +1,21 @@
<?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 894 87" version="1.1" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-91.7237,-316.8)">
<g transform="matrix(1,0,0,1,-186.893,-49.2326)">
<path d="M282.793,367.473L335.641,367.473C336.697,367.473 337.657,367.881 338.521,368.697C339.385,369.513 339.817,370.449 339.817,371.505L339.817,381.153C339.817,382.209 339.385,383.145 338.521,383.961C337.657,384.777 336.697,385.185 335.641,385.185L318.937,385.185L318.937,446.673C318.937,447.729 318.529,448.689 317.713,449.553C316.897,450.417 315.961,450.849 314.905,450.849L303.529,450.849C302.473,450.849 301.537,450.417 300.721,449.553C299.905,448.689 299.497,447.729 299.497,446.673L299.497,385.185L282.793,385.185C281.737,385.185 280.777,384.777 279.913,383.961C279.049,383.145 278.617,382.209 278.617,381.153L278.617,371.505C278.617,370.449 279.049,369.513 279.913,368.697C280.777,367.881 281.737,367.473 282.793,367.473Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M354.937,378.273C361.657,370.113 371.017,366.033 383.017,366.033C395.017,366.033 404.353,370.113 411.025,378.273C417.697,386.433 421.033,396.753 421.033,409.233C421.033,421.617 417.673,431.913 410.953,440.121C404.233,448.329 394.921,452.433 383.017,452.433C371.113,452.433 361.801,448.329 355.081,440.121C348.361,431.913 345.001,421.617 345.001,409.233C345.001,396.753 348.313,386.433 354.937,378.273ZM365.017,409.233C365.017,426.225 371.017,434.721 383.017,434.721C395.017,434.721 401.017,426.225 401.017,409.233C401.017,392.241 395.017,383.745 383.017,383.745C371.017,383.745 365.017,392.241 365.017,409.233Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M477.049,439.761L475.321,439.761C474.265,439.761 473.545,439.377 473.161,438.609L455.593,406.497L455.593,446.673C455.593,447.729 455.185,448.689 454.369,449.553C453.553,450.417 452.617,450.849 451.561,450.849L440.329,450.849C439.273,450.849 438.337,450.417 437.521,449.553C436.705,448.689 436.297,447.729 436.297,446.673L436.297,371.649C436.297,370.593 436.705,369.633 437.521,368.769C438.337,367.905 439.273,367.473 440.329,367.473L453.289,367.473C454.825,367.473 455.881,368.049 456.457,369.201L476.041,404.337L495.625,369.201C496.201,368.049 497.257,367.473 498.793,367.473L511.753,367.473C512.809,367.473 513.745,367.905 514.561,368.769C515.377,369.633 515.785,370.593 515.785,371.649L515.785,446.673C515.785,447.729 515.377,448.689 514.561,449.553C513.745,450.417 512.809,450.849 511.753,450.849L500.521,450.849C499.465,450.849 498.529,450.417 497.713,449.553C496.897,448.689 496.489,447.729 496.489,446.673L496.489,406.497L478.921,438.609C478.537,439.377 477.913,439.761 477.049,439.761Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M574.969,433.425L583.177,433.425C589.801,433.425 593.113,430.641 593.113,425.073C593.113,419.697 589.801,417.009 583.177,417.009L574.969,417.009L574.969,433.425ZM583.033,384.897L574.969,384.897L574.969,399.297L583.033,399.297C583.609,399.297 584.305,399.249 585.121,399.153C585.937,399.057 587.041,398.409 588.433,397.209C589.825,396.009 590.521,394.305 590.521,392.097C590.521,389.889 589.897,388.185 588.649,386.985C587.401,385.785 586.153,385.137 584.905,385.041L583.033,384.897ZM559.705,367.473L583.177,367.473C583.945,367.473 584.905,367.521 586.057,367.617C587.209,367.713 589.321,368.121 592.393,368.841C595.465,369.561 598.177,370.593 600.529,371.937C602.881,373.281 605.017,375.369 606.937,378.201C608.857,381.033 609.817,384.417 609.817,388.353C609.817,391.329 609.409,393.993 608.593,396.345C607.777,398.697 606.817,400.521 605.713,401.817C604.609,403.113 603.433,404.217 602.185,405.129C600.937,406.041 599.929,406.641 599.161,406.929C598.393,407.217 597.913,407.361 597.721,407.361C598.969,407.745 600.145,408.225 601.249,408.801C602.353,409.377 604.009,410.385 606.217,411.825C608.425,413.265 610.177,415.257 611.473,417.801C612.769,420.345 613.417,423.249 613.417,426.513C613.417,442.737 602.185,450.849 579.721,450.849L559.705,450.849C558.649,450.849 557.713,450.417 556.897,449.553C556.081,448.689 555.673,447.729 555.673,446.673L555.673,371.649C555.673,370.593 556.081,369.633 556.897,368.769C557.713,367.905 558.649,367.473 559.705,367.473Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M657.337,452.433C656.953,452.433 656.377,452.409 655.609,452.361C654.841,452.313 653.377,452.097 651.217,451.713C649.057,451.329 647.017,450.753 645.097,449.985C643.177,449.217 641.017,447.993 638.617,446.313C636.217,444.633 634.153,442.665 632.425,440.409C630.697,438.153 629.257,435.153 628.105,431.409C626.953,427.665 626.377,423.489 626.377,418.881L626.377,371.649C626.377,370.593 626.785,369.633 627.601,368.769C628.417,367.905 629.353,367.473 630.409,367.473L641.785,367.473C642.841,367.473 643.777,367.905 644.593,368.769C645.409,369.633 645.817,370.593 645.817,371.649L645.817,421.905C645.817,422.289 645.841,422.769 645.889,423.345C645.937,423.921 646.177,424.929 646.609,426.369C647.041,427.809 647.617,429.105 648.337,430.257C649.057,431.409 650.209,432.441 651.793,433.353C653.377,434.265 655.225,434.721 657.337,434.721C660.697,434.721 663.337,433.665 665.257,431.553C667.177,429.441 668.233,427.329 668.425,425.217L668.857,421.905L668.857,371.649C668.857,370.593 669.265,369.633 670.081,368.769C670.897,367.905 671.833,367.473 672.889,367.473L684.265,367.473C685.321,367.473 686.257,367.905 687.073,368.769C687.889,369.633 688.297,370.593 688.297,371.649L688.297,418.881C688.297,423.489 687.721,427.665 686.569,431.409C685.417,435.153 683.929,438.153 682.105,440.409C680.281,442.665 678.265,444.633 676.057,446.313C673.849,447.993 671.665,449.217 669.505,449.985C667.345,450.753 665.353,451.329 663.529,451.713C661.705,452.097 660.217,452.289 659.065,452.289L657.337,452.433Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M704.713,367.473L757.561,367.473C758.617,367.473 759.577,367.881 760.441,368.697C761.305,369.513 761.737,370.449 761.737,371.505L761.737,381.153C761.737,382.209 761.305,383.145 760.441,383.961C759.577,384.777 758.617,385.185 757.561,385.185L740.857,385.185L740.857,446.673C740.857,447.729 740.449,448.689 739.633,449.553C738.817,450.417 737.881,450.849 736.825,450.849L725.449,450.849C724.393,450.849 723.457,450.417 722.641,449.553C721.825,448.689 721.417,447.729 721.417,446.673L721.417,385.185L704.713,385.185C703.657,385.185 702.697,384.777 701.833,383.961C700.969,383.145 700.537,382.209 700.537,381.153L700.537,371.505C700.537,370.449 700.969,369.513 701.833,368.697C702.697,367.881 703.657,367.473 704.713,367.473Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M805.081,452.433C793.081,452.433 783.601,448.329 776.641,440.121C769.681,431.913 766.201,421.617 766.201,409.233C766.201,396.849 769.681,386.553 776.641,378.345C783.601,370.137 793.081,366.033 805.081,366.033C825.721,366.033 838.249,375.633 842.665,394.833C842.473,395.889 841.969,396.801 841.153,397.569C840.337,398.337 839.401,398.721 838.345,398.721L825.961,398.721C824.521,398.721 823.417,398.001 822.649,396.561C820.345,388.017 814.489,383.745 805.081,383.745C798.649,383.745 793.897,386.097 790.825,390.801C787.753,395.505 786.217,401.649 786.217,409.233C786.217,416.721 787.753,422.841 790.825,427.593C793.897,432.345 798.649,434.721 805.081,434.721C814.489,434.721 820.345,430.449 822.649,421.905C823.417,420.465 824.521,419.745 825.961,419.745L838.345,419.745C839.401,419.745 840.337,420.129 841.153,420.897C841.969,421.665 842.473,422.577 842.665,423.633C838.249,442.833 825.721,452.433 805.081,452.433Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M896.521,446.673L896.521,421.905L874.633,421.905L874.633,446.673C874.633,447.729 874.225,448.689 873.409,449.553C872.593,450.417 871.657,450.849 870.601,450.849L859.369,450.849C858.313,450.849 857.377,450.417 856.561,449.553C855.745,448.689 855.337,447.729 855.337,446.673L855.337,371.649C855.337,370.593 855.745,369.633 856.561,368.769C857.377,367.905 858.313,367.473 859.369,367.473L870.601,367.473C871.657,367.473 872.593,367.905 873.409,368.769C874.225,369.633 874.633,370.593 874.633,371.649L874.633,404.337L896.521,404.337L896.521,371.649C896.521,370.593 896.929,369.633 897.745,368.769C898.561,367.905 899.497,367.473 900.553,367.473L911.785,367.473C912.841,367.473 913.777,367.905 914.593,368.769C915.409,369.633 915.817,370.593 915.817,371.649L915.817,446.673C915.817,447.729 915.409,448.689 914.593,449.553C913.777,450.417 912.841,450.849 911.785,450.849L900.553,450.849C899.497,450.849 898.561,450.417 897.745,449.553C896.929,448.689 896.521,447.729 896.521,446.673Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M932.521,446.817L932.521,371.505C932.521,370.449 932.953,369.513 933.817,368.697C934.681,367.881 935.641,367.473 936.697,367.473L977.881,367.473C978.937,367.473 979.897,367.881 980.761,368.697C981.625,369.513 982.057,370.449 982.057,371.505L982.057,381.153C982.057,382.209 981.625,383.145 980.761,383.961C979.897,384.777 978.937,385.185 977.881,385.185L951.817,385.185L951.817,401.025L974.857,401.025C975.913,401.025 976.873,401.433 977.737,402.249C978.601,403.065 979.033,404.001 979.033,405.057L979.033,414.705C979.033,415.761 978.601,416.697 977.737,417.513C976.873,418.329 975.913,418.737 974.857,418.737L951.817,418.737L951.817,433.137L977.881,433.137C978.937,433.137 979.897,433.545 980.761,434.361C981.625,435.177 982.057,436.113 982.057,437.169L982.057,446.817C982.057,447.873 981.625,448.809 980.761,449.625C979.897,450.441 978.937,450.849 977.881,450.849L936.697,450.849C935.641,450.849 934.681,450.441 933.817,449.625C932.953,448.809 932.521,447.873 932.521,446.817Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M1022.23,409.953C1025.79,409.953 1028.66,408.945 1030.87,406.929C1033.08,404.913 1034.18,401.745 1034.18,397.425C1034.18,393.105 1033.08,389.913 1030.87,387.849C1028.66,385.785 1025.79,384.753 1022.23,384.753L1015.75,384.753L1015.75,409.953L1022.23,409.953ZM1037.64,449.409L1025.98,426.945L1022.67,427.233L1015.75,427.233L1015.75,446.673C1015.75,447.729 1015.35,448.689 1014.53,449.553C1013.71,450.417 1012.78,450.849 1011.72,450.849L1000.49,450.849C999.433,450.849 998.497,450.417 997.681,449.553C996.865,448.689 996.457,447.729 996.457,446.673L996.457,371.505C996.457,370.449 996.889,369.513 997.753,368.697C998.617,367.881 999.577,367.473 1000.63,367.473L1022.67,367.473C1023.14,367.473 1023.77,367.497 1024.54,367.545C1025.31,367.593 1026.82,367.809 1029.07,368.193C1031.33,368.577 1033.46,369.105 1035.48,369.777C1037.5,370.449 1039.75,371.553 1042.25,373.089C1044.75,374.625 1046.88,376.401 1048.66,378.417C1050.43,380.433 1051.94,383.097 1053.19,386.409C1054.44,389.721 1055.07,393.393 1055.07,397.425C1055.07,407.505 1051.51,415.137 1044.41,420.321L1058.09,446.817C1058.09,447.969 1057.73,448.929 1057.01,449.697C1056.29,450.465 1055.35,450.849 1054.2,450.849L1040.81,450.849C1039.46,450.849 1038.41,450.369 1037.64,449.409Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,4,0)">
<path d="M947.978,337.608L901.718,337.608C900.662,337.608 899.702,337.2 898.838,336.384C897.974,335.568 897.542,334.632 897.542,333.576L897.542,322.344C897.542,321.288 897.974,320.352 898.838,319.536C899.702,318.72 900.662,318.312 901.718,318.312L967.817,318.312C971.199,318.312 974.203,319.676 976.827,322.403C979.554,325.027 980.918,328.031 980.918,331.413L980.918,397.512C980.918,398.568 980.51,399.528 979.694,400.392C978.878,401.256 977.942,401.688 976.886,401.688L965.654,401.688C964.598,401.688 963.662,401.256 962.846,400.392C962.03,399.528 961.622,398.568 961.622,397.512L961.622,351.252L912.36,400.515C911.613,401.262 910.646,401.652 909.458,401.686C908.27,401.72 907.302,401.363 906.556,400.617L898.614,392.674C897.867,391.928 897.51,390.96 897.544,389.772C897.578,388.585 897.969,387.617 898.715,386.87L947.978,337.608Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

View File

@ -0,0 +1,41 @@
<?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 503 301" preserveAspectRatio="xMinYMin meet" version="1.1" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-91.7237,-144)">
<g transform="matrix(1,0,0,1,-186.893,-46.2326)">
<g transform="matrix(1,0,0,1,0,-68.8)">
<path d="M282.793,367.473L335.641,367.473C336.697,367.473 337.657,367.881 338.521,368.697C339.385,369.513 339.817,370.449 339.817,371.505L339.817,381.153C339.817,382.209 339.385,383.145 338.521,383.961C337.657,384.777 336.697,385.185 335.641,385.185L318.937,385.185L318.937,446.673C318.937,447.729 318.529,448.689 317.713,449.553C316.897,450.417 315.961,450.849 314.905,450.849L303.529,450.849C302.473,450.849 301.537,450.417 300.721,449.553C299.905,448.689 299.497,447.729 299.497,446.673L299.497,385.185L282.793,385.185C281.737,385.185 280.777,384.777 279.913,383.961C279.049,383.145 278.617,382.209 278.617,381.153L278.617,371.505C278.617,370.449 279.049,369.513 279.913,368.697C280.777,367.881 281.737,367.473 282.793,367.473Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,0,-68.8)">
<path d="M354.937,378.273C361.657,370.113 371.017,366.033 383.017,366.033C395.017,366.033 404.353,370.113 411.025,378.273C417.697,386.433 421.033,396.753 421.033,409.233C421.033,421.617 417.673,431.913 410.953,440.121C404.233,448.329 394.921,452.433 383.017,452.433C371.113,452.433 361.801,448.329 355.081,440.121C348.361,431.913 345.001,421.617 345.001,409.233C345.001,396.753 348.313,386.433 354.937,378.273ZM365.017,409.233C365.017,426.225 371.017,434.721 383.017,434.721C395.017,434.721 401.017,426.225 401.017,409.233C401.017,392.241 395.017,383.745 383.017,383.745C371.017,383.745 365.017,392.241 365.017,409.233Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,0,-68.8)">
<path d="M477.049,439.761L475.321,439.761C474.265,439.761 473.545,439.377 473.161,438.609L455.593,406.497L455.593,446.673C455.593,447.729 455.185,448.689 454.369,449.553C453.553,450.417 452.617,450.849 451.561,450.849L440.329,450.849C439.273,450.849 438.337,450.417 437.521,449.553C436.705,448.689 436.297,447.729 436.297,446.673L436.297,371.649C436.297,370.593 436.705,369.633 437.521,368.769C438.337,367.905 439.273,367.473 440.329,367.473L453.289,367.473C454.825,367.473 455.881,368.049 456.457,369.201L476.041,404.337L495.625,369.201C496.201,368.049 497.257,367.473 498.793,367.473L511.753,367.473C512.809,367.473 513.745,367.905 514.561,368.769C515.377,369.633 515.785,370.593 515.785,371.649L515.785,446.673C515.785,447.729 515.377,448.689 514.561,449.553C513.745,450.417 512.809,450.849 511.753,450.849L500.521,450.849C499.465,450.849 498.529,450.417 497.713,449.553C496.897,448.689 496.489,447.729 496.489,446.673L496.489,406.497L478.921,438.609C478.537,439.377 477.913,439.761 477.049,439.761Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-277.056,38)">
<path d="M574.969,433.425L583.177,433.425C589.801,433.425 593.113,430.641 593.113,425.073C593.113,419.697 589.801,417.009 583.177,417.009L574.969,417.009L574.969,433.425ZM583.033,384.897L574.969,384.897L574.969,399.297L583.033,399.297C583.609,399.297 584.305,399.249 585.121,399.153C585.937,399.057 587.041,398.409 588.433,397.209C589.825,396.009 590.521,394.305 590.521,392.097C590.521,389.889 589.897,388.185 588.649,386.985C587.401,385.785 586.153,385.137 584.905,385.041L583.033,384.897ZM559.705,367.473L583.177,367.473C583.945,367.473 584.905,367.521 586.057,367.617C587.209,367.713 589.321,368.121 592.393,368.841C595.465,369.561 598.177,370.593 600.529,371.937C602.881,373.281 605.017,375.369 606.937,378.201C608.857,381.033 609.817,384.417 609.817,388.353C609.817,391.329 609.409,393.993 608.593,396.345C607.777,398.697 606.817,400.521 605.713,401.817C604.609,403.113 603.433,404.217 602.185,405.129C600.937,406.041 599.929,406.641 599.161,406.929C598.393,407.217 597.913,407.361 597.721,407.361C598.969,407.745 600.145,408.225 601.249,408.801C602.353,409.377 604.009,410.385 606.217,411.825C608.425,413.265 610.177,415.257 611.473,417.801C612.769,420.345 613.417,423.249 613.417,426.513C613.417,442.737 602.185,450.849 579.721,450.849L559.705,450.849C558.649,450.849 557.713,450.417 556.897,449.553C556.081,448.689 555.673,447.729 555.673,446.673L555.673,371.649C555.673,370.593 556.081,369.633 556.897,368.769C557.713,367.905 558.649,367.473 559.705,367.473Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-277.056,38)">
<path d="M657.337,452.433C656.953,452.433 656.377,452.409 655.609,452.361C654.841,452.313 653.377,452.097 651.217,451.713C649.057,451.329 647.017,450.753 645.097,449.985C643.177,449.217 641.017,447.993 638.617,446.313C636.217,444.633 634.153,442.665 632.425,440.409C630.697,438.153 629.257,435.153 628.105,431.409C626.953,427.665 626.377,423.489 626.377,418.881L626.377,371.649C626.377,370.593 626.785,369.633 627.601,368.769C628.417,367.905 629.353,367.473 630.409,367.473L641.785,367.473C642.841,367.473 643.777,367.905 644.593,368.769C645.409,369.633 645.817,370.593 645.817,371.649L645.817,421.905C645.817,422.289 645.841,422.769 645.889,423.345C645.937,423.921 646.177,424.929 646.609,426.369C647.041,427.809 647.617,429.105 648.337,430.257C649.057,431.409 650.209,432.441 651.793,433.353C653.377,434.265 655.225,434.721 657.337,434.721C660.697,434.721 663.337,433.665 665.257,431.553C667.177,429.441 668.233,427.329 668.425,425.217L668.857,421.905L668.857,371.649C668.857,370.593 669.265,369.633 670.081,368.769C670.897,367.905 671.833,367.473 672.889,367.473L684.265,367.473C685.321,367.473 686.257,367.905 687.073,368.769C687.889,369.633 688.297,370.593 688.297,371.649L688.297,418.881C688.297,423.489 687.721,427.665 686.569,431.409C685.417,435.153 683.929,438.153 682.105,440.409C680.281,442.665 678.265,444.633 676.057,446.313C673.849,447.993 671.665,449.217 669.505,449.985C667.345,450.753 665.353,451.329 663.529,451.713C661.705,452.097 660.217,452.289 659.065,452.289L657.337,452.433Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-277.056,38)">
<path d="M704.713,367.473L757.561,367.473C758.617,367.473 759.577,367.881 760.441,368.697C761.305,369.513 761.737,370.449 761.737,371.505L761.737,381.153C761.737,382.209 761.305,383.145 760.441,383.961C759.577,384.777 758.617,385.185 757.561,385.185L740.857,385.185L740.857,446.673C740.857,447.729 740.449,448.689 739.633,449.553C738.817,450.417 737.881,450.849 736.825,450.849L725.449,450.849C724.393,450.849 723.457,450.417 722.641,449.553C721.825,448.689 721.417,447.729 721.417,446.673L721.417,385.185L704.713,385.185C703.657,385.185 702.697,384.777 701.833,383.961C700.969,383.145 700.537,382.209 700.537,381.153L700.537,371.505C700.537,370.449 700.969,369.513 701.833,368.697C702.697,367.881 703.657,367.473 704.713,367.473Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-277.056,38)">
<path d="M805.081,452.433C793.081,452.433 783.601,448.329 776.641,440.121C769.681,431.913 766.201,421.617 766.201,409.233C766.201,396.849 769.681,386.553 776.641,378.345C783.601,370.137 793.081,366.033 805.081,366.033C825.721,366.033 838.249,375.633 842.665,394.833C842.473,395.889 841.969,396.801 841.153,397.569C840.337,398.337 839.401,398.721 838.345,398.721L825.961,398.721C824.521,398.721 823.417,398.001 822.649,396.561C820.345,388.017 814.489,383.745 805.081,383.745C798.649,383.745 793.897,386.097 790.825,390.801C787.753,395.505 786.217,401.649 786.217,409.233C786.217,416.721 787.753,422.841 790.825,427.593C793.897,432.345 798.649,434.721 805.081,434.721C814.489,434.721 820.345,430.449 822.649,421.905C823.417,420.465 824.521,419.745 825.961,419.745L838.345,419.745C839.401,419.745 840.337,420.129 841.153,420.897C841.969,421.665 842.473,422.577 842.665,423.633C838.249,442.833 825.721,452.433 805.081,452.433Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-277.056,38)">
<path d="M896.521,446.673L896.521,421.905L874.633,421.905L874.633,446.673C874.633,447.729 874.225,448.689 873.409,449.553C872.593,450.417 871.657,450.849 870.601,450.849L859.369,450.849C858.313,450.849 857.377,450.417 856.561,449.553C855.745,448.689 855.337,447.729 855.337,446.673L855.337,371.649C855.337,370.593 855.745,369.633 856.561,368.769C857.377,367.905 858.313,367.473 859.369,367.473L870.601,367.473C871.657,367.473 872.593,367.905 873.409,368.769C874.225,369.633 874.633,370.593 874.633,371.649L874.633,404.337L896.521,404.337L896.521,371.649C896.521,370.593 896.929,369.633 897.745,368.769C898.561,367.905 899.497,367.473 900.553,367.473L911.785,367.473C912.841,367.473 913.777,367.905 914.593,368.769C915.409,369.633 915.817,370.593 915.817,371.649L915.817,446.673C915.817,447.729 915.409,448.689 914.593,449.553C913.777,450.417 912.841,450.849 911.785,450.849L900.553,450.849C899.497,450.849 898.561,450.417 897.745,449.553C896.929,448.689 896.521,447.729 896.521,446.673Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-277.056,38)">
<path d="M932.521,446.817L932.521,371.505C932.521,370.449 932.953,369.513 933.817,368.697C934.681,367.881 935.641,367.473 936.697,367.473L977.881,367.473C978.937,367.473 979.897,367.881 980.761,368.697C981.625,369.513 982.057,370.449 982.057,371.505L982.057,381.153C982.057,382.209 981.625,383.145 980.761,383.961C979.897,384.777 978.937,385.185 977.881,385.185L951.817,385.185L951.817,401.025L974.857,401.025C975.913,401.025 976.873,401.433 977.737,402.249C978.601,403.065 979.033,404.001 979.033,405.057L979.033,414.705C979.033,415.761 978.601,416.697 977.737,417.513C976.873,418.329 975.913,418.737 974.857,418.737L951.817,418.737L951.817,433.137L977.881,433.137C978.937,433.137 979.897,433.545 980.761,434.361C981.625,435.177 982.057,436.113 982.057,437.169L982.057,446.817C982.057,447.873 981.625,448.809 980.761,449.625C979.897,450.441 978.937,450.849 977.881,450.849L936.697,450.849C935.641,450.849 934.681,450.441 933.817,449.625C932.953,448.809 932.521,447.873 932.521,446.817Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-277.056,38)">
<path d="M1022.23,409.953C1025.79,409.953 1028.66,408.945 1030.87,406.929C1033.08,404.913 1034.18,401.745 1034.18,397.425C1034.18,393.105 1033.08,389.913 1030.87,387.849C1028.66,385.785 1025.79,384.753 1022.23,384.753L1015.75,384.753L1015.75,409.953L1022.23,409.953ZM1037.64,449.409L1025.98,426.945L1022.67,427.233L1015.75,427.233L1015.75,446.673C1015.75,447.729 1015.35,448.689 1014.53,449.553C1013.71,450.417 1012.78,450.849 1011.72,450.849L1000.49,450.849C999.433,450.849 998.497,450.417 997.681,449.553C996.865,448.689 996.457,447.729 996.457,446.673L996.457,371.505C996.457,370.449 996.889,369.513 997.753,368.697C998.617,367.881 999.577,367.473 1000.63,367.473L1022.67,367.473C1023.14,367.473 1023.77,367.497 1024.54,367.545C1025.31,367.593 1026.82,367.809 1029.07,368.193C1031.33,368.577 1033.46,369.105 1035.48,369.777C1037.5,370.449 1039.75,371.553 1042.25,373.089C1044.75,374.625 1046.88,376.401 1048.66,378.417C1050.43,380.433 1051.94,383.097 1053.19,386.409C1054.44,389.721 1055.07,393.393 1055.07,397.425C1055.07,407.505 1051.51,415.137 1044.41,420.321L1058.09,446.817C1058.09,447.969 1057.73,448.929 1057.01,449.697C1056.29,450.465 1055.35,450.849 1054.2,450.849L1040.81,450.849C1039.46,450.849 1038.41,450.369 1037.64,449.409Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
<g transform="matrix(1,0,0,1,-805.818,-174.312)">
<path d="M947.978,337.608L901.718,337.608C900.662,337.608 899.702,337.2 898.838,336.384C897.974,335.568 897.542,334.632 897.542,333.576L897.542,322.344C897.542,321.288 897.974,320.352 898.838,319.536C899.702,318.72 900.662,318.312 901.718,318.312L967.817,318.312C971.199,318.312 974.203,319.676 976.827,322.403C979.554,325.027 980.918,328.031 980.918,331.413L980.918,397.512C980.918,398.568 980.51,399.528 979.694,400.392C978.878,401.256 977.942,401.688 976.886,401.688L965.654,401.688C964.598,401.688 963.662,401.256 962.846,400.392C962.03,399.528 961.622,398.568 961.622,397.512L961.622,351.252L912.36,400.515C911.613,401.262 910.646,401.652 909.458,401.686C908.27,401.72 907.302,401.363 906.556,400.617L898.614,392.674C897.867,391.928 897.51,390.96 897.544,389.772C897.578,388.585 897.969,387.617 898.715,386.87L947.978,337.608Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB