Commit Major Implementation now that gitea is setup
This commit is contained in:
188
src/components/ParticlesBackground.tsx
Normal file
188
src/components/ParticlesBackground.tsx
Normal 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;
|
||||
187
src/components/VisitedMap.tsx
Normal file
187
src/components/VisitedMap.tsx
Normal 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);
|
||||
86
src/components/animatedTyping.tsx
Normal file
86
src/components/animatedTyping.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/contentCard.tsx
Normal file
13
src/components/contentCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/floatingHeader.tsx
Normal file
43
src/components/floatingHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/components/fullPageImage.tsx
Normal file
23
src/components/fullPageImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user