Commit Major Implementation now that gitea is setup

This commit is contained in:
2025-12-30 16:58:18 -06:00
parent 23589efb3e
commit 0d7eb30ffd
19 changed files with 1953 additions and 31 deletions

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useRef } from 'react';
const ParticlesBackground: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number;
let particles: Particle[] = [];
class Particle {
x: number;
y: number;
size: number;
speedX: number;
speedY: number;
color: string;
constructor() {
this.x = Math.random() * (canvas?.width || window.innerWidth);
this.y = Math.random() * (canvas?.height || window.innerHeight);
this.size = Math.random() * 4 + 2;
this.speedX = Math.random() * 1 - 0.5;
this.speedY = Math.random() * 1 - 0.5;
this.color = `rgba(255, 255, 255, ${Math.random() * 0.3 + 0.1})`;
}
update() {
// update position
this.x += this.speedX;
this.y += this.speedY;
// update speedX
let speedXRng = Math.random();
if (speedXRng > 0.75) {
this.speedX += Math.random() * 0.1;
} else if (speedXRng < 0.25) {
this.speedX -= Math.random() * 0.1;
}
if (this.speedX > 1) {
this.speedX = 1;
} else if (this.speedX < -1) {
this.speedX = -1;
}
// update speedY
let speedYRng = Math.random();
if (speedYRng > 0.75) {
this.speedY += Math.random() * 0.1;
} else if (speedYRng < 0.25) {
this.speedY -= Math.random() * 0.1;
}
if (this.speedY > 1) {
this.speedY = 1;
} else if (this.speedY < -1) {
this.speedY = -1;
}
//size
let sizeRng = Math.random();
if (sizeRng > 0.75) {
this.size += Math.random() / 2;
} else if (sizeRng < 0.25) {
this.size -= Math.random() / 2;
}
if (this.size < 2) {
this.size = 2;
} else if (this.size > 6) {
this.size = 6;
}
if (canvas) {
if (this.x > canvas.width) this.x = 0;
if (this.x < 0) this.x = canvas.width;
if (this.y > canvas.height) this.y = 0;
if (this.y < 0) this.y = canvas.height;
}
}
draw() {
if (!ctx) return;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
const init = () => {
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// Reduce particle count on mobile/portrait screens
const isPortrait = canvas.height > canvas.width;
const particleCount = isPortrait ? 90 : 180;
particles = [];
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
};
const animate = () => {
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw a subtle gradient background for the canvas itself if needed, or keep transparent
// For now, let's keep it transparent so we can style the container behind it if we want,
// OR we can make this the definitive background.
// Let's add a deep gradient here to match the user's previous aesthetic but cooler.
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, '#0f0c29');
gradient.addColorStop(0.5, '#302b63');
gradient.addColorStop(1, '#24243e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
particles.forEach((particle) => {
particle.update();
particle.draw();
});
// Connect particles with lines if close
connect();
animationFrameId = requestAnimationFrame(animate);
};
const connect = () => {
if (!ctx) return;
for (let a = 0; a < particles.length; a++) {
for (let b = a; b < particles.length; b++) {
const dx = particles[a].x - particles[b].x;
const dy = particles[a].y - particles[b].y;
const distance = Math.sqrt(dx * dx + dy * dy);
const sizeAverage = (particles[a].size + particles[b].size) / 2;
// Scale from 100 to 300 based on size average (2 to 6)
const distanceThreshold = 100 + (sizeAverage - 2) * 50;
if (distance < distanceThreshold) {
ctx.strokeStyle = `rgba(255, 255, 255, ${(distanceThreshold * (2 / 3) * .001) - distance / 1000})`;
ctx.lineWidth = sizeAverage;
ctx.beginPath();
ctx.moveTo(particles[a].x, particles[a].y);
ctx.lineTo(particles[b].x, particles[b].y);
ctx.stroke();
}
}
}
}
init();
animate();
const handleResize = () => {
init();
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: -1, // Behind everything
}}
/>
);
};
export default ParticlesBackground;

View File

@@ -0,0 +1,187 @@
import React, { useMemo } from 'react';
import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api';
const containerStyle = {
width: '100%',
height: '400px',
borderRadius: '20px',
border: '1px solid rgba(255, 255, 255, 0.2)'
};
const defaultCenter = {
lat: 52.8564,
lng: -104.6100
};
// Coordinate lookup table
const PLACE_COORDINATES: Record<string, { lat: number; lng: number }> = {
"Melfort": { lat: 52.8564, lng: -104.6100 },
"Star City": { lat: 52.9333, lng: -104.3333 },
"Saskatoon": { lat: 52.1332, lng: -106.6700 },
"Regina": { lat: 50.4452, lng: -104.6189 },
"Winnipeg": { lat: 49.8951, lng: -97.1384 },
"Vancouver": { lat: 49.2827, lng: -123.1207 },
"Edmonton": { lat: 53.5461, lng: -113.4938 },
"Calgary": { lat: 51.0447, lng: -114.0719 },
"Kyiv": { lat: 50.4501, lng: 30.5234 },
"Torhovytsy": { lat: 50.5560, lng: 25.3989 },
"Lviv": { lat: 49.8397, lng: 24.0297 },
"Puerto Vallarta": { lat: 20.6534, lng: -105.2253 },
"Havana": { lat: 23.1136, lng: -82.3666 },
"Cancun": { lat: 21.1619, lng: -86.8515 },
"Waikiki Beach": { lat: 21.2769, lng: -157.8274 },
};
interface VisitedMapProps {
places: string[];
}
function VisitedMap({ places }: VisitedMapProps) {
const { isLoaded } = useJsApiLoader({
id: 'google-map-script',
googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_API_KEY || ""
});
const markers = useMemo(() => {
return places
.map(place => ({
name: place,
pos: PLACE_COORDINATES[place]
}))
.filter(item => item.pos !== undefined);
}, [places]);
const mapCenter = useMemo(() => {
if (markers.length > 0) {
return markers[0].pos;
}
return defaultCenter;
}, [markers]);
if (!isLoaded) {
return <div style={{ color: 'white', textAlign: 'center' }}>Loading Map...</div>;
}
if (!process.env.REACT_APP_GOOGLE_MAPS_API_KEY) {
return (
<div style={containerStyle}>
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.5)", color: "white", borderRadius: "20px" }}>
Google Maps API Key Missing
</div>
</div>
)
}
return (
<GoogleMap
mapContainerStyle={containerStyle}
center={mapCenter}
zoom={8}
options={{
disableDefaultUI: false,
zoomControl: true,
streetViewControl: false,
mapTypeControl: false,
styles: [
{
"elementType": "geometry",
"stylers": [{ "color": "#242f3e" }]
},
{
"elementType": "labels.text.stroke",
"stylers": [{ "color": "#242f3e" }]
},
{
"elementType": "labels.text.fill",
"stylers": [{ "color": "#746855" }]
},
{
"featureType": "administrative.locality",
"elementType": "labels.text.fill",
"stylers": [{ "color": "#d59563" }]
},
{
"featureType": "poi",
"elementType": "labels.text.fill",
"stylers": [{ "color": "#d59563" }]
},
{
"featureType": "poi.park",
"elementType": "geometry",
"stylers": [{ "color": "#263c3f" }]
},
{
"featureType": "poi.park",
"elementType": "labels.text.fill",
"stylers": [{ "color": "#6b9a76" }]
},
{
"featureType": "road",
"elementType": "geometry",
"stylers": [{ "color": "#38414e" }]
},
{
"featureType": "road",
"elementType": "geometry.stroke",
"stylers": [{ "color": "#212a37" }]
},
{
"featureType": "road",
"elementType": "labels.text.fill",
"stylers": [{ "color": "#9ca5b3" }]
},
{
"featureType": "road.highway",
"elementType": "geometry",
"stylers": [{ "color": "#746855" }]
},
{
"featureType": "road.highway",
"elementType": "geometry.stroke",
"stylers": [{ "color": "#1f2835" }]
},
{
"featureType": "road.highway",
"elementType": "labels.text.fill",
"stylers": [{ "color": "#f3d19c" }]
},
{
"featureType": "transit",
"elementType": "geometry",
"stylers": [{ "color": "#2f3948" }]
},
{
"featureType": "transit.station",
"elementType": "labels.text.fill",
"stylers": [{ "color": "#d59563" }]
},
{
"featureType": "water",
"elementType": "geometry",
"stylers": [{ "color": "#17263c" }]
},
{
"featureType": "water",
"elementType": "labels.text.fill",
"stylers": [{ "color": "#515c6d" }]
},
{
"featureType": "water",
"elementType": "labels.text.stroke",
"stylers": [{ "color": "#17263c" }]
}
]
}}
>
{markers.map((marker, index) => (
<Marker
key={index}
position={marker.pos}
title={marker.name}
/>
))}
</GoogleMap>
);
}
export default React.memo(VisitedMap);

View File

@@ -0,0 +1,86 @@
import { motion } from "framer-motion";
import { useState, useEffect } from "react";
interface TypingTextProps {
text: string;
msPerChar?: number; // milliseconds per character
delayMs?: number; // milliseconds to delay before typing starts
textAlign?: "left" | "center" | "right"; // text alignment
}
export default function TypingText({ text, msPerChar: speed = 100, delayMs = 0, textAlign = "left" }: TypingTextProps) {
const [displayedText, setDisplayedText] = useState<string>("");
const [isComplete, setIsComplete] = useState(false);
useEffect(() => {
setDisplayedText("");
setIsComplete(false);
let index = 0;
// Set up initial delay timeout
const delayTimeout = setTimeout(() => {
const interval = setInterval(() => {
if (index < text.length) {
setDisplayedText(text.substring(0, index + 1));
index++;
if (index === text.length) {
setIsComplete(true);
clearInterval(interval);
}
}
}, speed);
return () => clearInterval(interval);
}, delayMs);
return () => clearTimeout(delayTimeout);
}, [text, speed, delayMs]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
style={{
whiteSpace: "pre-wrap",
overflowWrap: "break-word",
wordWrap: "break-word",
width: "100%",
position: "relative",
textAlign: textAlign
}}
>
{/* Transparent placeholder - reserves space without causing layout shift */}
<div style={{ color: "transparent", pointerEvents: "none" }}>
{text}
</div>
{/* Positioned absolutely over placeholder to show revealed text */}
<div style={{
position: "absolute",
top: 0,
left: textAlign === "right" ? "auto" : 0,
right: textAlign === "right" ? 0 : "auto",
width: textAlign === "center" ? "100%" : "auto",
textAlign: textAlign
}}>
{displayedText}
{/* blinking cursor - shows while typing or delaying, hides when complete */}
{!isComplete && (
<motion.span
animate={{ opacity: [0, 1, 0] }}
transition={{ repeat: Infinity, duration: 1 }}
style={{
display: "inline-block",
width: "4px",
height: "1em",
backgroundColor: "currentColor",
marginLeft: "4px",
verticalAlign: "middle"
}}
/>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,13 @@
interface ContentCardProps {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
export default function ContentCard({ children, className = "", style }: ContentCardProps) {
return (
<div className={`contentCard ${className}`} style={style}>
{children}
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Link, useLocation } from "react-router-dom";
export default function FloatingHeader() {
const location = useLocation();
return (
<header className="floating-header">
<nav className="header-nav">
<Link
to="/"
className={`nav-link ${location.pathname === "/" ? "active" : ""}`}
>
Home
</Link>
<Link
to="/work-experience"
className={`nav-link ${location.pathname === "/work-experience" ? "active" : ""}`}
>
Work Experience
</Link>
<Link
to="/about"
className={`nav-link ${location.pathname === "/about" ? "active" : ""}`}
>
About Me
</Link>
<Link
to="/projects"
className={`nav-link ${location.pathname === "/projects" ? "active" : ""}`}
>
Projects
</Link>
<Link
to="/contact"
className={`nav-link ${location.pathname === "/contact" ? "active" : ""}`}
>
Contact
</Link>
</nav>
</header>
);
}

View File

@@ -0,0 +1,23 @@
interface FullPageImageProps {
src: string;
alt: string;
credit?: string;
isFixed?: boolean;
}
export default function FullPageImage({ src, alt, credit, isFixed = false }: FullPageImageProps) {
const containerStyle = isFixed
? {height: "100vh", overflow: "hidden", width: "100vw", position: "fixed" as const, top: 0, left: 0, zIndex: 0}
: {height: "100vh", overflow: "hidden", width: "100vw", position: "relative" as const, left: "50%", right: "50%", marginLeft: "-50vw", marginRight: "-50vw"};
return (
<div style={containerStyle}>
<img src={src} alt={alt} style={{width: "100%", height: "100%", objectFit: "cover"}} />
{credit && (
<div style={{position: "absolute", bottom: "20px", right: "20px", color: "white", fontFamily: "roboto, sans-serif", fontSize: "14px", backgroundColor: "rgba(0, 0, 0, 0.5)", padding: "8px 12px", borderRadius: "4px"}}>
Credit: {credit}
</div>
)}
</div>
);
}