Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
36c6484c81 Bump brace-expansion from 2.0.1 to 2.0.2 in /src
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 2.0.1 to 2.0.2.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.1...v2.0.2)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 2.0.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-11 17:20:17 +00:00
51 changed files with 3436 additions and 5170 deletions

View File

@@ -8,36 +8,36 @@
"preview": "astro preview"
},
"devDependencies": {
"@astrojs/react": "^5.0.2",
"@astrojs/react": "^4.4.0",
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"astro": "^6.1.2",
"tailwindcss": "^3.4.19"
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"astro": "^5.14.1",
"tailwindcss": "^3.4.17"
},
"dependencies": {
"@astrojs/mdx": "^5.0.3",
"@astrojs/node": "^10.0.4",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/mdx": "^4.3.6",
"@astrojs/node": "^9.4.4",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.6.0",
"@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2",
"@rehype-pretty/transformers": "^0.13.2",
"arctic": "^3.7.0",
"arctic": "^3.6.0",
"lucide-react": "^0.468.0",
"marked": "^15.0.12",
"marked": "^15.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.6.0",
"react-icons": "^5.5.0",
"react-responsive": "^10.0.1",
"reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.3",
"rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0",
"schema-dts": "^1.1.5",
"shiki": "^3.23.0",
"typewriter-effect": "^2.22.0",
"unist-util-visit": "^5.1.0"
"shiki": "^3.12.2",
"typewriter-effect": "^2.21.0",
"unist-util-visit": "^5.0.0"
}
}

3168
src/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
import React from 'react';
import { Code2, BookOpen, RocketIcon, Compass } from 'lucide-react';
export default function CurrentFocus() {
const recentProjects = [
@@ -59,46 +7,42 @@ export default function CurrentFocus() {
title: "Darkbox",
description: "My gruvbox theme, with a pure black background",
href: "/projects/darkbox",
tech: ["Neovim", "Lua"],
tech: ["Neovim", "Lua"]
},
{
title: "Revive Auto Parts",
description: "A car parts listing site built for a client",
href: "/projects/reviveauto",
tech: ["Tanstack", "React Query", "Fastapi"],
tech: ["Tanstack", "React Query", "Fastapi"]
},
{
title: "Fhccenter",
description: "Website made for a private school",
href: "/projects/fhccenter",
tech: ["Nextjs", "Typescript", "Prisma"],
},
tech: ["Nextjs", "Typescript", "Prisma"]
}
];
return (
<div className="flex justify-center items-center w-full">
<div className="w-full max-w-6xl p-4 sm:px-6 py-6 sm:py-8">
<AnimateIn>
<h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12">
Current Focus
</h2>
</AnimateIn>
{/* Recent Projects Section */}
<div className="mb-8 sm:mb-16">
<AnimateIn delay={100}>
<div className="flex items-center justify-center gap-2 mb-6">
<Code2 className="text-yellow-bright" size={24} />
<h3 className="text-xl font-bold text-foreground/90">Recent Projects</h3>
</div>
</AnimateIn>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-5xl mx-auto">
{recentProjects.map((project, i) => (
<AnimateIn key={project.title} delay={200 + i * 100}>
{recentProjects.map((project) => (
<a
href={project.href}
className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
transition-all duration-300 group bg-background/50 h-full"
key={project.title}
className="p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
transition-all duration-300 group bg-background/50"
>
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
{project.title}
@@ -112,15 +56,14 @@ export default function CurrentFocus() {
))}
</div>
</a>
</AnimateIn>
))}
</div>
</div>
{/* Current Learning & Interests */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
<AnimateIn delay={100}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
{/* What I'm Learning */}
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50">
<div className="flex items-center justify-center gap-2">
<BookOpen className="text-green-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Currently Learning</h3>
@@ -140,10 +83,9 @@ export default function CurrentFocus() {
</li>
</ul>
</div>
</AnimateIn>
<AnimateIn delay={200}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
{/* Project Interests */}
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50">
<div className="flex items-center justify-center gap-2">
<RocketIcon className="text-blue-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Project Interests</h3>
@@ -163,10 +105,9 @@ export default function CurrentFocus() {
</li>
</ul>
</div>
</AnimateIn>
<AnimateIn delay={300}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
{/* Areas to Explore */}
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50">
<div className="flex items-center justify-center gap-2">
<Compass className="text-purple-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Want to Explore</h3>
@@ -186,7 +127,6 @@ export default function CurrentFocus() {
</li>
</ul>
</div>
</AnimateIn>
</div>
</div>
</div>

View File

@@ -1,89 +1,47 @@
import React, { useEffect, useRef, useState } from "react";
import React from "react";
import { ChevronDownIcon } from "@/components/icons";
export default function Intro() {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setVisible(true);
return;
}
if (inView) {
// Fresh navigation — animate in
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const scrollToNext = () => {
const nextSection = document.querySelector("section")?.nextElementSibling;
if (nextSection) {
const offset = (nextSection as HTMLElement).offsetTop - (window.innerHeight - (nextSection as HTMLElement).offsetHeight) / 2;
window.scrollTo({ top: offset, behavior: "smooth" });
const offset = nextSection.offsetTop - (window.innerHeight - nextSection.offsetHeight) / 2; // Center the section
window.scrollTo({
top: offset,
behavior: "smooth"
});
}
};
const anim = (delay: number) =>
({
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
transition: `all 0.7s ease-out ${delay}ms`,
}) as React.CSSProperties;
return (
<div ref={ref} className="w-full max-w-4xl px-4">
<div className="w-full max-w-4xl px-4">
<div className="space-y-8 md:space-y-12">
<div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16">
<div
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"
style={anim(0)}
>
<div className="w-32 h-32 sm:w-48 sm:h-48 shrink-0">
<img
src="/me.jpeg"
alt="Timothy Pidashev"
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300"
/>
</div>
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
<div className="text-center sm:text-left space-y-4 sm:space-y-6">
<h2 className="text-xl sm:text-5xl font-bold text-yellow-bright">
Timothy Pidashev
</h2>
<div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2">
<span className="text-blue">Software Systems Engineer</span>
</p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2">
<span className="text-green">Open Source Enthusiast</span>
</p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2">
<span className="text-yellow">Coffee Connoisseur</span>
</p>
</div>
</div>
</div>
<div className="space-y-8" style={anim(750)}>
<div className="space-y-8">
<p className="text-foreground/80 text-center text-base sm:text-2xl italic max-w-3xl mx-auto font-medium">
"Turning coffee into code" isn't just a clever phrase
<span className="text-aqua-bright"> it's how I approach each project:</span>
@@ -91,7 +49,7 @@ export default function Intro() {
<span className="text-blue-bright"> with attention to detail,</span>
<span className="text-green-bright"> and a refined process.</span>
</p>
<div className="flex justify-center" style={anim(900)}>
<div className="flex justify-center">
<button
onClick={scrollToNext}
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"

View File

@@ -1,115 +1,64 @@
import React, { useEffect, useRef, useState } from "react";
import { Cross, Fish, Mountain, Book } from "lucide-react";
import React from 'react';
import { Fish, Mountain, Book, Car } from 'lucide-react';
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0) scale(1)" : "translateY(20px) scale(0.97)",
}}
>
{children}
</div>
);
}
const interests = [
{
icon: <Cross className="text-red-bright" size={20} />,
title: "Faith",
description: "My walk with Jesus is the foundation of everything I do, guiding my purpose and perspective",
},
export default function OutsideCoding() {
const interests = [
{
icon: <Fish className="text-blue-bright" size={20} />,
title: "Fishing",
description: "Finding peace and adventure on the water, always looking for the next great fishing spot",
description: "Finding peace and adventure on the water, always looking for the next great fishing spot"
},
{
icon: <Mountain className="text-green-bright" size={20} />,
title: "Hiking",
description: "Exploring trails with friends and seeking out scenic viewpoints in nature",
description: "Exploring trails with friends and seeking out scenic viewpoints in nature"
},
{
icon: <Book className="text-purple-bright" size={20} />,
title: "Reading",
description: "Deep diving into novels & technical books that expand my horizons & captivate my mind",
description: "Deep diving into novels & technical books that expand my horizons & captivate my mind"
},
];
{
icon: <Car className="text-yellow-bright" size={20} />,
title: "Project Cars",
description: "Working on automotive projects, modifying & restoring sporty sedans"
}
];
export default function OutsideCoding() {
return (
<div className="flex justify-center items-center w-full">
<div className="w-full max-w-4xl px-4 py-8">
<AnimateIn>
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8">
Outside of Programming
</h2>
</AnimateIn>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{interests.map((interest, i) => (
<AnimateIn key={interest.title} delay={100 + i * 100}>
{interests.map((interest) => (
<div
key={interest.title}
className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-all duration-300 bg-background/50 h-full"
hover:border-yellow-bright/50 transition-all duration-300 bg-background/50"
>
<div className="mb-3">{interest.icon}</div>
<h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3>
<p className="text-sm text-foreground/70">{interest.description}</p>
<div className="mb-3">
{interest.icon}
</div>
<h3 className="font-bold text-foreground/90 mb-2">
{interest.title}
</h3>
<p className="text-sm text-foreground/70">
{interest.description}
</p>
</div>
</AnimateIn>
))}
</div>
<AnimateIn delay={500}>
<p className="text-center text-foreground/80 mt-8 max-w-2xl mx-auto text-sm md:text-base italic">
When I'm not writing code, you'll find me
<span className="text-red-bright"> walking with Christ,</span>
<span className="text-blue-bright"> out on the water,</span>
<span className="text-green-bright"> hiking trails,</span>
<span className="text-purple-bright"> or reading books.</span>
<span className="text-purple-bright"> reading books,</span>
<span className="text-yellow-bright"> or modifying my current ride.</span>
</p>
</AnimateIn>
</div>
</div>
);

View File

@@ -1,19 +1,34 @@
import React from "react";
import React, { useState, useEffect } from 'react';
interface ActivityDay {
grand_total: { total_seconds: number };
date: string;
}
export const ActivityGrid = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
interface ActivityGridProps {
data: ActivityDay[];
}
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export const ActivityGrid = ({ data }: ActivityGridProps) => {
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('/api/wakatime');
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const result = await response.json();
setData(result.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const getIntensity = (hours: number) => {
fetchData();
}, []);
// Get intensity based on coding hours (0-4 for different shades)
const getIntensity = (hours) => {
if (hours === 0) return 0;
if (hours < 2) return 1;
if (hours < 4) return 2;
@@ -21,18 +36,20 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
return 4;
};
const getColorClass = (intensity: number) => {
if (intensity === 0) return "bg-foreground/5";
if (intensity === 1) return "bg-green-DEFAULT/30";
if (intensity === 2) return "bg-green-DEFAULT/60";
if (intensity === 3) return "bg-green-DEFAULT/80";
return "bg-green-bright";
// Get color class based on intensity
const getColorClass = (intensity) => {
if (intensity === 0) return 'bg-foreground/5';
if (intensity === 1) return 'bg-green-DEFAULT/30';
if (intensity === 2) return 'bg-green-DEFAULT/60';
if (intensity === 3) return 'bg-green-DEFAULT/80';
return 'bg-green-bright';
};
const weeks: ActivityDay[][] = [];
let currentWeek: ActivityDay[] = [];
// Group data by week
const weeks = [];
let currentWeek = [];
if (data && data.length > 0) {
if (data.length > 0) {
data.forEach((day, index) => {
currentWeek.push(day);
if (currentWeek.length === 7 || index === data.length - 1) {
@@ -42,8 +59,20 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
});
}
if (!data || data.length === 0) {
return null;
if (loading) {
return (
<div className="bg-background border border-foreground/10 rounded-lg p-6">
<div className="text-lg text-aqua-bright mb-6">Loading activity data...</div>
</div>
);
}
if (error) {
return (
<div className="bg-background border border-foreground/10 rounded-lg p-6">
<div className="text-lg text-red-bright mb-6">Error loading activity: {error}</div>
</div>
);
}
return (
@@ -54,7 +83,7 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
{/* Days labels */}
<div className="flex flex-col gap-2 pt-6 text-xs">
{days.map((day, i) => (
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ""}</div>
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ''}</div>
))}
</div>
{/* Grid */}
@@ -73,6 +102,7 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
group relative`}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
bg-background border border-foreground/10 rounded-md opacity-0
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs">
@@ -93,7 +123,7 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
<div
key={i}
className="w-3 mx-1"
style={{ marginLeft: i === 0 ? "0" : undefined }}
style={{ marginLeft: i === 0 ? '0' : undefined }}
>
{isFirstOfMonth && months[date.getMonth()]}
</div>
@@ -106,7 +136,10 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
<div className="flex items-center gap-2 mt-4 text-xs text-foreground/60">
<span>Less</span>
{[0, 1, 2, 3, 4].map((intensity) => (
<div key={intensity} className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`} />
<div
key={intensity}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`}
/>
))}
<span>More</span>
</div>

View File

@@ -1,115 +1,98 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
const Stats = () => {
const [stats, setStats] = useState<any>(null);
const [error, setError] = useState(false);
const [count, setCount] = useState(0);
const [isFinished, setIsFinished] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [skipAnim, setSkipAnim] = useState(false);
const hasAnimated = useRef(false);
const sectionRef = useRef<HTMLDivElement>(null);
// Fetch data on mount
useEffect(() => {
fetch("/api/wakatime/alltime")
.then((res) => {
if (!res.ok) throw new Error("API error");
return res.json();
})
.then((data) => setStats(data.data))
.catch(() => setError(true));
setIsVisible(true);
const fetchStats = async () => {
try {
const res = await fetch("/api/wakatime/alltime");
const data = await res.json();
setStats(data.data);
startCounting(data.data.total_seconds);
} catch (error) {
console.error("Error fetching stats:", error);
}
};
fetchStats();
}, []);
// Observe visibility — skip animation if already in view on mount
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkipAnim(true);
setIsVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setIsVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Start counter when both visible and data is ready
useEffect(() => {
if (!isVisible || !stats || hasAnimated.current) return;
hasAnimated.current = true;
const totalSeconds = stats.total_seconds;
const startCounting = (totalSeconds: number) => {
const duration = 2000;
const steps = 60;
let currentStep = 0;
const timer = setInterval(() => {
currentStep += 1;
if (currentStep >= steps) {
setCount(totalSeconds);
setIsFinished(true);
clearInterval(timer);
return;
}
const progress = 1 - Math.pow(1 - currentStep / steps, 4);
setCount(Math.floor(totalSeconds * progress));
}, duration / steps);
return () => clearInterval(timer);
}, [isVisible, stats]);
};
if (error) return null;
if (!stats) return <div ref={sectionRef} className="min-h-[50vh]" />;
if (!stats) return null;
const hours = Math.floor(count / 3600);
const formattedHours = hours.toLocaleString("en-US", {
minimumIntegerDigits: 4,
useGrouping: true,
useGrouping: true
});
return (
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={skipAnim ? "text-2xl opacity-80" : `text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={`
text-2xl opacity-0
${isVisible ? "animate-fade-in-first" : ""}
`}>
I've spent
</div>
<div className="relative">
<div className="text-8xl text-center relative z-10">
<span className="font-bold relative">
<span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
<span className={`
bg-gradient-text opacity-0
${isVisible ? "animate-fade-in-second" : ""}
`}>
{formattedHours}
</span>
</span>
<span className={skipAnim ? "text-4xl opacity-60 ml-4" : `text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}>
<span className={`
text-4xl opacity-0
${isVisible ? "animate-slide-in-hours" : ""}
`}>
hours
</span>
</div>
</div>
<div className="flex flex-col items-center gap-3 text-center">
<div className={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
<div className={`
text-xl opacity-0
${isVisible ? "animate-fade-in-third" : ""}
`}>
writing code & building apps
</div>
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}>
<div className={`
flex items-center gap-3 text-lg opacity-0
${isVisible ? "animate-fade-in-fourth" : ""}
`}>
<span>since</span>
<span className="text-green-bright font-bold">{stats.range.start_text}</span>
</div>
@@ -117,13 +100,14 @@ const Stats = () => {
<style jsx>{`
.bg-gradient-text {
background: linear-gradient(90deg,
rgb(var(--color-yellow-bright)),
rgb(var(--color-orange-bright)),
rgb(var(--color-orange)),
rgb(var(--color-yellow)),
rgb(var(--color-orange-bright)),
rgb(var(--color-yellow-bright))
background: linear-gradient(
90deg,
#fbbf24,
#f59e0b,
#d97706,
#b45309,
#f59e0b,
#fbbf24
);
background-size: 200% auto;
color: transparent;
@@ -132,32 +116,95 @@ const Stats = () => {
-webkit-text-fill-color: transparent;
}
@keyframes fadeInFirst {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 0.8; transform: translateY(0); }
}
@keyframes fadeInSecond {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInHours {
from { opacity: 0; transform: translateX(20px); margin-left: 0; }
to { opacity: 0.6; transform: translateX(0); margin-left: 1rem; }
}
@keyframes fadeInThird {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 0.8; transform: translateY(0); }
}
@keyframes fadeInFourth {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 0.6; transform: translateY(0); }
.animate-gradient {
animation: gradient 4s linear infinite;
}
.animate-fade-in-first { animation: fadeInFirst 0.7s ease-out forwards; }
.animate-fade-in-second { animation: fadeInSecond 0.7s ease-out 0.4s forwards; }
.animate-slide-in-hours { animation: slideInHours 0.7s ease-out 0.6s forwards; }
.animate-fade-in-third { animation: fadeInThird 0.7s ease-out 0.8s forwards; }
.animate-fade-in-fourth { animation: fadeInFourth 0.7s ease-out 1s forwards; }
@keyframes gradient {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes fadeInFirst {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 0.8;
transform: translateY(0);
}
}
@keyframes fadeInSecond {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInHours {
0% {
opacity: 0;
transform: translateX(20px);
margin-left: 0;
}
100% {
opacity: 0.6;
transform: translateX(0);
margin-left: 1rem;
}
}
@keyframes fadeInThird {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 0.8;
transform: translateY(0);
}
}
@keyframes fadeInFourth {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 0.6;
transform: translateY(0);
}
}
.animate-fade-in-first {
animation: fadeInFirst 0.7s ease-out forwards;
}
.animate-fade-in-second {
animation: fadeInSecond 0.7s ease-out forwards;
animation-delay: 0.4s;
}
.animate-slide-in-hours {
animation: slideInHours 0.7s ease-out forwards;
animation-delay: 0.6s;
}
.animate-fade-in-third {
animation: fadeInThird 0.7s ease-out forwards;
animation-delay: 0.8s;
}
.animate-fade-in-fourth {
animation: fadeInFourth 0.7s ease-out forwards;
animation-delay: 1s;
}
`}</style>
</div>
);

View File

@@ -1,66 +1,35 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
import { ActivityGrid } from "@/components/about/stats-activity";
const DetailedStats = () => {
const [stats, setStats] = useState<any>(null);
const [activity, setActivity] = useState<any>(null);
const [error, setError] = useState(false);
const [visible, setVisible] = useState(false);
const [skipAnim, setSkipAnim] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [stats, setStats] = useState(null);
const [activity, setActivity] = useState(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
fetch("/api/wakatime/detailed")
.then((res) => {
if (!res.ok) throw new Error();
return res.json();
.then(res => res.json())
.then(data => {
setStats(data.data);
setIsVisible(true);
})
.then((data) => setStats(data.data))
.catch(() => setError(true));
.catch(error => {
console.error("Error fetching stats:", error);
});
fetch("/api/wakatime/activity")
.then((res) => {
if (!res.ok) throw new Error();
return res.json();
.then(res => res.json())
.then(data => {
setActivity(data.data);
})
.then((data) => setActivity(data.data))
.catch(() => {});
.catch(error => {
console.error("Error fetching activity:", error);
});
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkipAnim(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1, rootMargin: "-15% 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [stats]);
if (error) return null;
if (!stats) return null;
const progressColors = [
"bg-red-bright",
@@ -69,163 +38,138 @@ const DetailedStats = () => {
"bg-green-bright",
"bg-blue-bright",
"bg-purple-bright",
"bg-aqua-bright",
"bg-aqua-bright"
];
const statCards = stats
? [
{
title: "Total Time",
value: `${Math.round((stats.total_seconds / 3600) * 10) / 10}`,
unit: "hours",
subtitle: "this week",
color: "text-yellow-bright",
borderHover: "hover:border-yellow-bright/50",
icon: Clock,
iconColor: "stroke-yellow-bright",
},
{
title: "Daily Average",
value: `${Math.round((stats.daily_average / 3600) * 10) / 10}`,
unit: "hours",
subtitle: "per day",
color: "text-orange-bright",
borderHover: "hover:border-orange-bright/50",
icon: CalendarClock,
iconColor: "stroke-orange-bright",
},
{
title: "Primary Editor",
value: stats.editors?.[0]?.name || "None",
unit: `${Math.round(stats.editors?.[0]?.percent || 0)}%`,
subtitle: "of the time",
color: "text-blue-bright",
borderHover: "hover:border-blue-bright/50",
icon: CodeXml,
iconColor: "stroke-blue-bright",
},
{
title: "Operating System",
value: stats.operating_systems?.[0]?.name || "None",
unit: `${Math.round(stats.operating_systems?.[0]?.percent || 0)}%`,
subtitle: "of the time",
color: "text-green-bright",
borderHover: "hover:border-green-bright/50",
icon: Computer,
iconColor: "stroke-green-bright",
},
]
: [];
const languages =
stats?.languages?.slice(0, 7).map((lang: any, index: number) => ({
name: lang.name,
percent: Math.round(lang.percent),
time: Math.round((lang.total_seconds / 3600) * 10) / 10 + " hrs",
color: progressColors[index % progressColors.length],
})) || [];
return (
<div ref={containerRef} className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4 min-h-[50vh]">
{!stats ? null : (
<>
{/* Header */}
<h2
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
style={skipAnim ? {} : {
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
}}
>
<div className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright">
Weekly Statistics
</h2>
{/* Stat Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{statCards.map((card, i) => {
const Icon = card.icon;
return (
<div
key={card.title}
className={`bg-background border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-all duration-500 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: `${150 + i * 100}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
<div className="flex gap-4 items-center">
<div className="p-3 rounded-lg bg-foreground/5">
<Icon className={`w-6 h-6 ${card.iconColor}`} strokeWidth={1.5} />
</div>
<div className="flex flex-col">
<div className={`${card.color} text-sm mb-1`}>{card.title}</div>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{card.value}</div>
<div className="text-lg opacity-80">{card.unit}</div>
</div>
<div className="text-xs opacity-50 mt-0.5">{card.subtitle}</div>
</div>
</div>
</div>
);
})}
{/* Top Stats Grid */}
<div className={`
grid grid-cols-1 md:grid-cols-2 gap-8
transition-all duration-700 transform
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
`}>
{/* Total Time */}
<StatsCard
title="Total Time"
value={`${Math.round(stats.total_seconds / 3600 * 10) / 10}`}
unit="hours"
subtitle="this week"
color="text-yellow-bright"
icon={Clock}
iconColor="stroke-yellow-bright"
/>
{/* Daily Average */}
<StatsCard
title="Daily Average"
value={`${Math.round(stats.daily_average / 3600 * 10) / 10}`}
unit="hours"
subtitle="per day"
color="text-orange-bright"
icon={CalendarClock}
iconColor="stroke-orange-bright"
/>
{/* Editors */}
<StatsCard
title="Primary Editor"
value={stats.editors?.[0]?.name || "None"}
unit={`${Math.round(stats.editors?.[0]?.percent || 0)}%`}
subtitle="of the time"
color="text-blue-bright"
icon={CodeXml}
iconColor="stroke-blue-bright"
/>
{/* OS */}
<StatsCard
title="Operating System"
value={stats.operating_systems?.[0]?.name || "None"}
unit={`${Math.round(stats.operating_systems?.[0]?.percent || 0)}%`}
subtitle="of the time"
color="text-green-bright"
icon={Computer}
iconColor="stroke-green-bright"
/>
</div>
{/* Languages */}
<div
className={`bg-background border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: "550ms",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
<div className="text-purple-bright mb-6 text-lg">Languages</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-5">
{languages.map((lang: any, i: number) => (
<div key={lang.name} className="flex flex-col gap-2">
<div className={`
transition-all duration-700 delay-200 transform
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
`}>
<DetailCard
title="Languages"
items={stats.languages?.slice(0, 7).map((lang, index) => ({
name: lang.name,
value: Math.round(lang.percent) + '%',
time: Math.round(lang.total_seconds / 3600 * 10) / 10 + ' hrs',
color: progressColors[index % progressColors.length]
})) || []}
titleColor="text-purple-bright"
/>
{/* Activity Grid */}
{activity && (
<div className={`
transition-all duration-700 delay-300 transform
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
`}>
<ActivityGrid data={activity} />
</div>
)}
</div>
</div>
);
};
const StatsCard = ({ title, value, unit, subtitle, color, icon: Icon, iconColor }) => (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-yellow transition-colors flex items-center justify-center">
<div className="flex gap-3 items-center">
<Icon className={`w-6 h-6 ${iconColor}`} strokeWidth={1.5} />
<div className="flex flex-col items-center">
<div className={`${color} text-lg mb-1`}>{title}</div>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{value}</div>
<div className="text-lg opacity-80">{unit}</div>
</div>
<div className="text-sm opacity-60 mt-1">{subtitle}</div>
</div>
</div>
</div>
);
const DetailCard = ({ title, items, titleColor }) => (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-yellow transition-colors">
<div className={`${titleColor} mb-6 text-lg`}>{title}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-6">
{items.map((item) => (
<div key={item.name} className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">{lang.name}</span>
<span className="text-sm opacity-70 tabular-nums">{lang.time}</span>
<span className="text-base font-medium">{item.name}</span>
<span className="text-base opacity-80">{item.value}</span>
</div>
<div className="flex gap-3 items-center">
<div className="flex-grow h-2 bg-foreground/5 rounded-full overflow-hidden">
<div
className={`h-full ${lang.color} rounded-full`}
className={`h-full ${item.color} rounded-full transition-all duration-1000`}
style={{
width: visible ? `${lang.percent}%` : "0%",
opacity: 0.85,
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`,
width: item.value,
opacity: '0.8'
}}
/>
</div>
<span className="text-xs text-foreground/50 min-w-[36px] text-right tabular-nums">
{lang.percent}%
</span>
<span className="text-sm text-foreground/60 min-w-[70px] text-right">{item.time}</span>
</div>
</div>
))}
</div>
</div>
{/* Activity Grid */}
{activity && (
<div
className={skipAnim ? "" : "transition-all duration-700 ease-out"}
style={skipAnim ? {} : {
transitionDelay: "750ms",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
<ActivityGrid data={activity} />
</div>
)}
</>
)}
</div>
);
};
);
export default DetailedStats;

View File

@@ -1,125 +1,70 @@
import React, { useEffect, useRef, useState } from "react";
import { Check, Code, GitBranch, Star, Rocket } from "lucide-react";
import React from "react";
import { Check, Code, GitBranch, Star } from "lucide-react";
const timelineItems = [
{
year: "2026",
title: "Present",
description: "Building domain-specific languages, diving deep into the Salesforce ecosystem, and writing production Java and Python daily. The craft keeps evolving.",
technologies: ["Java", "Python", "Salesforce", "DSLs"],
icon: <Rocket className="text-red-bright" size={20} />,
},
export default function Timeline() {
const timelineItems = [
{
year: "2024",
title: "Shipping & Scaling",
title: "Present",
description: "The wisdom of past ventures now flows through my work, whether crafting elegant CRUD applications or embarking on bold projects that expand my limits.",
technologies: ["Rust", "Typescript", "Go", "Postgres"],
icon: <Code className="text-yellow-bright" size={20} />,
icon: <Code className="text-yellow-bright" size={20} />
},
{
year: "2022",
title: "Diving Deeper",
description: "The worlds of systems programming and scalable infrastructure collided as I explored low-level C++ graphics programming and containerization with Docker.",
technologies: ["C++", "Cmake", "Docker", "Docker Compose"],
icon: <GitBranch className="text-green-bright" size={20} />,
icon: <GitBranch className="text-green-bright" size={20} />
},
{
year: "2020",
title: "Exploring the Stack",
description: "Starting with pure HTML and CSS, I explored the foundations of web development, gradually venturing into JavaScript and React to bring my static pages to life.",
technologies: ["Javascript", "Tailwind", "React", "Express"],
icon: <Star className="text-blue-bright" size={20} />,
icon: <Star className="text-blue-bright" size={20} />
},
{
year: "2018",
title: "Starting the Journey",
description: "An elective Python class in 8th grade transformed my keen interest in programming into a relentless obsession, one that drove me to constantly explore new depths.",
technologies: ["Python", "Discord.py", "Asyncio", "Sqlite"],
icon: <Check className="text-purple-bright" size={20} />,
},
];
function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
icon: <Check className="text-purple-bright" size={20} />
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const isLeft = index % 2 === 0;
];
return (
<div ref={ref} className="relative mb-8 md:mb-12 last:mb-0">
<div className={`flex flex-col sm:flex-row items-start ${isLeft ? "sm:flex-row-reverse" : ""}`}>
{/* Node */}
<div
className={`
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
<div className="w-full max-w-6xl px-4 py-8 relative z-0">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12">
Journey Through Code
</h2>
<div className="relative">
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 bg-foreground/10 -translate-x-1/2" />
<div className="ml-8 sm:ml-0">
{timelineItems.map((item, index) => (
<div key={item.year} className="relative mb-8 md:mb-12 last:mb-0">
<div className={`flex flex-col sm:flex-row items-start ${
index % 2 === 0 ? 'sm:flex-row-reverse' : ''
}`}>
<div className="absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
flex items-center justify-center z-10
${skip ? "" : "transition-all duration-500"}
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
`}
>
flex items-center justify-center z-10">
{item.icon}
</div>
{/* Card */}
<div
className={`
w-full sm:w-[calc(50%-32px)]
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
${skip ? "" : "transition-all duration-700 ease-out"}
${visible
? "opacity-100 translate-x-0"
: `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0`
}
`}
>
<div
className="p-4 sm:p-6 bg-background/50 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-colors duration-300"
>
<div className={`w-full sm:w-[calc(50%-32px)] ${
index % 2 === 0 ? 'sm:pr-8 md:pr-12' : 'sm:pl-8 md:pl-12'
}`}>
<div className="p-4 sm:p-6 bg-background/50 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-colors duration-300">
<span className="text-xs sm:text-sm font-mono text-yellow-bright">{item.year}</span>
<h3 className="text-lg sm:text-xl font-bold text-foreground/90 mt-2">{item.title}</h3>
<p className="text-sm sm:text-base text-foreground/70 mt-2">{item.description}</p>
<div className="flex flex-wrap gap-2 mt-3">
{item.technologies.map((tech) => (
<span
key={tech}
<span key={tech}
className="px-2 py-1 text-xs sm:text-sm rounded-full bg-foreground/5
text-foreground/60 hover:text-yellow-bright transition-colors duration-300"
>
text-foreground/60 hover:text-yellow-bright transition-colors duration-300">
{tech}
</span>
))}
@@ -128,54 +73,6 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
</div>
</div>
</div>
);
}
export default function Timeline() {
const lineRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [lineHeight, setLineHeight] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
// Animate line to full height over time
const el = lineRef.current;
if (el) {
setLineHeight(100);
}
observer.disconnect();
}
},
{ threshold: 0.1 }
);
observer.observe(container);
return () => observer.disconnect();
}, []);
return (
<div className="w-full max-w-6xl px-4 py-8 relative z-0">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12">
My Journey Through Code
</h2>
<div ref={containerRef} className="relative">
{/* Animated vertical line */}
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
<div
ref={lineRef}
className="w-full bg-foreground/10 transition-all duration-[1500ms] ease-out origin-top"
style={{ height: `${lineHeight}%` }}
/>
</div>
<div className="ml-8 sm:ml-0">
{timelineItems.map((item, index) => (
<TimelineCard key={item.year} item={item} index={index} />
))}
</div>
</div>

View File

@@ -1,50 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
interface AnimateInProps {
children: React.ReactNode;
delay?: number;
threshold?: number;
}
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}

View File

@@ -1,58 +0,0 @@
import { useState, useEffect, useRef } from "react";
import {
getStoredAnimationId,
getNextAnimation,
saveAnimation,
} from "@/lib/animations/engine";
import { ANIMATION_LABELS } from "@/lib/animations";
export default function AnimationSwitcher() {
const [hovering, setHovering] = useState(false);
const [nextLabel, setNextLabel] = useState("");
const committedRef = useRef("");
useEffect(() => {
committedRef.current = getStoredAnimationId();
setNextLabel(ANIMATION_LABELS[getNextAnimation(committedRef.current)]);
const handleSwap = () => {
const id = getStoredAnimationId();
committedRef.current = id;
setNextLabel(ANIMATION_LABELS[getNextAnimation(id)]);
};
document.addEventListener("astro:after-swap", handleSwap);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
};
}, []);
const handleClick = () => {
const nextId = getNextAnimation(
committedRef.current as Parameters<typeof getNextAnimation>[0]
);
saveAnimation(nextId);
committedRef.current = nextId;
setNextLabel(ANIMATION_LABELS[getNextAnimation(nextId)]);
document.dispatchEvent(
new CustomEvent("animation-changed", { detail: { id: nextId } })
);
};
return (
<div
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
{nextLabel}
</span>
</div>
);
}

View File

@@ -1,329 +0,0 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
r: number;
color: [number, number, number];
baseColor: [number, number, number];
opacity: number;
dop: number;
elevation: number;
targetElevation: number;
staggerDelay: number;
burst: boolean;
}
const BASE_CONFETTI = 350;
const BASE_AREA = 1920 * 1080;
const PI_2 = 2 * Math.PI;
const TARGET_FPS = 60;
const SPEED_FACTOR = 0.15;
const STAGGER_INTERVAL = 12;
const COLOR_LERP_SPEED = 0.02;
const MOUSE_INFLUENCE_RADIUS = 150;
const ELEVATION_FACTOR = 6;
const ELEVATION_LERP_SPEED = 0.05;
const COLOR_SHIFT_AMOUNT = 30;
const SHADOW_OFFSET_RATIO = 1.1;
function range(a: number, b: number): number {
return (b - a) * Math.random() + a;
}
export class ConfettiEngine implements AnimationEngine {
id = "confetti";
name = "Confetti";
private particles: ConfettiParticle[] = [];
private palette: [number, number, number][] = [];
private width = 0;
private height = 0;
private mouseX = -1000;
private mouseY = -1000;
private mouseXNorm = 0.5;
private elapsed = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
_bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.elapsed = 0;
this.mouseXNorm = 0.5;
this.initParticles();
}
cleanup(): void {
this.particles = [];
}
private randomColor(): [number, number, number] {
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private getParticleCount(): number {
const area = this.width * this.height;
return Math.max(20, Math.round(BASE_CONFETTI * (area / BASE_AREA)));
}
private initParticles(): void {
this.particles = [];
const count = this.getParticleCount();
for (let i = 0; i < count; i++) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
this.particles.push({
x: range(-r * 2, this.width - r * 2),
y: range(-20, this.height - r * 2),
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
r,
color: [...baseColor],
baseColor,
opacity: 0,
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
elevation: 0,
targetElevation: 0,
staggerDelay: i * STAGGER_INTERVAL + range(0, STAGGER_INTERVAL),
burst: false,
});
}
}
private replaceParticle(p: ConfettiParticle): void {
p.opacity = 0;
p.dop = 0.03 * range(1, 4) * SPEED_FACTOR;
p.x = range(-p.r * 2, this.width - p.r * 2);
p.y = range(-20, -p.r * 2);
p.vx = (range(0, 2) + 8 * this.mouseXNorm - 5) * SPEED_FACTOR;
p.vy = (0.7 * p.r + range(-1, 1)) * SPEED_FACTOR;
p.elevation = 0;
p.targetElevation = 0;
p.baseColor = this.randomColor();
p.color = [...p.baseColor];
p.burst = false;
}
update(deltaTime: number): void {
const dt = deltaTime / (1000 / TARGET_FPS);
this.elapsed += deltaTime;
const mouseX = this.mouseX;
const mouseY = this.mouseY;
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
// Stagger gate
if (p.staggerDelay >= 0) {
if (this.elapsed >= p.staggerDelay) {
p.staggerDelay = -1;
} else {
continue;
}
}
// Gravity (capped so falling particles don't accelerate)
const maxVy = (0.7 * p.r + 1) * SPEED_FACTOR;
if (p.vy < maxVy) {
p.vy = Math.min(p.vy + 0.02 * dt, maxVy);
}
// Position update
p.x += p.vx * dt;
p.y += p.vy * dt;
// Fade in only (no fade-out cycle)
if (p.opacity < 1) {
p.opacity += Math.abs(p.dop) * dt;
if (p.opacity > 1) p.opacity = 1;
}
// Past the bottom: burst particles get removed, base particles recycle
if (p.y > this.height + p.r) {
if (p.burst) {
this.particles.splice(i, 1);
i--;
} else {
this.replaceParticle(p);
}
continue;
}
// Horizontal wrap
const xmax = this.width - p.r;
if (p.x < 0 || p.x > xmax) {
p.x = ((p.x % xmax) + xmax) % xmax;
}
// Mouse proximity elevation
const dx = p.x - mouseX;
const dy = p.y - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_INFLUENCE_RADIUS && p.opacity > 0.1) {
const influenceFactor = Math.cos(
(dist / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
);
p.targetElevation =
ELEVATION_FACTOR * influenceFactor * influenceFactor;
const shift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
p.color = [
Math.min(255, Math.max(0, p.baseColor[0] + shift)),
Math.min(255, Math.max(0, p.baseColor[1] + shift)),
Math.min(255, Math.max(0, p.baseColor[2] + shift)),
];
} else {
p.targetElevation = 0;
p.color[0] += (p.baseColor[0] - p.color[0]) * 0.1;
p.color[1] += (p.baseColor[1] - p.color[1]) * 0.1;
p.color[2] += (p.baseColor[2] - p.color[2]) * 0.1;
}
// Elevation lerp
p.elevation +=
(p.targetElevation - p.elevation) * ELEVATION_LERP_SPEED * dt;
}
}
render(
ctx: CanvasRenderingContext2D,
_width: number,
_height: number
): void {
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
if (p.opacity <= 0.01 || p.staggerDelay >= 0) continue;
const drawX = ~~p.x;
const drawY = ~~p.y - p.elevation;
const [r, g, b] = p.color;
// Shadow
if (p.elevation > 0.5) {
const shadowAlpha =
0.2 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
ctx.globalAlpha = shadowAlpha;
ctx.fillStyle = "rgb(0,0,0)";
ctx.shadowBlur = 2;
ctx.shadowColor = "rgba(0,0,0,0.1)";
ctx.beginPath();
ctx.arc(
drawX,
drawY + p.elevation * SHADOW_OFFSET_RATIO,
p.r,
0,
PI_2
);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowColor = "transparent";
}
// Main circle
ctx.globalAlpha = p.opacity * 0.9;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.arc(drawX, drawY, p.r, 0, PI_2);
ctx.fill();
// Highlight on elevated particles
if (p.elevation > 0.5) {
const highlightAlpha =
0.1 * (p.elevation / ELEVATION_FACTOR) * p.opacity;
ctx.globalAlpha = highlightAlpha;
ctx.fillStyle = "rgb(255,255,255)";
ctx.beginPath();
ctx.arc(drawX, drawY, p.r, Math.PI, 0);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
const target = this.getParticleCount();
while (this.particles.length < target) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
this.particles.push({
x: range(-r * 2, width - r * 2),
y: range(-20, height - r * 2),
vx: (range(0, 2) + 8 * 0.5 - 5) * SPEED_FACTOR,
vy: (0.7 * r + range(-1, 1)) * SPEED_FACTOR,
r,
color: [...baseColor],
baseColor,
opacity: 0,
dop: 0.03 * range(1, 4) * SPEED_FACTOR,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
burst: false,
});
}
if (this.particles.length > target) {
this.particles.length = target;
}
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
if (this.width > 0) {
this.mouseXNorm = Math.max(0, Math.min(1, x / this.width));
}
}
handleMouseDown(x: number, y: number): void {
const count = 12;
for (let i = 0; i < count; i++) {
const baseColor = this.randomColor();
const r = ~~range(3, 8);
const angle = (i / count) * PI_2 + range(-0.3, 0.3);
const speed = range(0.3, 1.2);
this.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r,
color: [...baseColor],
baseColor,
opacity: 1,
dop: 0,
elevation: 0,
targetElevation: 0,
staggerDelay: -1,
burst: true,
});
}
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
this.mouseXNorm = 0.5;
}
updatePalette(palette: [number, number, number][], _bgColor: string): void {
this.palette = palette;
for (const p of this.particles) {
p.baseColor = palette[Math.floor(Math.random() * palette.length)];
}
}
}

View File

@@ -1,615 +0,0 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface Cell {
alive: boolean;
next: boolean;
color: [number, number, number];
baseColor: [number, number, number];
currentX: number;
currentY: number;
targetX: number;
targetY: number;
opacity: number;
targetOpacity: number;
scale: number;
targetScale: number;
elevation: number;
targetElevation: number;
transitioning: boolean;
transitionComplete: boolean;
rippleEffect: number;
rippleStartTime: number;
rippleDistance: number;
}
interface Grid {
cells: Cell[][];
cols: number;
rows: number;
offsetX: number;
offsetY: number;
}
const CELL_SIZE_MOBILE = 15;
const CELL_SIZE_DESKTOP = 25;
const TARGET_FPS = 60;
const CYCLE_TIME = 3000;
const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05;
const INITIAL_DENSITY = 0.15;
const MOUSE_INFLUENCE_RADIUS = 150;
const COLOR_SHIFT_AMOUNT = 30;
const RIPPLE_ELEVATION_FACTOR = 4;
const ELEVATION_FACTOR = 8;
export class GameOfLifeEngine implements AnimationEngine {
id = "game-of-life";
name = "Game of Life";
private grid: Grid | null = null;
private palette: [number, number, number][] = [];
private bgColor = "rgb(0, 0, 0)";
private mouseX = -1000;
private mouseY = -1000;
private mouseIsDown = false;
private mouseCellX = -1;
private mouseCellY = -1;
private lastCycleTime = 0;
private timeAccumulator = 0;
private pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
private canvasWidth = 0;
private canvasHeight = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void {
this.palette = palette;
this.bgColor = bgColor;
this.canvasWidth = width;
this.canvasHeight = height;
this.lastCycleTime = 0;
this.timeAccumulator = 0;
this.grid = this.initGrid(width, height);
}
cleanup(): void {
for (const id of this.pendingTimeouts) {
clearTimeout(id);
}
this.pendingTimeouts = [];
this.grid = null;
}
private getCellSize(): number {
return this.canvasWidth <= 768 ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
}
private randomColor(): [number, number, number] {
return this.palette[Math.floor(Math.random() * this.palette.length)];
}
private initGrid(width: number, height: number): Grid {
const cellSize = this.getCellSize();
const cols = Math.floor(width / cellSize);
const rows = Math.floor(height / cellSize);
const offsetX = Math.floor((width - cols * cellSize) / 2);
const offsetY = Math.floor((height - rows * cellSize) / 2);
const cells = Array(cols)
.fill(0)
.map((_, i) =>
Array(rows)
.fill(0)
.map((_, j) => {
const baseColor = this.randomColor();
return {
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: [...baseColor] as [number, number, number],
baseColor,
currentX: i,
currentY: j,
targetX: i,
targetY: j,
opacity: 0,
targetOpacity: 0,
scale: 0,
targetScale: 0,
elevation: 0,
targetElevation: 0,
transitioning: false,
transitionComplete: false,
rippleEffect: 0,
rippleStartTime: 0,
rippleDistance: 0,
};
})
);
const grid = { cells, cols, rows, offsetX, offsetY };
this.computeNextState(grid);
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const cell = cells[i][j];
if (cell.next) {
cell.alive = true;
const tid = setTimeout(() => {
cell.targetOpacity = 1;
cell.targetScale = 1;
}, Math.random() * 1000);
this.pendingTimeouts.push(tid);
} else {
cell.alive = false;
}
}
}
return grid;
}
private countNeighbors(
grid: Grid,
x: number,
y: number
): { count: number; colors: [number, number, number][] } {
const neighbors = { count: 0, colors: [] as [number, number, number][] };
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
if (i === 0 && j === 0) continue;
const col = (x + i + grid.cols) % grid.cols;
const row = (y + j + grid.rows) % grid.rows;
if (grid.cells[col][row].alive) {
neighbors.count++;
neighbors.colors.push(grid.cells[col][row].baseColor);
}
}
}
return neighbors;
}
private averageColors(
colors: [number, number, number][]
): [number, number, number] {
if (colors.length === 0) return [0, 0, 0];
const sum = colors.reduce(
(acc, color) => [acc[0] + color[0], acc[1] + color[1], acc[2] + color[2]],
[0, 0, 0]
);
return [
Math.round(sum[0] / colors.length),
Math.round(sum[1] / colors.length),
Math.round(sum[2] / colors.length),
];
}
private computeNextState(grid: Grid): void {
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const { count, colors } = this.countNeighbors(grid, i, j);
if (cell.alive) {
cell.next = count === 2 || count === 3;
} else {
cell.next = count === 3;
if (cell.next) {
cell.baseColor = this.averageColors(colors);
cell.color = [...cell.baseColor];
}
}
}
}
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive !== cell.next && !cell.transitioning) {
cell.transitioning = true;
cell.transitionComplete = false;
const delay = Math.random() * 800;
const tid = setTimeout(() => {
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
cell.targetElevation = 0;
} else {
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
}
}, delay);
this.pendingTimeouts.push(tid);
}
}
}
}
private createRippleEffect(
grid: Grid,
centerX: number,
centerY: number
): void {
const currentTime = Date.now();
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const dx = i - centerX;
const dy = j - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (cell.opacity > 0.1) {
cell.rippleStartTime = currentTime + distance * 100;
cell.rippleDistance = distance;
cell.rippleEffect = 0;
}
}
}
}
private spawnCellAtPosition(grid: Grid, x: number, y: number): void {
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
const cell = grid.cells[x][y];
if (!cell.alive && !cell.transitioning) {
cell.alive = true;
cell.next = true;
cell.transitioning = true;
cell.transitionComplete = false;
cell.baseColor = this.randomColor();
cell.color = [...cell.baseColor];
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
this.createRippleEffect(grid, x, y);
}
}
}
update(deltaTime: number): void {
if (!this.grid) return;
this.timeAccumulator += deltaTime;
if (this.timeAccumulator >= CYCLE_TIME) {
this.computeNextState(this.grid);
this.timeAccumulator -= CYCLE_TIME;
}
this.updateCellAnimations(this.grid, deltaTime);
}
private updateCellAnimations(grid: Grid, deltaTime: number): void {
const mouseX = this.mouseX;
const mouseY = this.mouseY;
const cellSize = this.getCellSize();
const transitionFactor =
TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
cell.elevation +=
(cell.targetElevation - cell.elevation) * scaleFactor;
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
const influenceFactor = Math.cos(
(distanceToMouse / MOUSE_INFLUENCE_RADIUS) * (Math.PI / 2)
);
cell.targetElevation =
ELEVATION_FACTOR * influenceFactor * influenceFactor;
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift)),
] as [number, number, number];
} else {
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
cell.targetElevation = 0;
}
if (cell.transitioning) {
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
cell.alive = false;
cell.transitioning = false;
cell.transitionComplete = true;
cell.opacity = 0;
cell.scale = 0;
cell.elevation = 0;
} else if (cell.next && !cell.alive && !cell.transitionComplete) {
cell.alive = true;
cell.transitioning = false;
cell.transitionComplete = true;
}
}
if (cell.rippleStartTime > 0) {
const elapsedTime = Date.now() - cell.rippleStartTime;
if (elapsedTime > 0) {
const rippleProgress = elapsedTime / 1000;
if (rippleProgress < 1) {
const wavePhase = rippleProgress * Math.PI * 2;
const waveHeight =
Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.rippleEffect = waveHeight;
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
} else {
cell.rippleEffect = waveHeight * 0.3;
}
} else {
cell.rippleEffect = 0;
cell.rippleStartTime = 0;
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.targetElevation = 0;
}
}
}
}
}
}
}
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
if (!this.grid) return;
const grid = this.grid;
const cellSize = this.getCellSize();
const displayCellSize = cellSize * 0.8;
const roundness = displayCellSize * 0.2;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (
(cell.alive || cell.targetOpacity > 0) &&
cell.opacity > 0.01
) {
const [r, g, b] = cell.color;
ctx.globalAlpha = cell.opacity * 0.9;
const scaledSize = displayCellSize * cell.scale;
const xOffset = (displayCellSize - scaledSize) / 2;
const yOffset = (displayCellSize - scaledSize) / 2;
const elevationOffset = cell.elevation;
const x =
grid.offsetX +
i * cellSize +
(cellSize - displayCellSize) / 2 +
xOffset;
const y =
grid.offsetY +
j * cellSize +
(cellSize - displayCellSize) / 2 +
yOffset -
elevationOffset;
const scaledRoundness = roundness * cell.scale;
// Shadow for 3D effect
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
ctx.lineTo(
x + scaledSize - scaledRoundness,
y + elevationOffset * 1.1
);
ctx.quadraticCurveTo(
x + scaledSize,
y + elevationOffset * 1.1,
x + scaledSize,
y + elevationOffset * 1.1 + scaledRoundness
);
ctx.lineTo(
x + scaledSize,
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
);
ctx.quadraticCurveTo(
x + scaledSize,
y + elevationOffset * 1.1 + scaledSize,
x + scaledSize - scaledRoundness,
y + elevationOffset * 1.1 + scaledSize
);
ctx.lineTo(
x + scaledRoundness,
y + elevationOffset * 1.1 + scaledSize
);
ctx.quadraticCurveTo(
x,
y + elevationOffset * 1.1 + scaledSize,
x,
y + elevationOffset * 1.1 + scaledSize - scaledRoundness
);
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
ctx.quadraticCurveTo(
x,
y + elevationOffset * 1.1,
x + scaledRoundness,
y + elevationOffset * 1.1
);
ctx.fill();
}
// Main cell
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(
x + scaledSize,
y + scaledSize,
x + scaledSize - scaledRoundness,
y + scaledSize
);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
// Highlight on elevated cells
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(
x + scaledSize,
y,
x + scaledSize,
y + scaledRoundness
);
ctx.lineTo(x + scaledSize, y + scaledSize / 3);
ctx.lineTo(x, y + scaledSize / 3);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
}
}
}
}
ctx.globalAlpha = 1;
}
handleResize(width: number, height: number): void {
this.canvasWidth = width;
this.canvasHeight = height;
const cellSize = this.getCellSize();
if (
!this.grid ||
this.grid.cols !== Math.floor(width / cellSize) ||
this.grid.rows !== Math.floor(height / cellSize)
) {
for (const id of this.pendingTimeouts) {
clearTimeout(id);
}
this.pendingTimeouts = [];
this.grid = this.initGrid(width, height);
}
}
handleMouseMove(x: number, y: number, isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
this.mouseIsDown = isDown;
if (isDown && this.grid) {
const grid = this.grid;
const cellSize = this.getCellSize();
const cellX = Math.floor((x - grid.offsetX) / cellSize);
const cellY = Math.floor((y - grid.offsetY) / cellSize);
if (cellX !== this.mouseCellX || cellY !== this.mouseCellY) {
this.mouseCellX = cellX;
this.mouseCellY = cellY;
if (
cellX >= 0 &&
cellX < grid.cols &&
cellY >= 0 &&
cellY < grid.rows
) {
const cell = grid.cells[cellX][cellY];
if (!cell.alive && !cell.transitioning) {
this.spawnCellAtPosition(grid, cellX, cellY);
}
}
}
}
}
handleMouseDown(x: number, y: number): void {
this.mouseIsDown = true;
if (!this.grid) return;
const grid = this.grid;
const cellSize = this.getCellSize();
const cellX = Math.floor((x - grid.offsetX) / cellSize);
const cellY = Math.floor((y - grid.offsetY) / cellSize);
if (
cellX >= 0 &&
cellX < grid.cols &&
cellY >= 0 &&
cellY < grid.rows
) {
this.mouseCellX = cellX;
this.mouseCellY = cellY;
const cell = grid.cells[cellX][cellY];
if (cell.alive) {
this.createRippleEffect(grid, cellX, cellY);
} else {
this.spawnCellAtPosition(grid, cellX, cellY);
}
}
}
handleMouseUp(): void {
this.mouseIsDown = false;
}
handleMouseLeave(): void {
this.mouseIsDown = false;
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(palette: [number, number, number][], bgColor: string): void {
this.palette = palette;
this.bgColor = bgColor;
if (this.grid) {
const grid = this.grid;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive && cell.opacity > 0.01) {
cell.baseColor =
palette[Math.floor(Math.random() * palette.length)];
}
}
}
}
}
}

View File

@@ -1,498 +0,0 @@
import type { AnimationEngine } from "@/lib/animations/types";
interface Blob {
x: number;
y: number;
vx: number;
vy: number;
baseRadius: number;
radiusScale: number;
targetRadiusScale: number;
color: [number, number, number];
targetColor: [number, number, number];
phase: number;
phaseSpeed: number;
staggerDelay: number; // -1 means already revealed
}
const BLOB_COUNT = 26;
const BASE_MAX_BLOBS = 80; // at 1080p; scales with canvas area
const MIN_SPEED = 0.1;
const MAX_SPEED = 0.35;
const RESOLUTION_SCALE = 5; // render at 1/5 resolution (was 1/4)
const FIELD_THRESHOLD = 1.0;
const SMOOTHSTEP_RANGE = 0.25;
const MOUSE_REPEL_RADIUS = 150;
const MOUSE_REPEL_FORCE = 0.2;
const COLOR_LERP_SPEED = 0.02;
const DRIFT_AMPLITUDE = 0.2;
const RADIUS_LERP_SPEED = 0.06;
const STAGGER_INTERVAL = 60;
const CYCLE_MIN_MS = 2000; // min time between natural spawn/despawn
const CYCLE_MAX_MS = 5000; // max time
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
export class LavaLampEngine implements AnimationEngine {
id = "lava-lamp";
name = "Lava Lamp";
private blobs: Blob[] = [];
private palette: [number, number, number][] = [];
private bgRgb: [number, number, number] = [0, 0, 0];
private width = 0;
private height = 0;
private mouseX = -1000;
private mouseY = -1000;
private offCanvas: HTMLCanvasElement | null = null;
private offCtx: CanvasRenderingContext2D | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
private shadowCtx: CanvasRenderingContext2D | null = null;
private elapsed = 0;
private nextCycleTime = 0;
// Pre-allocated typed arrays for the inner render loop (avoid per-frame GC)
private blobX: Float64Array = new Float64Array(0);
private blobY: Float64Array = new Float64Array(0);
private blobR: Float64Array = new Float64Array(0);
private blobCR: Float64Array = new Float64Array(0);
private blobCG: Float64Array = new Float64Array(0);
private blobCB: Float64Array = new Float64Array(0);
private activeBlobCount = 0;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void {
this.width = width;
this.height = height;
this.palette = palette;
this.parseBgColor(bgColor);
this.elapsed = 0;
this.nextCycleTime = CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
this.initBlobs();
this.initOffscreenCanvas();
}
private parseBgColor(bgColor: string): void {
const match = bgColor.match(/(\d+)/g);
if (match && match.length >= 3) {
this.bgRgb = [parseInt(match[0]), parseInt(match[1]), parseInt(match[2])];
}
}
private getMaxBlobs(): number {
const area = this.width * this.height;
const scale = area / 2_073_600; // normalize to 1080p
return Math.max(BASE_MAX_BLOBS, Math.round(BASE_MAX_BLOBS * scale));
}
private getRadiusRange(): { min: number; max: number } {
const area = this.width * this.height;
const scale = Math.sqrt(area / 2_073_600);
const min = Math.max(8, Math.round(25 * scale));
const max = Math.max(15, Math.round(65 * scale));
return { min, max };
}
private makeBlob(x: number, y: number, radiusOverride?: number): Blob {
const { min, max } = this.getRadiusRange();
const color = this.palette[
Math.floor(Math.random() * this.palette.length)
] || [128, 128, 128];
return {
x,
y,
vx: (Math.random() - 0.5) * 2 * MAX_SPEED,
vy: (Math.random() - 0.5) * 2 * MAX_SPEED,
baseRadius: radiusOverride ?? (min + Math.random() * (max - min)),
radiusScale: 0,
targetRadiusScale: 1,
color: [...color],
targetColor: [...color],
phase: Math.random() * Math.PI * 2,
phaseSpeed: 0.0005 + Math.random() * 0.001,
staggerDelay: -1,
};
}
private initBlobs(): void {
this.blobs = [];
const { max } = this.getRadiusRange();
const minDist = max * 2.5; // minimum distance between blob centers
for (let i = 0; i < BLOB_COUNT; i++) {
let x: number, y: number;
let attempts = 0;
// Try to find a position that doesn't overlap existing blobs
do {
x = Math.random() * this.width;
y = Math.random() * this.height;
attempts++;
} while (attempts < 50 && this.tooCloseToExisting(x, y, minDist));
const blob = this.makeBlob(x, y);
blob.targetRadiusScale = 0;
blob.staggerDelay = i * STAGGER_INTERVAL + Math.random() * STAGGER_INTERVAL;
this.blobs.push(blob);
}
}
private tooCloseToExisting(x: number, y: number, minDist: number): boolean {
for (const blob of this.blobs) {
const dx = blob.x - x;
const dy = blob.y - y;
if (dx * dx + dy * dy < minDist * minDist) return true;
}
return false;
}
private initOffscreenCanvas(): void {
const rw = Math.ceil(this.width / RESOLUTION_SCALE);
const rh = Math.ceil(this.height / RESOLUTION_SCALE);
this.offCanvas = document.createElement("canvas");
this.offCanvas.width = rw;
this.offCanvas.height = rh;
this.offCtx = this.offCanvas.getContext("2d", { willReadFrequently: true });
this.shadowCanvas = document.createElement("canvas");
this.shadowCanvas.width = rw;
this.shadowCanvas.height = rh;
this.shadowCtx = this.shadowCanvas.getContext("2d", {
willReadFrequently: true,
});
}
cleanup(): void {
this.blobs = [];
this.offCanvas = null;
this.offCtx = null;
this.shadowCanvas = null;
this.shadowCtx = null;
}
/** Snapshot active blob data into flat typed arrays for fast inner-loop access */
private syncBlobArrays(): void {
const blobs = this.blobs;
const n = blobs.length;
// Grow arrays if needed
if (this.blobX.length < n) {
const cap = n + 32;
this.blobX = new Float64Array(cap);
this.blobY = new Float64Array(cap);
this.blobR = new Float64Array(cap);
this.blobCR = new Float64Array(cap);
this.blobCG = new Float64Array(cap);
this.blobCB = new Float64Array(cap);
}
let count = 0;
for (let i = 0; i < n; i++) {
const b = blobs[i];
const r = b.baseRadius * b.radiusScale;
if (r < 1) continue; // skip invisible blobs entirely
this.blobX[count] = b.x;
this.blobY[count] = b.y;
this.blobR[count] = r;
this.blobCR[count] = b.color[0];
this.blobCG[count] = b.color[1];
this.blobCB[count] = b.color[2];
count++;
}
this.activeBlobCount = count;
}
update(deltaTime: number): void {
const dt = deltaTime / (1000 / 60);
this.elapsed += deltaTime;
for (const blob of this.blobs) {
// Staggered load-in
if (blob.staggerDelay >= 0) {
if (this.elapsed >= blob.staggerDelay) {
blob.targetRadiusScale = 1;
blob.staggerDelay = -1;
}
}
blob.radiusScale +=
(blob.targetRadiusScale - blob.radiusScale) * RADIUS_LERP_SPEED * dt;
blob.phase += blob.phaseSpeed * deltaTime;
const driftX = Math.sin(blob.phase) * DRIFT_AMPLITUDE;
const driftY = Math.cos(blob.phase * 0.7) * DRIFT_AMPLITUDE;
blob.vx += driftX * dt * 0.01;
blob.vy += driftY * dt * 0.01;
blob.vx += (Math.random() - 0.5) * 0.008 * dt;
blob.vy += (Math.random() - 0.5) * 0.008 * dt;
const speed = Math.sqrt(blob.vx * blob.vx + blob.vy * blob.vy);
if (speed > MAX_SPEED) {
blob.vx = (blob.vx / speed) * MAX_SPEED;
blob.vy = (blob.vy / speed) * MAX_SPEED;
}
if (speed < MIN_SPEED) {
const angle = Math.atan2(blob.vy, blob.vx);
blob.vx = Math.cos(angle) * MIN_SPEED;
blob.vy = Math.sin(angle) * MIN_SPEED;
}
blob.x += blob.vx * dt;
blob.y += blob.vy * dt;
const pad = blob.baseRadius * 0.3;
if (blob.x < -pad) { blob.x = -pad; blob.vx = Math.abs(blob.vx) * 0.8; }
if (blob.x > this.width + pad) { blob.x = this.width + pad; blob.vx = -Math.abs(blob.vx) * 0.8; }
if (blob.y < -pad) { blob.y = -pad; blob.vy = Math.abs(blob.vy) * 0.8; }
if (blob.y > this.height + pad) { blob.y = this.height + pad; blob.vy = -Math.abs(blob.vy) * 0.8; }
// Mouse repulsion
const dx = blob.x - this.mouseX;
const dy = blob.y - this.mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_REPEL_RADIUS && dist > 0) {
const force = (1 - dist / MOUSE_REPEL_RADIUS) * MOUSE_REPEL_FORCE * dt;
blob.vx += (dx / dist) * force;
blob.vy += (dy / dist) * force;
}
for (let c = 0; c < 3; c++) {
blob.color[c] += (blob.targetColor[c] - blob.color[c]) * COLOR_LERP_SPEED * dt;
}
}
// Remove blobs that have fully shrunk away (but not ones still waiting to stagger in)
for (let i = this.blobs.length - 1; i >= 0; i--) {
const b = this.blobs[i];
if (b.targetRadiusScale === 0 && b.radiusScale < 0.01 && b.staggerDelay < 0) {
this.blobs.splice(i, 1);
}
}
// Natural spawn/despawn cycle — keeps the scene alive
if (this.elapsed >= this.nextCycleTime) {
// Pick a random visible blob to fade out (skip ones still staggering in)
const visible = [];
for (let i = 0; i < this.blobs.length; i++) {
if (this.blobs[i].radiusScale > 0.5 && this.blobs[i].staggerDelay < 0) {
visible.push(i);
}
}
if (visible.length > 0) {
const killIdx = visible[Math.floor(Math.random() * visible.length)];
this.blobs[killIdx].targetRadiusScale = 0;
}
// Spawn a fresh one at a random position
const blob = this.makeBlob(
Math.random() * this.width,
Math.random() * this.height
);
this.blobs.push(blob);
// Schedule next cycle
this.nextCycleTime = this.elapsed + CYCLE_MIN_MS + Math.random() * (CYCLE_MAX_MS - CYCLE_MIN_MS);
}
// Prune excess blobs (keep the initial set, drop oldest user-spawned ones)
const maxBlobs = this.getMaxBlobs();
if (this.blobs.length > maxBlobs) {
this.blobs.splice(BLOB_COUNT, this.blobs.length - maxBlobs);
}
}
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void {
if (!this.offCtx || !this.offCanvas || !this.shadowCtx || !this.shadowCanvas)
return;
// Snapshot blob positions/radii into typed arrays for fast pixel loop
this.syncBlobArrays();
const rw = this.offCanvas.width;
const rh = this.offCanvas.height;
// Render shadow layer
const shadowData = this.shadowCtx.createImageData(rw, rh);
this.renderField(shadowData, rw, rh, true);
this.shadowCtx.putImageData(shadowData, 0, 0);
// Render main layer
const imageData = this.offCtx.createImageData(rw, rh);
this.renderField(imageData, rw, rh, false);
this.offCtx.putImageData(imageData, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "medium";
ctx.globalAlpha = 0.2;
ctx.drawImage(this.shadowCanvas, 0, 4, width, height);
ctx.globalAlpha = 1;
ctx.drawImage(this.offCanvas, 0, 0, width, height);
}
private renderField(
imageData: ImageData,
rw: number,
rh: number,
isShadow: boolean
): void {
const data = imageData.data;
const threshold = isShadow ? FIELD_THRESHOLD * 0.75 : FIELD_THRESHOLD;
const bgR = this.bgRgb[0];
const bgG = this.bgRgb[1];
const bgB = this.bgRgb[2];
const scale = RESOLUTION_SCALE;
const n = this.activeBlobCount;
const bx = this.blobX;
const by = this.blobY;
const br = this.blobR;
const bcr = this.blobCR;
const bcg = this.blobCG;
const bcb = this.blobCB;
const threshLow = threshold - SMOOTHSTEP_RANGE;
for (let py = 0; py < rh; py++) {
const wy = py * scale;
for (let px = 0; px < rw; px++) {
const wx = px * scale;
let fieldSum = 0;
let weightedR = 0;
let weightedG = 0;
let weightedB = 0;
for (let i = 0; i < n; i++) {
const dx = wx - bx[i];
const dy = wy - by[i];
const distSq = dx * dx + dy * dy;
const ri = br[i];
const rSq = ri * ri;
// Raw metaball field
const raw = rSq / (distSq + rSq * 0.1);
// Cap per-blob contribution so color stays flat inside the blob
const contribution = raw > 2 ? 2 : raw;
fieldSum += contribution;
if (contribution > 0.01) {
weightedR += bcr[i] * contribution;
weightedG += bcg[i] * contribution;
weightedB += bcb[i] * contribution;
}
}
const idx = (py * rw + px) << 2;
if (fieldSum > threshLow) {
const alpha = smoothstep(threshLow, threshold, fieldSum);
if (isShadow) {
data[idx] = 0;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = (alpha * 150) | 0;
} else {
const invField = 1 / fieldSum;
const r = Math.min(255, (weightedR * invField) | 0);
const g = Math.min(255, (weightedG * invField) | 0);
const b = Math.min(255, (weightedB * invField) | 0);
data[idx] = bgR + (r - bgR) * alpha;
data[idx + 1] = bgG + (g - bgG) * alpha;
data[idx + 2] = bgB + (b - bgB) * alpha;
data[idx + 3] = 255;
}
} else {
if (isShadow) {
// data stays 0 (already zeroed by createImageData)
} else {
data[idx] = bgR;
data[idx + 1] = bgG;
data[idx + 2] = bgB;
data[idx + 3] = 255;
}
}
}
}
}
handleResize(width: number, height: number): void {
this.width = width;
this.height = height;
this.initOffscreenCanvas();
const { min, max } = this.getRadiusRange();
for (const blob of this.blobs) {
blob.baseRadius = min + Math.random() * (max - min);
}
}
private sampleColorAt(x: number, y: number): [number, number, number] | null {
let closest: Blob | null = null;
let closestDist = Infinity;
for (const blob of this.blobs) {
const dx = blob.x - x;
const dy = blob.y - y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < blob.baseRadius * 1.5 && dist < closestDist) {
closestDist = dist;
closest = blob;
}
}
return closest ? ([...closest.color] as [number, number, number]) : null;
}
private spawnAt(x: number, y: number): void {
const { max } = this.getRadiusRange();
const blob = this.makeBlob(x, y, max * (0.8 + Math.random() * 0.4));
const nearby = this.sampleColorAt(x, y);
if (nearby) {
blob.color = nearby;
blob.targetColor = [...nearby];
}
this.blobs.push(blob);
}
handleMouseMove(x: number, y: number, _isDown: boolean): void {
this.mouseX = x;
this.mouseY = y;
}
handleMouseDown(x: number, y: number): void {
this.spawnAt(x, y);
}
handleMouseUp(): void {}
handleMouseLeave(): void {
this.mouseX = -1000;
this.mouseY = -1000;
}
updatePalette(palette: [number, number, number][], bgColor: string): void {
this.palette = palette;
this.parseBgColor(bgColor);
for (let i = 0; i < this.blobs.length; i++) {
this.blobs[i].targetColor = [
...palette[i % palette.length],
] as [number, number, number];
}
}
}

View File

@@ -1,88 +1,466 @@
import { useEffect, useRef } from "react";
import { GameOfLifeEngine } from "./engines/game-of-life";
import { LavaLampEngine } from "./engines/lava-lamp";
import { ConfettiEngine } from "./engines/confetti";
import { getStoredAnimationId } from "@/lib/animations/engine";
import type { AnimationEngine } from "@/lib/animations/types";
import type { AnimationId } from "@/lib/animations";
const SIDEBAR_WIDTH = 240;
const FALLBACK_PALETTE: [number, number, number][] = [
[204, 36, 29], [152, 151, 26], [215, 153, 33],
[69, 133, 136], [177, 98, 134], [104, 157, 106],
];
function createEngine(id: AnimationId): AnimationEngine {
switch (id) {
case "lava-lamp":
return new LavaLampEngine();
case "confetti":
return new ConfettiEngine();
case "game-of-life":
default:
return new GameOfLifeEngine();
}
interface Cell {
alive: boolean;
next: boolean;
color: [number, number, number];
baseColor: [number, number, number]; // Original color
currentX: number;
currentY: number;
targetX: number;
targetY: number;
opacity: number;
targetOpacity: number;
scale: number;
targetScale: number;
elevation: number; // For 3D effect
targetElevation: number;
transitioning: boolean;
transitionComplete: boolean;
rippleEffect: number; // For ripple animation
rippleStartTime: number; // When ripple started
rippleDistance: number; // Distance from ripple center
}
function readPaletteFromCSS(): [number, number, number][] {
try {
const style = getComputedStyle(document.documentElement);
const keys = [
"--color-red", "--color-green", "--color-yellow",
"--color-blue", "--color-purple", "--color-aqua",
];
const palette: [number, number, number][] = [];
for (const key of keys) {
const val = style.getPropertyValue(key).trim();
if (val) {
const parts = val.split(" ").map(Number);
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
palette.push([parts[0], parts[1], parts[2]]);
}
}
}
return palette.length > 0 ? palette : FALLBACK_PALETTE;
} catch {
return FALLBACK_PALETTE;
}
interface Grid {
cells: Cell[][];
cols: number;
rows: number;
offsetX: number;
offsetY: number;
}
function readBgFromCSS(): string {
try {
const val = getComputedStyle(document.documentElement)
.getPropertyValue("--color-background")
.trim();
if (val) {
const [r, g, b] = val.split(" ");
return `rgb(${r}, ${g}, ${b})`;
}
} catch {}
return "rgb(0, 0, 0)";
interface MousePosition {
x: number;
y: number;
isDown: boolean;
lastClickTime: number;
cellX: number;
cellY: number;
}
interface BackgroundProps {
layout?: "index" | "sidebar" | "content";
position?: "left" | "right";
layout?: 'index' | 'sidebar';
position?: 'left' | 'right';
}
const CELL_SIZE_MOBILE = 15;
const CELL_SIZE_DESKTOP = 25;
const TARGET_FPS = 60; // Target frame rate
const CYCLE_TIME = 3000; // 3 seconds per full cycle, regardless of FPS
const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05;
const INITIAL_DENSITY = 0.15;
const SIDEBAR_WIDTH = 240;
const MOUSE_INFLUENCE_RADIUS = 150; // Radius of mouse influence in pixels
const COLOR_SHIFT_AMOUNT = 30; // Maximum color shift amount
const RIPPLE_SPEED = 0.02; // Speed of ripple propagation
const RIPPLE_ELEVATION_FACTOR = 4; // Height of ripple wave
const ELEVATION_FACTOR = 8; // Max height for 3D effect - reduced for more subtle effect
const Background: React.FC<BackgroundProps> = ({
layout = "index",
position = "left",
layout = 'index',
position = 'left'
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<AnimationEngine | null>(null);
const gridRef = useRef<Grid>();
const animationFrameRef = useRef<number>();
const lastUpdateTimeRef = useRef<number>(0);
const lastCycleTimeRef = useRef<number>(0);
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
const dimensionsRef = useRef({ width: 0, height: 0 });
const mouseRef = useRef<MousePosition>({
x: -1000,
y: -1000,
isDown: false,
lastClickTime: 0,
cellX: -1,
cellY: -1
});
const setupCanvas = (
canvas: HTMLCanvasElement,
width: number,
height: number
) => {
const ctx = canvas.getContext("2d");
const randomColor = (): [number, number, number] => {
const colors = [
[204, 36, 29], // red
[152, 151, 26], // green
[215, 153, 33], // yellow
[69, 133, 136], // blue
[177, 98, 134], // purple
[104, 157, 106] // aqua
];
return colors[Math.floor(Math.random() * colors.length)];
};
const getCellSize = () => {
// Check if we're on mobile based on screen width
const isMobile = window.innerWidth <= 768;
return isMobile ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
};
const calculateGridDimensions = (width: number, height: number) => {
const cellSize = getCellSize();
const cols = Math.floor(width / cellSize);
const rows = Math.floor(height / cellSize);
const offsetX = Math.floor((width - (cols * cellSize)) / 2);
const offsetY = Math.floor((height - (rows * cellSize)) / 2);
return { cols, rows, offsetX, offsetY };
};
const initGrid = (width: number, height: number): Grid => {
const { cols, rows, offsetX, offsetY } = calculateGridDimensions(width, height);
const cells = Array(cols).fill(0).map((_, i) =>
Array(rows).fill(0).map((_, j) => {
const baseColor = randomColor();
return {
alive: Math.random() < INITIAL_DENSITY,
next: false,
color: [...baseColor] as [number, number, number],
baseColor: baseColor,
currentX: i,
currentY: j,
targetX: i,
targetY: j,
opacity: 0,
targetOpacity: 0,
scale: 0,
targetScale: 0,
elevation: 0,
targetElevation: 0,
transitioning: false,
transitionComplete: false,
rippleEffect: 0,
rippleStartTime: 0,
rippleDistance: 0
};
})
);
const grid = { cells, cols, rows, offsetX, offsetY };
computeNextState(grid);
// Initialize cells with staggered animation
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const cell = cells[i][j];
if (cell.next) {
cell.alive = true;
setTimeout(() => {
cell.targetOpacity = 1;
cell.targetScale = 1;
}, Math.random() * 1000);
} else {
cell.alive = false;
}
}
}
return grid;
};
const countNeighbors = (grid: Grid, x: number, y: number): { count: number, colors: [number, number, number][] } => {
const neighbors = { count: 0, colors: [] as [number, number, number][] };
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
if (i === 0 && j === 0) continue;
const col = (x + i + grid.cols) % grid.cols;
const row = (y + j + grid.rows) % grid.rows;
if (grid.cells[col][row].alive) {
neighbors.count++;
neighbors.colors.push(grid.cells[col][row].baseColor);
}
}
}
return neighbors;
};
const averageColors = (colors: [number, number, number][]): [number, number, number] => {
if (colors.length === 0) return [0, 0, 0];
const sum = colors.reduce((acc, color) => [
acc[0] + color[0],
acc[1] + color[1],
acc[2] + color[2]
], [0, 0, 0]);
return [
Math.round(sum[0] / colors.length),
Math.round(sum[1] / colors.length),
Math.round(sum[2] / colors.length)
];
};
const computeNextState = (grid: Grid) => {
// First, calculate the next state for all cells based on standard rules
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
const { count, colors } = countNeighbors(grid, i, j);
// Standard Conway's Game of Life rules
if (cell.alive) {
cell.next = count === 2 || count === 3;
} else {
cell.next = count === 3;
if (cell.next) {
cell.baseColor = averageColors(colors);
cell.color = [...cell.baseColor];
}
}
}
}
// Then, set up animations for cells that need to change state
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
if (cell.alive !== cell.next && !cell.transitioning) {
cell.transitioning = true;
cell.transitionComplete = false;
// Random delay for staggered animation effect
const delay = Math.random() * 800;
setTimeout(() => {
if (!cell.next) {
cell.targetScale = 0;
cell.targetOpacity = 0;
cell.targetElevation = 0;
} else {
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
}
}, delay);
}
}
}
};
const createRippleEffect = (grid: Grid, centerX: number, centerY: number) => {
const currentTime = Date.now();
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Calculate distance from cell to ripple center
const dx = i - centerX;
const dy = j - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Only apply ripple to visible cells
if (cell.opacity > 0.1) {
cell.rippleStartTime = currentTime + distance * 100; // Delayed start based on distance
cell.rippleDistance = distance;
cell.rippleEffect = 0;
}
}
}
};
const spawnCellAtPosition = (grid: Grid, x: number, y: number) => {
if (x >= 0 && x < grid.cols && y >= 0 && y < grid.rows) {
const cell = grid.cells[x][y];
if (!cell.alive && !cell.transitioning) {
cell.alive = true;
cell.next = true;
cell.transitioning = true;
cell.transitionComplete = false;
cell.baseColor = randomColor();
cell.color = [...cell.baseColor];
cell.targetScale = 1;
cell.targetOpacity = 1;
cell.targetElevation = 0;
// Create a small ripple from the new cell
createRippleEffect(grid, x, y);
}
}
};
const updateCellAnimations = (grid: Grid, deltaTime: number) => {
const mouseX = mouseRef.current.x;
const mouseY = mouseRef.current.y;
const cellSize = getCellSize();
// Adjust transition speeds based on time
const transitionFactor = TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Smooth transitions
cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
cell.elevation += (cell.targetElevation - cell.elevation) * scaleFactor;
// Apply mouse interaction
const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
// 3D hill effect based on mouse position
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
// Calculate height based on distance - peak at center, gradually decreasing
const influenceFactor = Math.cos((distanceToMouse / MOUSE_INFLUENCE_RADIUS) * Math.PI / 2);
// Only positive elevation (growing upward)
cell.targetElevation = ELEVATION_FACTOR * influenceFactor * influenceFactor; // squared for more pronounced effect
// Slight color shift as cells rise
const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[2] + colorShift))
] as [number, number, number];
} else {
// Gradually return to base color and zero elevation when mouse is away
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
cell.targetElevation = 0;
}
// Handle cell state transitions
if (cell.transitioning) {
// When a cell is completely faded out, update its alive state
if (!cell.next && cell.opacity < 0.01 && cell.scale < 0.01) {
cell.alive = false;
cell.transitioning = false;
cell.transitionComplete = true;
cell.opacity = 0;
cell.scale = 0;
cell.elevation = 0;
}
// When a new cell is born
else if (cell.next && !cell.alive && !cell.transitionComplete) {
cell.alive = true;
cell.transitioning = false;
cell.transitionComplete = true;
}
}
// Handle ripple animation
if (cell.rippleStartTime > 0) {
const elapsedTime = Date.now() - cell.rippleStartTime;
if (elapsedTime > 0) {
// Calculate ripple progress (0 to 1)
const rippleProgress = elapsedTime / 1000; // 1 second for full animation
if (rippleProgress < 1) {
// Create a smooth wave effect
const wavePhase = rippleProgress * Math.PI * 2;
const waveHeight = Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
// Apply wave height to cell elevation only if it's not being overridden by mouse
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.rippleEffect = waveHeight;
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
} else {
cell.rippleEffect = waveHeight * 0.3; // Reduced effect when mouse is influencing
}
} else {
// Reset ripple effects
cell.rippleEffect = 0;
cell.rippleStartTime = 0;
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.targetElevation = 0;
}
}
}
}
}
}
};
const handleMouseDown = (e: MouseEvent) => {
if (!gridRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const cellSize = getCellSize();
mouseRef.current.isDown = true;
mouseRef.current.lastClickTime = Date.now();
const grid = gridRef.current;
// Calculate which cell was clicked
const cellX = Math.floor((mouseX - grid.offsetX) / cellSize);
const cellY = Math.floor((mouseY - grid.offsetY) / cellSize);
if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) {
mouseRef.current.cellX = cellX;
mouseRef.current.cellY = cellY;
const cell = grid.cells[cellX][cellY];
if (cell.alive) {
// Create ripple effect from existing cell
createRippleEffect(grid, cellX, cellY);
} else {
// Spawn new cell at empty position
spawnCellAtPosition(grid, cellX, cellY);
}
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!canvasRef.current || !gridRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const cellSize = getCellSize();
mouseRef.current.x = e.clientX - rect.left;
mouseRef.current.y = e.clientY - rect.top;
// Drawing functionality - place cells while dragging
if (mouseRef.current.isDown) {
const grid = gridRef.current;
// Calculate which cell the mouse is over
const cellX = Math.floor((mouseRef.current.x - grid.offsetX) / cellSize);
const cellY = Math.floor((mouseRef.current.y - grid.offsetY) / cellSize);
// Only draw if we're on a new cell
if (cellX !== mouseRef.current.cellX || cellY !== mouseRef.current.cellY) {
mouseRef.current.cellX = cellX;
mouseRef.current.cellY = cellY;
// Spawn cell at this position if it's empty
if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) {
const cell = grid.cells[cellX][cellY];
if (!cell.alive && !cell.transitioning) {
spawnCellAtPosition(grid, cellX, cellY);
}
}
}
}
};
const handleMouseUp = () => {
mouseRef.current.isDown = false;
};
const handleMouseLeave = () => {
mouseRef.current.isDown = false;
mouseRef.current.x = -1000;
mouseRef.current.y = -1000;
};
const setupCanvas = (canvas: HTMLCanvasElement, width: number, height: number) => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
@@ -100,58 +478,10 @@ const Background: React.FC<BackgroundProps> = ({
const canvas = canvasRef.current;
if (!canvas) return;
// Create an AbortController for cleanup
const controller = new AbortController();
const signal = controller.signal;
const displayWidth =
layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const displayHeight = window.innerHeight;
dimensionsRef.current = { width: displayWidth, height: displayHeight };
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return;
const palette = readPaletteFromCSS();
const bgColor = readBgFromCSS();
// Initialize engine
if (!engineRef.current) {
const animId = getStoredAnimationId();
engineRef.current = createEngine(animId);
engineRef.current.init(displayWidth, displayHeight, palette, bgColor);
}
// Handle animation switching
const handleAnimationChanged = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.id) return;
if (engineRef.current) {
engineRef.current.cleanup();
}
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const h = window.innerHeight;
engineRef.current = createEngine(detail.id);
engineRef.current.init(w, h, readPaletteFromCSS(), readBgFromCSS());
};
document.addEventListener("animation-changed", handleAnimationChanged, {
signal,
});
// Handle theme changes
const handleThemeChanged = () => {
const newPalette = readPaletteFromCSS();
const newBg = readBgFromCSS();
if (engineRef.current) {
engineRef.current.updatePalette(newPalette, newBg);
}
};
document.addEventListener("theme-changed", handleThemeChanged, { signal });
// Handle resize
const handleResize = () => {
if (signal.aborted) return;
@@ -162,122 +492,185 @@ const Background: React.FC<BackgroundProps> = ({
resizeTimeoutRef.current = setTimeout(() => {
if (signal.aborted) return;
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
const h = window.innerHeight;
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
const displayHeight = window.innerHeight;
const newCtx = setupCanvas(canvas, w, h);
if (!newCtx) return;
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return;
lastUpdateTimeRef.current = 0;
dimensionsRef.current = { width: w, height: h };
lastCycleTimeRef.current = 0;
if (engineRef.current) {
engineRef.current.handleResize(w, h);
const cellSize = getCellSize();
// Only initialize new grid if one doesn't exist or dimensions changed
if (!gridRef.current ||
gridRef.current.cols !== Math.floor(displayWidth / cellSize) ||
gridRef.current.rows !== Math.floor(displayHeight / cellSize)) {
gridRef.current = initGrid(displayWidth, displayHeight);
}
}, 250);
};
// Mouse events
const handleMouseDown = (e: MouseEvent) => {
if (!engineRef.current || !canvas) return;
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
const displayHeight = window.innerHeight;
// Don't spawn when clicking interactive elements
const target = e.target as HTMLElement;
if (target.closest("a, button, [role='button'], input, select, textarea, [onclick]")) return;
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (
mouseX < 0 ||
mouseX > rect.width ||
mouseY < 0 ||
mouseY > rect.height
)
return;
e.preventDefault();
engineRef.current.handleMouseDown(mouseX, mouseY);
};
const handleMouseMove = (e: MouseEvent) => {
if (!engineRef.current || !canvas) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
engineRef.current.handleMouseMove(mouseX, mouseY, e.buttons === 1);
};
const handleMouseUp = () => {
if (engineRef.current) {
engineRef.current.handleMouseUp();
// Only initialize grid if it doesn't exist
if (!gridRef.current) {
gridRef.current = initGrid(displayWidth, displayHeight);
}
};
const handleMouseLeave = () => {
if (engineRef.current) {
engineRef.current.handleMouseLeave();
}
};
// Add mouse event listeners
canvas.addEventListener('mousedown', handleMouseDown, { signal });
canvas.addEventListener('mousemove', handleMouseMove, { signal });
canvas.addEventListener('mouseup', handleMouseUp, { signal });
canvas.addEventListener('mouseleave', handleMouseLeave, { signal });
window.addEventListener("mousedown", handleMouseDown, { signal });
window.addEventListener("mousemove", handleMouseMove, { signal });
window.addEventListener("mouseup", handleMouseUp, { signal });
// Visibility change
const handleVisibilityChange = () => {
if (document.hidden) {
// Tab is hidden
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
} else {
// Tab is visible again
if (!animationFrameRef.current) {
// Reset timing references to prevent catching up
lastUpdateTimeRef.current = performance.now();
lastCycleTimeRef.current = performance.now();
animationFrameRef.current = requestAnimationFrame(animate);
}
}
};
// Animation loop
const animate = (currentTime: number) => {
if (signal.aborted) return;
// Initialize timing if first frame
if (!lastUpdateTimeRef.current) {
lastUpdateTimeRef.current = currentTime;
lastCycleTimeRef.current = currentTime;
}
// Calculate time since last frame
const deltaTime = currentTime - lastUpdateTimeRef.current;
// Limit delta time to prevent large jumps when tab becomes active again
const clampedDeltaTime = Math.min(deltaTime, 100);
lastUpdateTimeRef.current = currentTime;
const engine = engineRef.current;
if (engine) {
engine.update(clampedDeltaTime);
// Calculate time since last cycle update
const cycleElapsed = currentTime - lastCycleTimeRef.current;
// Clear canvas
const bg = readBgFromCSS();
ctx.fillStyle = bg;
if (gridRef.current) {
// Check if it's time for the next life cycle
if (cycleElapsed >= CYCLE_TIME) {
computeNextState(gridRef.current);
lastCycleTimeRef.current = currentTime;
}
updateCellAnimations(gridRef.current, clampedDeltaTime);
}
// Draw frame
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const { width: rw, height: rh } = dimensionsRef.current;
engine.render(ctx, rw, rh);
if (gridRef.current) {
const grid = gridRef.current;
const cellSize = getCellSize();
const displayCellSize = cellSize * 0.8;
const roundness = displayCellSize * 0.2;
for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j];
// Draw all transitioning cells, even if they're fading out
if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) {
const [r, g, b] = cell.color;
// Base opacity
ctx.globalAlpha = cell.opacity * 0.9;
const scaledSize = displayCellSize * cell.scale;
const xOffset = (displayCellSize - scaledSize) / 2;
const yOffset = (displayCellSize - scaledSize) / 2;
// Apply 3D elevation effect
const elevationOffset = cell.elevation;
const x = grid.offsetX + i * cellSize + (cellSize - displayCellSize) / 2 + xOffset;
const y = grid.offsetY + j * cellSize + (cellSize - displayCellSize) / 2 + yOffset - elevationOffset;
const scaledRoundness = roundness * cell.scale;
// Draw shadow for 3D effect when cell is elevated
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
ctx.lineTo(x + scaledSize - scaledRoundness, y + elevationOffset * 1.1);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset * 1.1, x + scaledSize, y + elevationOffset * 1.1 + scaledRoundness);
ctx.lineTo(x + scaledSize, y + elevationOffset * 1.1 + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset * 1.1 + scaledSize, x + scaledSize - scaledRoundness, y + elevationOffset * 1.1 + scaledSize);
ctx.lineTo(x + scaledRoundness, y + elevationOffset * 1.1 + scaledSize);
ctx.quadraticCurveTo(x, y + elevationOffset * 1.1 + scaledSize, x, y + elevationOffset * 1.1 + scaledSize - scaledRoundness);
ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
ctx.quadraticCurveTo(x, y + elevationOffset * 1.1, x + scaledRoundness, y + elevationOffset * 1.1);
ctx.fill();
}
// Draw main cell
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + scaledSize, x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.quadraticCurveTo(x, y + scaledSize, x, y + scaledSize - scaledRoundness);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
// Draw highlight on elevated cells
if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y);
ctx.quadraticCurveTo(x + scaledSize, y, x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize/3);
ctx.lineTo(x, y + scaledSize/3);
ctx.lineTo(x, y + scaledRoundness);
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill();
}
// No need for separate ripple drawing since the elevation handles the 3D ripple effect
}
}
}
ctx.globalAlpha = 1;
}
animationFrameRef.current = requestAnimationFrame(animate);
};
document.addEventListener("visibilitychange", handleVisibilityChange, {
signal,
});
window.addEventListener("resize", handleResize, { signal });
document.addEventListener('visibilitychange', handleVisibilityChange, { signal });
window.addEventListener('resize', handleResize, { signal });
animate(performance.now());
return () => {
controller.abort();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('resize', handleResize);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
@@ -285,45 +678,27 @@ const Background: React.FC<BackgroundProps> = ({
clearTimeout(resizeTimeoutRef.current);
}
};
}, [layout]);
const isIndex = layout === "index";
const isSidebar = !isIndex;
const getContainerStyle = (): React.CSSProperties => {
if (isIndex) return {};
// Fade the inner edge so blobs don't hard-cut at the content boundary
return {
maskImage:
position === "left"
? "linear-gradient(to right, black 60%, transparent 100%)"
: "linear-gradient(to left, black 60%, transparent 100%)",
WebkitMaskImage:
position === "left"
? "linear-gradient(to right, black 60%, transparent 100%)"
: "linear-gradient(to left, black 60%, transparent 100%)",
};
};
}, [layout]); // Added layout as a dependency since it's used in the effect
const getContainerClasses = () => {
if (isIndex) {
return "fixed inset-0 -z-10";
if (layout === 'index') {
return 'fixed inset-0 -z-10';
}
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10";
return position === "left"
const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10';
return position === 'left'
? `${baseClasses} left-0`
: `${baseClasses} right-0`;
};
return (
<div className={getContainerClasses()} style={getContainerStyle()}>
<div className={getContainerClasses()}>
<canvas
ref={canvasRef}
className="w-full h-full bg-background"
style={{ cursor: "default" }}
className="w-full h-full bg-black"
style={{ cursor: 'default' }} // Changed from cursor-pointer to default
/>
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm pointer-events-none" />
</div>
);
};

View File

@@ -1,17 +1,13 @@
import React from "react";
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
export const BlogHeader = () => {
return (
<div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
Latest Thoughts <br className="sm:hidden" />
& Writings
</h1>
</AnimateIn>
<AnimateIn delay={100}>
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
<a
href="/rss"
@@ -37,7 +33,6 @@ export const BlogHeader = () => {
<span>Most Popular</span>
</a>
</div>
</AnimateIn>
</div>
);
};

View File

@@ -1,8 +1,7 @@
import React from "react";
import { AnimateIn } from "@/components/animate-in";
type BlogPost = {
id: string;
slug: string;
data: {
title: string;
author: string;
@@ -30,16 +29,15 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
return (
<div className="w-full max-w-6xl mx-auto">
<ul className="space-y-6 md:space-y-10">
{posts.map((post, i) => (
<AnimateIn key={post.id} delay={i * 80}>
<li className="group px-4 md:px-0">
{posts.map((post) => (
<li key={post.slug} className="group px-4 md:px-0">
<a
href={`/blog/${post.id}`}
href={`/blog/${post.slug}`}
className="block"
>
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200">
<article className="flex flex-col md:flex-row gap-4 md:gap-8 pb-6 md:pb-10 border-b border-foreground/20 last:border-b-0 p-2 md:p-4 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200">
{/* Image container with fixed aspect ratio */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background">
<img
src={post.data.image || "/blog/placeholder.png"}
alt={post.data.title}
@@ -49,7 +47,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
</div>
{/* Content container */}
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-4 py-1 md:py-2">
{/* Title and meta info */}
<div className="space-y-1.5 md:space-y-3">
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-purple transition-colors duration-200 line-clamp-2">
@@ -94,7 +92,6 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { Links } from "@/components/header/links";
export default function Header({ transparent = false }: { transparent?: boolean }) {
export default function Header() {
const [isClient, setIsClient] = useState(false);
const [visible, setVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
@@ -34,7 +34,7 @@ export default function Header({ transparent = false }: { transparent?: boolean
return linkHref !== "/" && path.startsWith(linkHref);
};
const isIndexPage = transparent || checkIsActive("/");
const isIndexPage = checkIsActive("/");
const headerLinks = Links.map((link) => {
const isActive = checkIsActive(link.href);
@@ -44,7 +44,7 @@ export default function Header({ transparent = false }: { transparent?: boolean
className={`
relative inline-block
${link.color}
${!isIndexPage ? 'bg-background' : ''}
${!isIndexPage ? 'bg-black' : ''}
`}
>
<a
@@ -94,13 +94,13 @@ export default function Header({ transparent = false }: { transparent?: boolean
<div className={`
w-full flex flex-row items-center justify-center
pointer-events-none
${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
${!isIndexPage ? 'bg-black md:bg-transparent' : ''}
`}>
<div className={`
w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl
items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2
pointer-events-none [&_a]:pointer-events-auto
${!isIndexPage ? 'bg-background md:px-20' : ''}
${!isIndexPage ? 'bg-black md:px-20' : ''}
`}>
{headerLinks}
</div>

View File

@@ -0,0 +1,78 @@
import React from "react"
import type { CollectionEntry } from "astro:content";
interface ProjectCardProps {
project: CollectionEntry<"projects">;
}
export function ProjectCard({ project }: ProjectCardProps) {
const hasLinks = project.data.githubUrl || project.data.demoUrl;
return (
<article className="group relative h-full">
<a
href={`/projects/${project.slug}`}
className="block rounded-lg border-2 border-foreground/20
hover:border-blue transition-all duration-300
bg-background overflow-hidden h-full flex flex-col"
>
<div className="aspect-video w-full border-b border-foreground/20 bg-foreground/5 overflow-hidden flex-shrink-0">
{project.data.image ? (
<img
src={project.data.image}
alt={`${project.data.title} preview`}
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-foreground/30">
<span className="text-sm">No preview available</span>
</div>
)}
</div>
<div className="p-4 sm:p-6 space-y-3 flex flex-col flex-grow">
<h3 className="text-lg sm:text-xl font-bold group-hover:text-blue transition-colors">
{project.data.title}
</h3>
<div className="flex flex-wrap gap-2">
{project.data.techStack.map(tech => (
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-purple-bright/10 text-purple-bright">
{tech}
</span>
))}
</div>
<p className="text-foreground/70 text-sm sm:text-base flex-grow">
{project.data.description}
</p>
{hasLinks && (
<div className="flex gap-4 pt-3 border-t border-foreground/10 mt-auto">
{project.data.githubUrl && (
<a
href={project.data.githubUrl}
className="text-sm text-blue hover:text-blue-bright
transition-colors z-10"
onClick={(e) => e.stopPropagation()}
>
View Source
</a>
)}
{project.data.demoUrl && (
<a
href={project.data.demoUrl}
className="text-sm text-green hover:text-green-bright
transition-colors z-10"
onClick={(e) => e.stopPropagation()}
>
Live Link
</a>
)}
</div>
)}
</div>
</a>
</article>
);
}

View File

@@ -1,100 +1,49 @@
import React from "react";
import type { CollectionEntry } from "astro:content";
import { AnimateIn } from "@/components/animate-in";
import { ProjectCard } from "@/components/projects/project-card";
interface ProjectListProps {
projects: CollectionEntry<"projects">[];
}
export function ProjectList({ projects }: ProjectListProps) {
const latestProjects = projects.slice(0, 3);
const otherProjects = projects.slice(3);
return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32 px-4">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center leading-relaxed">
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32">
<h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center px-4 leading-relaxed">
Here's what I've been <br className="sm:hidden" />
building lately
</h1>
</AnimateIn>
<ul className="space-y-6 md:space-y-10">
{projects.map((project, i) => (
<AnimateIn key={project.id} delay={i * 80}>
<li className="group">
<a href={`/projects/${project.id}`} className="block">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-all duration-200">
{/* Image */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0">
{project.data.image ? (
<img
src={project.data.image}
alt={`${project.data.title} preview`}
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-foreground/30">
<span className="text-sm">No preview available</span>
</div>
)}
</div>
{/* Content */}
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-blue transition-colors duration-200 line-clamp-2">
{project.data.title}
<div className="px-4 mb-16">
<h2 className="text-xl font-bold text-foreground/90 mb-6">
Featured Projects
</h2>
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2">
{project.data.description}
</p>
{/* Tech stack */}
<div className="flex flex-wrap gap-1.5 md:gap-2 mt-1">
{project.data.techStack.map((tech) => (
<span
key={tech}
className="text-xs md:text-sm px-2 py-0.5 rounded-full bg-purple-bright/10 text-purple-bright"
>
{tech}
</span>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr justify-items-center">
{latestProjects.map(project => (
<div key={project.slug} className="w-full max-w-md">
<ProjectCard project={project} />
</div>
))}
</div>
</div>
{/* Links */}
{(project.data.githubUrl || project.data.demoUrl) && (
<div className="flex gap-4 mt-1">
{project.data.githubUrl && (
<span
className="text-sm text-foreground/50 hover:text-blue-bright transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(project.data.githubUrl, "_blank");
}}
>
Source
</span>
)}
{project.data.demoUrl && (
<span
className="text-sm text-foreground/50 hover:text-green-bright transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(project.data.demoUrl, "_blank");
}}
>
Live
</span>
)}
{otherProjects.length > 0 && (
<div className="px-4 pb-8">
<h2 className="text-xl font-bold text-foreground/90 mb-6">
All Projects
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr justify-items-center">
{otherProjects.map(project => (
<div key={project.slug} className="w-full max-w-md">
<ProjectCard project={project} />
</div>
)}
</div>
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React from "react";
import {
FileDown,
Github,
@@ -6,144 +6,6 @@ import {
Globe
} from "lucide-react";
// --- Typewriter hook ---
function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
let i = 0;
setDisplayed("");
setDone(false);
const interval = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
setDone(true);
clearInterval(interval);
}
}, speed);
return () => clearInterval(interval);
}, [trigger, text, speed]);
return { displayed, done };
}
// --- Visibility hook ---
function useScrollVisible(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, visible };
}
// --- Section fade-in ---
function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const { ref, visible } = useScrollVisible();
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
// --- Typed heading + fade-in body ---
function TypedSection({
heading,
headingClass = "text-3xl font-bold text-yellow-bright",
children,
}: {
heading: string;
headingClass?: string;
children: React.ReactNode;
}) {
const { ref, visible } = useScrollVisible();
const { displayed, done } = useTypewriter(heading, visible, 20);
return (
<div ref={ref} className="space-y-4">
<h3 className={headingClass} style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="transition-all duration-500 ease-out"
style={{
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
}}
>
{children}
</div>
</div>
);
}
// --- Staggered skill tags ---
function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean }) {
return (
<div className="flex flex-wrap gap-3">
{skills.map((skill, i) => (
<span
key={i}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-all duration-500 ease-out"
style={{
transitionDelay: `${i * 60}ms`,
opacity: trigger ? 1 : 0,
transform: trigger ? "translateY(0) scale(1)" : "translateY(12px) scale(0.95)",
}}
>
{skill}
</span>
))}
</div>
);
}
// --- Data ---
const resumeData = {
name: "Timothy Pidashev",
title: "Software Engineer",
@@ -183,7 +45,7 @@ const resumeData = {
achievements: [
"Designed and built the entire application from the ground up, including auth",
"Engineered a tagging system to optimize search results by keywords and relativity",
"Implemented a filter provider to further narrow down search results and enhance the user experience",
"Implemented a filter provider to further narrow down search results and enchance the user experience",
"Created a smooth and responsive infinitely scrollable listings page",
"Automated deployment & testing processes reducing downtime by 60%"
]
@@ -195,17 +57,22 @@ const resumeData = {
school: "Clark College",
location: "Vancouver, WA",
period: "Graduating 2026",
achievements: [] as string[]
achievements: []
}
],
skills: {
technical: ["JavaScript", "TypeScript", "React", "Node.js", "Python", "SQL", "AWS", "Docker", "C", "Go", "Rust"],
soft: ["Leadership", "Problem Solving", "Communication", "Agile Methodologies"]
},
certifications: [
{
name: "AWS Certified Solutions Architect",
issuer: "Amazon Web Services",
date: "2022"
}
]
};
// --- Component ---
const Resume = () => {
const handleDownloadPDF = () => {
window.open("/timothy-pidashev-resume.pdf", "_blank");
@@ -216,13 +83,8 @@ const Resume = () => {
<div className="space-y-16">
{/* Header */}
<header className="text-center space-y-6">
<Section>
<h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
</Section>
<Section delay={150}>
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2>
</Section>
<Section delay={300}>
<div className="flex flex-col md:flex-row justify-center gap-4 md:gap-6 text-foreground/60 text-lg">
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200">
{resumeData.contact.email}
@@ -234,8 +96,6 @@ const Resume = () => {
<span className="hidden md:inline"></span>
<span>{resumeData.contact.location}</span>
</div>
</Section>
<Section delay={450}>
<div className="flex justify-center items-center gap-6 text-lg">
<a href={`https://${resumeData.contact.github}`}
target="_blank"
@@ -244,6 +104,7 @@ const Resume = () => {
<Github size={18} />
GitHub
</a>
<a href={`https://${resumeData.contact.linkedin}`}
target="_blank"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
@@ -259,20 +120,19 @@ const Resume = () => {
Resume
</button>
</div>
</Section>
</header>
{/* Summary */}
<TypedSection heading="Professional Summary">
<section className="space-y-4">
<h3 className="text-3xl font-bold text-yellow-bright">Professional Summary</h3>
<p className="text-xl leading-relaxed">{resumeData.summary}</p>
</TypedSection>
</section>
{/* Experience */}
<TypedSection heading="Experience">
<div className="space-y-8">
<section className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright">Experience</h3>
{resumeData.experience.map((exp, index) => (
<Section key={index} delay={index * 100}>
<div className="space-y-4">
<div key={index} className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<h4 className="text-2xl font-semibold text-green-bright">{exp.title}</h4>
@@ -281,22 +141,19 @@ const Resume = () => {
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div>
</div>
<ul className="list-disc pl-6 space-y-3">
{exp.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
{exp.achievements.map((achievement, i) => (
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
))}
</ul>
</div>
</Section>
))}
</div>
</TypedSection>
</section>
{/* Contract Work */}
<TypedSection heading="Contract Work">
<div className="space-y-8">
<section className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright">Contract Work</h3>
{resumeData.contractWork.map((project, index) => (
<Section key={index} delay={index * 100}>
<div className="space-y-4">
<div key={index} className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<div className="flex items-center gap-3">
@@ -307,6 +164,7 @@ const Resume = () => {
target="_blank"
rel="noopener noreferrer"
className="text-blue-bright hover:text-blue transition-colors duration-200 opacity-60 hover:opacity-100"
aria-label={`Visit ${project.title}`}
>
<Globe size={16} strokeWidth={1.5} />
</a>
@@ -321,8 +179,8 @@ const Resume = () => {
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<ul className="list-disc pl-6 space-y-3">
{project.responsibilities.map((r, i) => (
<li key={i} className="text-lg leading-relaxed">{r}</li>
{project.responsibilities.map((responsibility, i) => (
<li key={i} className="text-lg leading-relaxed">{responsibility}</li>
))}
</ul>
</div>
@@ -331,25 +189,22 @@ const Resume = () => {
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<ul className="list-disc pl-6 space-y-3">
{project.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
{project.achievements.map((achievement, i) => (
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
))}
</ul>
</div>
)}
</div>
</div>
</Section>
))}
</div>
</TypedSection>
</section>
{/* Education */}
<TypedSection heading="Education">
<div className="space-y-8">
<section className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright">Education</h3>
{resumeData.education.map((edu, index) => (
<Section key={index}>
<div className="space-y-4">
<div key={index} className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<h4 className="text-2xl font-semibold text-green-bright">{edu.degree}</h4>
@@ -357,57 +212,59 @@ const Resume = () => {
</div>
<div className="text-foreground/60 text-lg font-medium">{edu.period}</div>
</div>
{edu.achievements.length > 0 && (
<ul className="list-disc pl-6 space-y-3">
{edu.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
{edu.achievements.map((achievement, i) => (
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
))}
</ul>
)}
</div>
</Section>
))}
</div>
</TypedSection>
</section>
{/* Skills */}
<SkillsSection />
<section className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright">Skills</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4>
<div className="flex flex-wrap gap-3">
{resumeData.skills.technical.map((skill, index) => (
<span key={index}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-colors duration-200">
{skill}
</span>
))}
</div>
</div>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4>
<div className="flex flex-wrap gap-3">
{resumeData.skills.soft.map((skill, index) => (
<span key={index}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-colors duration-200">
{skill}
</span>
))}
</div>
</div>
</div>
</section>
{/* Certifications */}
{/* Temporarily Hidden
<section className="space-y-6 mb-16">
<h3 className="text-3xl font-bold text-yellow-bright">Certifications</h3>
{resumeData.certifications.map((cert, index) => (
<div key={index} className="space-y-2">
<h4 className="text-2xl font-semibold text-green-bright">{cert.name}</h4>
<div className="text-foreground/60 text-lg">{cert.issuer} - {cert.date}</div>
</div>
))}
</section>
*/}
</div>
</div>
);
};
// --- Skills section ---
function SkillsSection() {
const { ref, visible } = useScrollVisible();
const { displayed, done } = useTypewriter("Skills", visible, 20);
return (
<div ref={ref} className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-all duration-500 ease-out"
style={{
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
}}
>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4>
<SkillTags skills={resumeData.skills.technical} trigger={done} />
</div>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4>
<SkillTags skills={resumeData.skills.soft} trigger={done} />
</div>
</div>
</div>
);
}
export default Resume;

View File

@@ -1,98 +0,0 @@
import { useRef, useState, useEffect } from "react";
import { getStoredThemeId, getNextTheme, applyTheme } from "@/lib/themes/engine";
const FADE_DURATION = 300;
const LABELS: Record<string, string> = {
darkbox: "classic",
"darkbox-retro": "retro",
"darkbox-dim": "dim",
};
export default function ThemeSwitcher() {
const [hovering, setHovering] = useState(false);
const [nextLabel, setNextLabel] = useState("");
const maskRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
const committedRef = useRef("");
useEffect(() => {
committedRef.current = getStoredThemeId();
setNextLabel(LABELS[getNextTheme(committedRef.current).id] ?? "");
const handleSwap = () => {
const id = getStoredThemeId();
applyTheme(id);
committedRef.current = id;
setNextLabel(LABELS[getNextTheme(id).id] ?? "");
};
document.addEventListener("astro:after-swap", handleSwap);
return () => {
document.removeEventListener("astro:after-swap", handleSwap);
};
}, []);
const handleClick = () => {
if (animatingRef.current) return;
animatingRef.current = true;
const mask = maskRef.current;
if (!mask) return;
const v = getComputedStyle(document.documentElement)
.getPropertyValue("--color-background")
.trim();
const [r, g, b] = v.split(" ").map(Number);
mask.style.backgroundColor = `rgb(${r},${g},${b})`;
mask.style.opacity = "1";
mask.style.visibility = "visible";
mask.style.transition = "none";
const next = getNextTheme(committedRef.current);
applyTheme(next.id);
committedRef.current = next.id;
setNextLabel(LABELS[getNextTheme(next.id).id] ?? "");
mask.offsetHeight;
mask.style.transition = `opacity ${FADE_DURATION}ms ease-out`;
mask.style.opacity = "0";
const onEnd = () => {
mask.removeEventListener("transitionend", onEnd);
mask.style.visibility = "hidden";
mask.style.transition = "none";
animatingRef.current = false;
};
mask.addEventListener("transitionend", onEnd);
};
return (
<>
<div
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden md:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200"
style={{ opacity: hovering ? 0.8 : 0.15 }}
>
{nextLabel}
</span>
</div>
<div
ref={maskRef}
className="fixed inset-0 z-[100] pointer-events-none"
style={{ visibility: "hidden", opacity: 0 }}
/>
</>
);
}

View File

@@ -5,5 +5,4 @@ author: Timothy Pidashev
tags: ["uefi", "coreboot", "firmware", "chromebooks"]
date: 2025-09-15
image: "/blog/breaking-the-chromebook-cage/thumbnail.png"
isDraft: true
---

View File

@@ -5,7 +5,6 @@ author: Timothy Pidashev
tags: [t440p, coreboot, thinkpad]
date: 2025-01-15
image: "/blog/thinkpad-t440p-coreboot-guide/thumbnail.png"
isDraft: true
---
import { Commands, Command, CommandSequence } from "@/components/mdx/command";
@@ -25,7 +24,6 @@ import Advertisement from '@/content/blog/components/thinkpad-t440p-coreboot-gui
Don't pipe anyone's scripts to **sh** blindly, including mine - <a href="https://github.com/timmypidashev/scripts" target="_blank" rel="noopener noreferrer">audit the source</a>.
## Getting Started
The Thinkpad T440p is a powerful and versatile laptop that can be further enhanced by installing coreboot,
an open-source BIOS replacement. This guide will walk you through the process of corebooting your T440p,
including flashing the BIOS chip and installing the necessary software.
@@ -203,64 +201,22 @@ Configuring coreboot is really where most of your time will be spent. To help ou
I've created several handy configs that should suit most use cases, and can be easily
tweaked to your liking. Here is a list of whats available:
### 1. GRUB2 (Recommended)
1. GRUB2
GRUB2 is the recommended payload for most users. It boots Linux directly without needing a
separate bootloader installation on disk. This configuration includes three secondary payloads:
This configuration features GRUB2 as the bootloader, and contains 3 secondary payloads,
which the user can opt in/out of:
- **memtest86+** - Memory testing utility
- **nvramcui** - CMOS/NVRAM settings editor
- **coreinfo** - System information viewer
* memtest built in
* nvramcui built in
* coreinfo built in
If your T440p has the optional GT730M dGPU, the GRUB2 config also includes the
necessary VGA option ROM for it.
This configuration also includes the dGPU option rom as well for T440p's featuring the gt730m on board.
### 2. SeaBIOS
2. SeaBIOS
SeaBIOS provides a traditional BIOS interface, making it the most compatible option.
Choose this if you need to boot operating systems that expect a legacy BIOS, such
as Windows or BSD.
3. edk2
### 3. edk2 (UEFI)
edk2 provides a UEFI firmware interface. Choose this if you prefer UEFI boot or
need UEFI-specific features.
---
If using the interactive script, it will prompt you to choose a payload and apply
a preset configuration automatically. You can also choose to open the full
configuration menu (`make nconfig`) to customize further.
For manual configuration, first copy the extracted blobs into place:
<CommandSequence
commands={[
"mkdir -p ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell",
"mkdir -p ~/t440p-coreboot/coreboot/3rdparty/blobs/cpu/intel/haswell",
"cp ~/t440p-coreboot/ifd.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/descriptor.bin",
"cp ~/t440p-coreboot/me.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/me.bin",
"cp ~/t440p-coreboot/gbe.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/gbe.bin",
"cp ~/t440p-coreboot/mrc.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/cpu/intel/haswell/mrc.bin"
]}
description="Copy firmware blobs into the coreboot source tree"
client:load
/>
Then open the configuration menu:
<Command
description="Open coreboot configuration"
command="cd ~/t440p-coreboot/coreboot && make nconfig"
client:load
/>
Key settings to configure:
- **Mainboard** &rarr; Mainboard vendor: **Lenovo** &rarr; Mainboard model: **ThinkPad T440p**
- **Chipset** &rarr; Add Intel descriptor.bin, ME firmware, and GbE configuration (set paths to your blobs)
- **Chipset** &rarr; Add haswell MRC file (set path to mrc.bin)
- **Payload** &rarr; Choose your preferred payload (GRUB2, SeaBIOS, or edk2)
> NOTE: Show the user how to choose the appropriate config, as well as building a custom config below.
## Building and Flashing
@@ -282,7 +238,7 @@ Once the coreboot build has completed, split the built ROM for the 8MB(bottom) c
commands={[
"cd ~/t440p-coreboot/coreboot/build",
"dd if=coreboot.rom of=bottom.rom bs=1M count=8",
"dd if=coreboot.rom of=top.rom bs=1M skip=8"
"dd if=coreboot.rom of=top.rom bs=1M skin=8"
]}
description="Split the built ROM for both EEPROM chips"
client:load

View File

@@ -1,9 +1,7 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
export const collections = {
blog: defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
@@ -14,11 +12,9 @@ export const collections = {
}),
image: z.string().optional(),
imagePosition: z.string().optional(),
isDraft: z.boolean().optional()
}),
}),
projects: defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/projects" }),
schema: z.object({
title: z.string(),
description: z.string(),
@@ -26,7 +22,7 @@ export const collections = {
demoUrl: z.string().url().optional(),
techStack: z.array(z.string()),
date: z.string(),
image: z.string().optional()
image: z.string().optional(),
}),
})
};

View File

@@ -5,10 +5,6 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props {
title: string;
@@ -24,13 +20,17 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter
@@ -41,6 +41,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
::view-transition-new(:root) {
animation: none;
}
::view-transition-old(:root) {
animation: 90ms ease-out both fade-out;
}
@@ -49,8 +50,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground min-h-screen flex flex-col">
<Header client:load />
@@ -66,9 +65,10 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<div class="mt-auto">
<Footer client:load transition:persist />
</div>
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
<script>
document.addEventListener("astro:after-navigation", () => {
window.scrollTo(0, 0);
});
</script>
</body>
</html>

View File

@@ -8,10 +8,6 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props {
title: string;
@@ -27,30 +23,28 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<ClientRouter />
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground">
<Header client:load transparent />
<Header client:load />
<main transition:animate="fade">
<Background layout="index" client:only="react" transition:persist />
<slot />
</main>
<Footer client:load transition:persist fixed=true />
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>
</html>

View File

@@ -5,10 +5,6 @@ import { ClientRouter } from "astro:transitions";
import Header from "@/components/header";
import Footer from "@/components/footer";
import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
export interface Props {
title: string;
@@ -24,13 +20,17 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<title>{title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter
@@ -41,6 +41,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
::view-transition-new(:root) {
animation: none;
}
::view-transition-old(:root) {
animation: 90ms ease-out both fade-out;
}
@@ -49,8 +50,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground min-h-screen flex flex-col">
<main class="flex-1 flex flex-col">
@@ -62,9 +61,10 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<Background layout="content" position="left" client:only="react" transition:persist />
</div>
</main>
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
<script>
document.addEventListener("astro:after-navigation", () => {
window.scrollTo(0, 0);
});
</script>
</body>
</html>

View File

@@ -1,20 +0,0 @@
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
import type { AnimationId } from "./index";
export function getStoredAnimationId(): AnimationId {
if (typeof window === "undefined") return DEFAULT_ANIMATION_ID;
const stored = localStorage.getItem("animation");
if (stored && (ANIMATION_IDS as readonly string[]).includes(stored)) {
return stored as AnimationId;
}
return DEFAULT_ANIMATION_ID;
}
export function saveAnimation(id: AnimationId): void {
localStorage.setItem("animation", id);
}
export function getNextAnimation(currentId: AnimationId): AnimationId {
const idx = ANIMATION_IDS.indexOf(currentId);
return ANIMATION_IDS[(idx + 1) % ANIMATION_IDS.length];
}

View File

@@ -1,9 +0,0 @@
export const ANIMATION_IDS = ["game-of-life", "lava-lamp", "confetti"] as const;
export type AnimationId = (typeof ANIMATION_IDS)[number];
export const DEFAULT_ANIMATION_ID: AnimationId = "game-of-life";
export const ANIMATION_LABELS: Record<AnimationId, string> = {
"game-of-life": "life",
"lava-lamp": "lava",
"confetti": "confetti",
};

View File

@@ -1,7 +0,0 @@
import { ANIMATION_IDS, DEFAULT_ANIMATION_ID } from "./index";
const VALID_IDS = JSON.stringify(ANIMATION_IDS);
export const ANIMATION_LOADER_SCRIPT = `(function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";})();`;
export const ANIMATION_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){var id=localStorage.getItem("animation");if(id&&${VALID_IDS}.indexOf(id)!==-1)document.documentElement.dataset.animation=id;else document.documentElement.dataset.animation="${DEFAULT_ANIMATION_ID}";});`;

View File

@@ -1,33 +0,0 @@
export interface AnimationEngine {
id: string;
name: string;
init(
width: number,
height: number,
palette: [number, number, number][],
bgColor: string
): void;
update(deltaTime: number): void;
render(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void;
handleResize(width: number, height: number): void;
handleMouseMove(x: number, y: number, isDown: boolean): void;
handleMouseDown(x: number, y: number): void;
handleMouseUp(): void;
handleMouseLeave(): void;
updatePalette(palette: [number, number, number][], bgColor: string): void;
cleanup(): void;
}

View File

@@ -41,7 +41,7 @@ export function getArticleSchema(post: CollectionEntry<"blog">) {
"@context": "https://schema.org",
"@type": "Article",
headline: post.data.title,
url: `${import.meta.env.SITE}/blog/${post.id}/`,
url: `${import.meta.env.SITE}/blog/${post.slug}/`,
description: post.data.excerpt,
datePublished: post.data.date.toString(),
publisher: {

View File

@@ -1,59 +0,0 @@
import { THEMES, DEFAULT_THEME_ID } from "./index";
import { CSS_PROPS } from "./props";
import type { Theme } from "./types";
export function getStoredThemeId(): string {
if (typeof window === "undefined") return DEFAULT_THEME_ID;
return localStorage.getItem("theme") || DEFAULT_THEME_ID;
}
export function saveTheme(id: string): void {
localStorage.setItem("theme", id);
}
export function getNextTheme(currentId: string): Theme {
const list = Object.values(THEMES);
const idx = list.findIndex((t) => t.id === currentId);
return list[(idx + 1) % list.length];
}
/** Sets CSS vars and notifies canvas, but does NOT persist to localStorage. */
export function previewTheme(id: string): void {
const theme = THEMES[id];
if (!theme) return;
const root = document.documentElement;
for (const [key, prop] of CSS_PROPS) {
root.style.setProperty(prop, theme.colors[key]);
}
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
}
export function applyTheme(id: string): void {
const theme = THEMES[id];
if (!theme) return;
// Set CSS vars on :root for immediate visual update
const root = document.documentElement;
for (const [key, prop] of CSS_PROPS) {
root.style.setProperty(prop, theme.colors[key]);
}
// Update <style id="theme-vars"> so Astro view transitions don't revert
let el = document.getElementById("theme-vars") as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = "theme-vars";
document.head.appendChild(el);
}
let css = ":root{";
for (const [key, prop] of CSS_PROPS) {
css += `${prop}:${theme.colors[key]};`;
}
css += "}";
el.textContent = css;
saveTheme(id);
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
}

View File

@@ -1,58 +0,0 @@
import type { Theme } from "./types";
export const DEFAULT_THEME_ID = "darkbox";
function theme(
id: string,
name: string,
type: "dark" | "light",
colors: Theme["colors"],
palette: [number, number, number][]
): Theme {
return { id, name, type, colors, canvasPalette: palette };
}
// Three darkbox variants from darkbox.nvim
// Classic (vivid) → Retro (muted) → Dim (deep)
// Each variant's "bright" is the next level up's base.
export const THEMES: Record<string, Theme> = {
darkbox: theme("darkbox", "Darkbox Classic", "dark", {
background: "0 0 0",
foreground: "235 219 178",
red: "251 73 52", redBright: "255 110 85",
orange: "254 128 25", orangeBright: "255 165 65",
green: "184 187 38", greenBright: "210 215 70",
yellow: "250 189 47", yellowBright: "255 215 85",
blue: "131 165 152", blueBright: "165 195 180",
purple: "211 134 155", purpleBright: "235 165 180",
aqua: "142 192 124", aquaBright: "175 220 160",
surface: "60 56 54",
}, [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]]),
"darkbox-retro": theme("darkbox-retro", "Darkbox Retro", "dark", {
background: "0 0 0",
foreground: "189 174 147",
red: "204 36 29", redBright: "251 73 52",
orange: "214 93 14", orangeBright: "254 128 25",
green: "152 151 26", greenBright: "184 187 38",
yellow: "215 153 33", yellowBright: "250 189 47",
blue: "69 133 136", blueBright: "131 165 152",
purple: "177 98 134", purpleBright: "211 134 155",
aqua: "104 157 106", aquaBright: "142 192 124",
surface: "60 56 54",
}, [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]]),
"darkbox-dim": theme("darkbox-dim", "Darkbox Dim", "dark", {
background: "0 0 0",
foreground: "168 153 132",
red: "157 0 6", redBright: "204 36 29",
orange: "175 58 3", orangeBright: "214 93 14",
green: "121 116 14", greenBright: "152 151 26",
yellow: "181 118 20", yellowBright: "215 153 33",
blue: "7 102 120", blueBright: "69 133 136",
purple: "143 63 113", purpleBright: "177 98 134",
aqua: "66 123 88", aquaBright: "104 157 106",
surface: "60 56 54",
}, [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]]),
};

View File

@@ -1,25 +0,0 @@
/**
* Generates the inline <script> content for theme loading.
* Called at build time in Astro frontmatter.
* The script reads "theme" from localStorage, looks up colors, injects a <style> tag.
*/
import { THEMES } from "./index";
import { CSS_PROPS } from "./props";
// Pre-build a { prop: value } map for each theme at build time
const themeVars: Record<string, Record<string, string>> = {};
for (const [id, theme] of Object.entries(THEMES)) {
const vars: Record<string, string> = {};
for (const [key, prop] of CSS_PROPS) {
vars[prop] = theme.colors[key];
}
themeVars[id] = vars;
}
// Sets inline styles on <html> — highest specificity, beats any stylesheet
const APPLY = `var v=t[id];if(!v)return;var s=document.documentElement.style;for(var k in v)s.setProperty(k,v[k])`;
const LOOKUP = `var id=localStorage.getItem("theme");if(!id)return;var t=${JSON.stringify(themeVars)};`;
export const THEME_LOADER_SCRIPT = `(function(){${LOOKUP}${APPLY}})();`;
export const THEME_NAV_SCRIPT = `document.addEventListener("astro:after-navigation",function(){${LOOKUP}${APPLY}});`;

View File

@@ -1,21 +0,0 @@
import type { ThemeColors } from "./types";
export const CSS_PROPS: [keyof ThemeColors, string][] = [
["background", "--color-background"],
["foreground", "--color-foreground"],
["red", "--color-red"],
["redBright", "--color-red-bright"],
["orange", "--color-orange"],
["orangeBright", "--color-orange-bright"],
["green", "--color-green"],
["greenBright", "--color-green-bright"],
["yellow", "--color-yellow"],
["yellowBright", "--color-yellow-bright"],
["blue", "--color-blue"],
["blueBright", "--color-blue-bright"],
["purple", "--color-purple"],
["purpleBright", "--color-purple-bright"],
["aqua", "--color-aqua"],
["aquaBright", "--color-aqua-bright"],
["surface", "--color-surface"],
];

View File

@@ -1,27 +0,0 @@
export interface ThemeColors {
background: string;
foreground: string;
red: string;
redBright: string;
orange: string;
orangeBright: string;
green: string;
greenBright: string;
yellow: string;
yellowBright: string;
blue: string;
blueBright: string;
purple: string;
purpleBright: string;
aqua: string;
aquaBright: string;
surface: string;
}
export interface Theme {
id: string;
name: string;
type: "dark" | "light";
colors: ThemeColors;
canvasPalette: [number, number, number][];
}

View File

@@ -17,23 +17,23 @@ import OutsideCoding from "@/components/about/outside-coding";
<Intro client:load />
</section>
<section class="min-h-[60vh] flex items-center justify-center py-16">
<AllTimeStats client:load />
<section class="flex items-center justify-center py-16">
<AllTimeStats client:only="react" />
</section>
<section class="min-h-screen flex items-center justify-center py-16">
<DetailedStats client:load />
<section class="flex items-center justify-center py-16">
<DetailedStats client:only="react" />
</section>
<section class="min-h-[80vh] flex items-center justify-center py-16">
<section class="flex items-center justify-center py-16">
<Timeline client:load />
</section>
<section class="min-h-[80vh] flex items-center justify-center py-16">
<section class="flex items-center justify-center py-16">
<CurrentFocus client:load />
</section>
<section class="min-h-[50vh] flex items-center justify-center py-16">
<section class="flex items-center justify-center py-16">
<OutsideCoding client:load />
</section>
</div>

View File

@@ -3,13 +3,6 @@ import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try {
const response = await fetch(
'https://wakatime.com/api/v1/users/current/summaries?range=last_6_months', {

View File

@@ -1,35 +1,22 @@
import type { APIRoute } from "astro";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try {
const response = await fetch(
"https://wakatime.com/api/v1/users/current/all_time_since_today",
{
headers: {
Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
},
}
);
const { stdout } = await execAsync(`curl -H "Authorization: Basic ${import.meta.env.WAKATIME_API_KEY}" https://wakatime.com/api/v1/users/current/all_time_since_today`);
const data = await response.json();
return new Response(JSON.stringify(data), {
return new Response(stdout, {
status: 200,
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json"
}
});
} catch (error) {
console.error("WakaTime alltime API error:", error);
return new Response(
JSON.stringify({ error: "Failed to fetch stats" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return new Response(JSON.stringify({ error: "Failed to fetch stats" }), {
status: 500
});
}
};
}

View File

@@ -4,13 +4,6 @@ import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try {
const response = await fetch(
'https://wakatime.com/api/v1/users/current/stats/last_7_days?timeout=15', {

View File

@@ -1,5 +1,5 @@
---
import { getCollection, render } from "astro:content";
import { getCollection } from "astro:content";
import { Image } from "astro:assets";
import ContentLayout from "@/layouts/content.astro";
import { getArticleSchema } from "@/lib/structuredData";
@@ -11,17 +11,17 @@ const { slug } = Astro.params;
// Fetch blog posts
const posts = await getCollection("blog");
const post = posts.find(post => post.id === slug);
const post = posts.find(post => post.slug === slug);
if (!post || (!import.meta.env.DEV && post.data.isDraft === true)) {
if (!post) {
return new Response(null, {
status: 404,
statusText: "Not found"
statusText: 'Not found'
});
}
// Dynamically render the content
const { Content } = await render(post);
const { Content } = await post.render();
// Format the date
const formattedDate = new Date(post.data.date).toLocaleDateString("en-US", {
@@ -46,7 +46,7 @@ const breadcrumbsStructuredData = {
"@type": "ListItem",
position: 2,
name: post.data.title,
item: `${import.meta.env.SITE}/blog/${post.id}/`,
item: `${import.meta.env.SITE}/blog/${post.slug}/`,
},
],
};
@@ -65,7 +65,7 @@ const jsonLd = {
<div class="relative max-w-8xl mx-auto">
<article class="prose prose-invert prose-lg mx-auto max-w-4xl">
{post.data.image && (
<div class="-mx-4 sm:mx-0 mb-4">
<div class="-mx-4 sm:mx-0 mb-8">
<Image
src={post.data.image}
alt={post.data.title}
@@ -76,8 +76,9 @@ const jsonLd = {
/>
</div>
)}
<h1 class="text-3xl !mt-2 !mb-2">{post.data.title}</h1>
<p class="lg:text-2xl sm:text-lg !mt-0 !mb-3">{post.data.description}</p>
<h1 class="text-3xl pt-4">{post.data.title}</h1>
<p class="lg:text-2xl sm:text-lg">{post.data.description}</p>
<div class="mt-4 md:mt-6">
<div class="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
<span class="text-orange">{post.data.author}</span>
<span class="text-foreground/50">•</span>
@@ -85,7 +86,8 @@ const jsonLd = {
{formattedDate}
</time>
</div>
<div class="flex flex-wrap gap-2 mt-2">
</div>
<div class="flex flex-wrap gap-2 mt-4 md:mt-6">
{post.data.tags.map((tag) => (
<span
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"

View File

@@ -5,7 +5,7 @@ import { BlogHeader } from "@/components/blog/header";
import { BlogPostList } from "@/components/blog/post-list";
const posts = (await getCollection("blog", ({ data }) => {
return import.meta.env.DEV || data.isDraft !== true;
return data.isDraft !== true;
})).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({

View File

@@ -5,7 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
import TagList from "@/components/blog/tag-list";
const posts = (await getCollection("blog", ({ data }) => {
return import.meta.env.DEV || data.isDraft !== true;
return data.isDraft !== true;
})).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({

View File

@@ -1,7 +1,7 @@
---
export const prerender = true;
import { getCollection, render } from "astro:content";
import { getCollection } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import { Comments } from "@/components/blog/comments";
@@ -9,13 +9,13 @@ import { Comments } from "@/components/blog/comments";
export async function getStaticPaths() {
const projects = await getCollection("projects");
return projects.map(project => ({
params: { slug: project.id },
params: { slug: project.slug },
props: { project },
}));
}
const { project } = Astro.props;
const { Content } = await render(project);
const { Content } = await project.render();
---
<ContentLayout title={`${project.data.title} | Timothy Pidashev`}>

View File

@@ -16,11 +16,11 @@ export async function GET(context: APIContext) {
title: post.data.title,
pubDate: post.data.date,
description: post.data.description,
link: `/blog/${post.id}/`,
link: `/blog/${post.slug}/`,
author: post.data.author,
categories: post.data.tags,
enclosure: post.data.image ? {
url: new URL(`blog/${post.id}/thumbnail.png`, context.site).toString(),
url: new URL(`blog/${post.slug}/thumbnail.png`, context.site).toString(),
type: 'image/jpeg',
length: 0
} : undefined

View File

@@ -1,23 +1,3 @@
:root {
--color-background: 0 0 0;
--color-foreground: 235 219 178;
--color-red: 251 73 52;
--color-red-bright: 255 110 85;
--color-orange: 254 128 25;
--color-orange-bright: 255 165 65;
--color-green: 184 187 38;
--color-green-bright: 210 215 70;
--color-yellow: 250 189 47;
--color-yellow-bright: 255 215 85;
--color-blue: 131 165 152;
--color-blue-bright: 165 195 180;
--color-purple: 211 134 155;
--color-purple-bright: 235 165 180;
--color-aqua: 142 192 124;
--color-aqua-bright: 175 220 160;
--color-surface: 60 56 54;
}
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -6,35 +6,35 @@ module.exports = {
"comic-code": ["Comic Code", "monospace"],
},
colors: {
background: "rgb(var(--color-background) / <alpha-value>)",
foreground: "rgb(var(--color-foreground) / <alpha-value>)",
background: "#000000",
foreground: "#ebdbb2",
red: {
DEFAULT: "rgb(var(--color-red) / <alpha-value>)",
bright: "rgb(var(--color-red-bright) / <alpha-value>)"
DEFAULT: "#cc241d",
bright: "#fb4934"
},
orange: {
DEFAULT: "rgb(var(--color-orange) / <alpha-value>)",
bright: "rgb(var(--color-orange-bright) / <alpha-value>)"
DEFAULT: "#d65d0e",
bright: "#fe8019"
},
green: {
DEFAULT: "rgb(var(--color-green) / <alpha-value>)",
bright: "rgb(var(--color-green-bright) / <alpha-value>)"
DEFAULT: "#98971a",
bright: "#b8bb26"
},
yellow: {
DEFAULT: "rgb(var(--color-yellow) / <alpha-value>)",
bright: "rgb(var(--color-yellow-bright) / <alpha-value>)"
DEFAULT: "#d79921",
bright: "#fabd2f"
},
blue: {
DEFAULT: "rgb(var(--color-blue) / <alpha-value>)",
bright: "rgb(var(--color-blue-bright) / <alpha-value>)"
DEFAULT: "#458588",
bright: "#83a598"
},
purple: {
DEFAULT: "rgb(var(--color-purple) / <alpha-value>)",
bright: "rgb(var(--color-purple-bright) / <alpha-value>)"
DEFAULT: "#b16286",
bright: "#d3869b"
},
aqua: {
DEFAULT: "rgb(var(--color-aqua) / <alpha-value>)",
bright: "rgb(var(--color-aqua-bright) / <alpha-value>)"
DEFAULT: "#689d6a",
bright: "#8ec07c"
}
},
keyframes: {
@@ -51,82 +51,86 @@ module.exports = {
"draw-line": "draw-line 0.6s ease-out forwards",
"fade-in": "fade-in 0.3s ease-in-out forwards"
},
typography: () => ({
typography: (theme) => ({
DEFAULT: {
css: {
color: "rgb(var(--color-foreground))",
"--tw-prose-body": "rgb(var(--color-foreground))",
"--tw-prose-headings": "rgb(var(--color-yellow-bright))",
"--tw-prose-links": "rgb(var(--color-blue-bright))",
"--tw-prose-bold": "rgb(var(--color-orange-bright))",
"--tw-prose-quotes": "rgb(var(--color-green-bright))",
"--tw-prose-code": "rgb(var(--color-purple-bright))",
"--tw-prose-hr": "rgb(var(--color-foreground))",
"--tw-prose-bullets": "rgb(var(--color-foreground))",
color: theme("colors.foreground"),
"--tw-prose-body": theme("colors.foreground"),
"--tw-prose-headings": theme("colors.yellow.bright"),
"--tw-prose-links": theme("colors.blue.bright"),
"--tw-prose-bold": theme("colors.orange.bright"),
"--tw-prose-quotes": theme("colors.green.bright"),
"--tw-prose-code": theme("colors.purple.bright"),
"--tw-prose-hr": theme("colors.foreground"),
"--tw-prose-bullets": theme("colors.foreground"),
// Base text color
color: theme("colors.foreground"),
// Headings
h1: {
color: "rgb(var(--color-yellow-bright))",
color: theme("colors.yellow.bright"),
fontWeight: "700",
},
h2: {
color: "rgb(var(--color-yellow-bright))",
color: theme("colors.yellow.bright"),
fontWeight: "600",
},
h3: {
color: "rgb(var(--color-yellow-bright))",
color: theme("colors.yellow.bright"),
fontWeight: "600",
},
h4: {
color: "rgb(var(--color-yellow-bright))",
color: theme("colors.yellow.bright"),
fontWeight: "600",
},
// Links
a: {
color: "rgb(var(--color-blue-bright))",
color: theme("colors.blue.bright"),
"&:hover": {
color: "rgb(var(--color-blue))",
color: theme("colors.blue.DEFAULT"),
},
textDecoration: "none",
borderBottom: "1px solid rgb(var(--color-blue-bright))",
borderBottom: `1px solid ${theme("colors.blue.bright")}`,
transition: "all 0.2s ease-in-out",
},
// Code
'code:not([data-language])': {
color: "rgb(var(--color-purple-bright))",
backgroundColor: "rgb(var(--color-surface))",
color: theme('colors.purple.bright'),
backgroundColor: '#282828',
padding: '0',
borderRadius: '0.25rem',
fontFamily: 'Comic Code, monospace',
fontWeight: '400',
fontSize: 'inherit',
fontSize: 'inherit', // Match the parent text size
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'pre': {
backgroundColor: "rgb(var(--color-surface))",
color: "rgb(var(--color-foreground))",
backgroundColor: '#282828',
color: theme("colors.foreground"),
borderRadius: '0.5rem',
overflow: 'visible',
position: 'relative',
marginTop: '1.5rem',
fontSize: 'inherit',
overflow: 'visible', // This allows the copy button to be positioned outside
position: 'relative', // For the copy button positioning
marginTop: '1.5rem', // Space for the copy button and language label
fontSize: 'inherit', // Match the parent font size
},
'pre code': {
display: 'block',
fontFamily: 'Comic Code, monospace',
fontSize: '1em',
fontSize: '1em', // This will inherit from the prose-lg setting
padding: '0',
overflow: 'auto',
overflow: 'auto', // Enable horizontal scrolling
whiteSpace: 'pre',
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'[data-rehype-pretty-code-fragment]:nth-of-type(2) pre': {
'[data-line]::before': {
content: 'counter(line)',
@@ -144,7 +148,7 @@ module.exports = {
// Bold
strong: {
color: "rgb(var(--color-orange-bright))",
color: theme("colors.orange.bright"),
fontWeight: "600",
},
@@ -152,15 +156,15 @@ module.exports = {
ul: {
li: {
"&::before": {
backgroundColor: "rgb(var(--color-foreground))",
backgroundColor: theme("colors.foreground"),
},
},
},
// Blockquotes
blockquote: {
borderLeftColor: "rgb(var(--color-green-bright))",
color: "rgb(var(--color-green-bright))",
borderLeftColor: theme("colors.green.bright"),
color: theme("colors.green.bright"),
fontStyle: "italic",
quotes: "\"\\201C\"\"\\201D\"\"\\2018\"\"\\2019\"",
p: {
@@ -171,21 +175,21 @@ module.exports = {
// Horizontal rules
hr: {
borderColor: "rgb(var(--color-foreground))",
borderColor: theme("colors.foreground"),
opacity: "0.2",
},
// Table
table: {
thead: {
borderBottomColor: "rgb(var(--color-foreground))",
borderBottomColor: theme("colors.foreground"),
th: {
color: "rgb(var(--color-yellow-bright))",
color: theme("colors.yellow.bright"),
},
},
tbody: {
tr: {
borderBottomColor: "rgb(var(--color-foreground))",
borderBottomColor: theme("colors.foreground"),
},
},
},
@@ -197,7 +201,7 @@ module.exports = {
// Figures
figcaption: {
color: "rgb(var(--color-foreground))",
color: theme("colors.foreground"),
opacity: "0.8",
},
},