mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
Compare commits
5 Commits
dependabot
...
174ca69dcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
174ca69dcd
|
|||
|
f6f9c15e0c
|
|||
|
16902f00f4
|
|||
|
2c5f64a769
|
|||
|
b2cd74385f
|
@@ -8,36 +8,36 @@
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/react": "^4.4.0",
|
||||
"@astrojs/react": "^5.0.2",
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/react": "^18.3.20",
|
||||
"@types/react-dom": "^18.3.6",
|
||||
"astro": "^5.14.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"astro": "^6.1.2",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.6",
|
||||
"@astrojs/node": "^9.4.4",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@astrojs/sitemap": "^3.6.0",
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
"@astrojs/node": "^10.0.4",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@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.6.0",
|
||||
"arctic": "^3.7.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"marked": "^15.0.8",
|
||||
"marked": "^15.0.12",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rehype-pretty-code": "^0.14.3",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"shiki": "^3.12.2",
|
||||
"typewriter-effect": "^2.21.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
"shiki": "^3.23.0",
|
||||
"typewriter-effect": "^2.22.0",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
3172
src/pnpm-lock.yaml
generated
3172
src/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Code2, BookOpen, RocketIcon, Compass } from 'lucide-react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CurrentFocus() {
|
||||
const recentProjects = [
|
||||
@@ -7,126 +59,134 @@ 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">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12">
|
||||
Current Focus
|
||||
</h2>
|
||||
<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">
|
||||
<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 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) => (
|
||||
<a
|
||||
href={project.href}
|
||||
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}
|
||||
</h4>
|
||||
<p className="text-foreground/70 mt-2 text-sm sm:text-base">{project.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{project.tech.map((tech) => (
|
||||
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-foreground/5 text-foreground/60">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
{recentProjects.map((project, i) => (
|
||||
<AnimateIn key={project.title} delay={200 + i * 100}>
|
||||
<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"
|
||||
>
|
||||
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
|
||||
{project.title}
|
||||
</h4>
|
||||
<p className="text-foreground/70 mt-2 text-sm sm:text-base">{project.description}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{project.tech.map((tech) => (
|
||||
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-foreground/5 text-foreground/60">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</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">
|
||||
{/* 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>
|
||||
<AnimateIn delay={100}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<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>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>Rust Programming</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>WebAssembly with Rust</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>HTTP/3 & WebTransport</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>Rust Programming</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>WebAssembly with Rust</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
|
||||
<span>HTTP/3 & WebTransport</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
|
||||
{/* 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>
|
||||
<AnimateIn delay={200}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<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>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>AI Model Integration</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>Rust Systems Programming</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>Cross-platform WASM Apps</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>AI Model Integration</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>Rust Systems Programming</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
|
||||
<span>Cross-platform WASM Apps</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
|
||||
{/* 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>
|
||||
<AnimateIn delay={300}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<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>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>LLM Fine-tuning</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>Rust 2024 Edition</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>Real-time Web Transport</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>LLM Fine-tuning</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>Rust 2024 Edition</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
|
||||
<span>Real-time Web Transport</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,56 +1,98 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } 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.offsetTop - (window.innerHeight - nextSection.offsetHeight) / 2; // Center the section
|
||||
window.scrollTo({
|
||||
top: offset,
|
||||
behavior: "smooth"
|
||||
});
|
||||
const offset = (nextSection as HTMLElement).offsetTop - (window.innerHeight - (nextSection as HTMLElement).offsetHeight) / 2;
|
||||
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 className="w-full max-w-4xl px-4">
|
||||
<div ref={ref} 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">
|
||||
<div
|
||||
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"
|
||||
style={anim(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">
|
||||
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
|
||||
<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">
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}>
|
||||
<span className="text-blue">Software Systems Engineer</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2">
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}>
|
||||
<span className="text-green">Open Source Enthusiast</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2">
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}>
|
||||
<span className="text-yellow">Coffee Connoisseur</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-8" style={anim(750)}>
|
||||
<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 –
|
||||
"Turning coffee into code" isn't just a clever phrase –
|
||||
<span className="text-aqua-bright"> it's how I approach each project:</span>
|
||||
<span className="text-purple-bright"> methodically,</span>
|
||||
<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">
|
||||
<button
|
||||
<div className="flex justify-center" style={anim(900)}>
|
||||
<button
|
||||
onClick={scrollToNext}
|
||||
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
|
||||
aria-label="Scroll to next section"
|
||||
|
||||
@@ -1,64 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Fish, Mountain, Book, Car } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Cross, Fish, Mountain, Book } 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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
icon: <Mountain className="text-green-bright" size={20} />,
|
||||
title: "Hiking",
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
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"
|
||||
},
|
||||
{
|
||||
icon: <Mountain className="text-green-bright" size={20} />,
|
||||
title: "Hiking",
|
||||
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"
|
||||
},
|
||||
{
|
||||
icon: <Car className="text-yellow-bright" size={20} />,
|
||||
title: "Project Cars",
|
||||
description: "Working on automotive projects, modifying & restoring sporty sedans"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<div className="w-full max-w-4xl px-4 py-8">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8">
|
||||
Outside of Programming
|
||||
</h2>
|
||||
|
||||
<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) => (
|
||||
<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"
|
||||
>
|
||||
<div className="mb-3">
|
||||
{interest.icon}
|
||||
{interests.map((interest, i) => (
|
||||
<AnimateIn key={interest.title} delay={100 + i * 100}>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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-blue-bright"> out on the water,</span>
|
||||
<span className="text-green-bright"> hiking trails,</span>
|
||||
<span className="text-purple-bright"> reading books,</span>
|
||||
<span className="text-yellow-bright"> or modifying my current ride.</span>
|
||||
</p>
|
||||
<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>
|
||||
</p>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from "react";
|
||||
|
||||
export const ActivityGrid = () => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
interface ActivityDay {
|
||||
grand_total: { total_seconds: number };
|
||||
date: string;
|
||||
}
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
interface ActivityGridProps {
|
||||
data: ActivityDay[];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
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"];
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Get intensity based on coding hours (0-4 for different shades)
|
||||
const getIntensity = (hours) => {
|
||||
const getIntensity = (hours: number) => {
|
||||
if (hours === 0) return 0;
|
||||
if (hours < 2) return 1;
|
||||
if (hours < 4) return 2;
|
||||
@@ -36,20 +21,18 @@ export const ActivityGrid = () => {
|
||||
return 4;
|
||||
};
|
||||
|
||||
// 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 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";
|
||||
};
|
||||
|
||||
// Group data by week
|
||||
const weeks = [];
|
||||
let currentWeek = [];
|
||||
|
||||
if (data.length > 0) {
|
||||
const weeks: ActivityDay[][] = [];
|
||||
let currentWeek: ActivityDay[] = [];
|
||||
|
||||
if (data && data.length > 0) {
|
||||
data.forEach((day, index) => {
|
||||
currentWeek.push(day);
|
||||
if (currentWeek.length === 7 || index === data.length - 1) {
|
||||
@@ -59,31 +42,19 @@ export const ActivityGrid = () => {
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
|
||||
<div className="text-lg text-aqua-bright mb-6">Activity</div>
|
||||
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* 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 */}
|
||||
@@ -94,17 +65,16 @@ export const ActivityGrid = () => {
|
||||
{week.map((day, dayIndex) => {
|
||||
const hours = day.grand_total.total_seconds / 3600;
|
||||
const intensity = getIntensity(hours);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
|
||||
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
|
||||
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
|
||||
<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">
|
||||
{hours.toFixed(1)} hours on {day.date}
|
||||
</div>
|
||||
@@ -120,10 +90,10 @@ export const ActivityGrid = () => {
|
||||
const date = new Date(week[0].date);
|
||||
const isFirstOfMonth = date.getDate() <= 7;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
<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>
|
||||
@@ -136,10 +106,7 @@ export const ActivityGrid = () => {
|
||||
<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>
|
||||
|
||||
@@ -1,98 +1,115 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } 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(() => {
|
||||
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();
|
||||
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));
|
||||
}, []);
|
||||
|
||||
const startCounting = (totalSeconds: number) => {
|
||||
// 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 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 (!stats) return null;
|
||||
if (error) return null;
|
||||
if (!stats) return <div ref={sectionRef} className="min-h-[50vh]" />;
|
||||
|
||||
const hours = Math.floor(count / 3600);
|
||||
const formattedHours = hours.toLocaleString("en-US", {
|
||||
minimumIntegerDigits: 4,
|
||||
useGrouping: true
|
||||
useGrouping: true,
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<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" : ""}
|
||||
`}>
|
||||
<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" : ""}`}>
|
||||
I've spent
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="text-8xl text-center relative z-10">
|
||||
<span className="font-bold relative">
|
||||
<span className={`
|
||||
bg-gradient-text opacity-0
|
||||
${isVisible ? "animate-fade-in-second" : ""}
|
||||
`}>
|
||||
<span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
|
||||
{formattedHours}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`
|
||||
text-4xl opacity-0
|
||||
${isVisible ? "animate-slide-in-hours" : ""}
|
||||
`}>
|
||||
<span className={skipAnim ? "text-4xl opacity-60 ml-4" : `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={`
|
||||
text-xl opacity-0
|
||||
${isVisible ? "animate-fade-in-third" : ""}
|
||||
`}>
|
||||
<div className={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
|
||||
writing code & building apps
|
||||
</div>
|
||||
|
||||
<div className={`
|
||||
flex items-center gap-3 text-lg opacity-0
|
||||
${isVisible ? "animate-fade-in-fourth" : ""}
|
||||
`}>
|
||||
<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" : ""}`}>
|
||||
<span>since</span>
|
||||
<span className="text-green-bright font-bold">{stats.range.start_text}</span>
|
||||
</div>
|
||||
@@ -100,14 +117,13 @@ const Stats = () => {
|
||||
|
||||
<style jsx>{`
|
||||
.bg-gradient-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#fbbf24,
|
||||
#f59e0b,
|
||||
#d97706,
|
||||
#b45309,
|
||||
#f59e0b,
|
||||
#fbbf24
|
||||
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-size: 200% auto;
|
||||
color: transparent;
|
||||
@@ -116,95 +132,32 @@ const Stats = () => {
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
animation: gradient 4s linear infinite;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.8; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInSecond {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { 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;
|
||||
}
|
||||
from { opacity: 0; transform: translateX(20px); margin-left: 0; }
|
||||
to { opacity: 0.6; transform: translateX(0); margin-left: 1rem; }
|
||||
}
|
||||
|
||||
@keyframes fadeInThird {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.8; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInFourth {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { 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;
|
||||
}
|
||||
.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; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,66 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
|
||||
|
||||
import { ActivityGrid } from "@/components/about/stats-activity";
|
||||
|
||||
const DetailedStats = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [activity, setActivity] = useState(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/wakatime/detailed")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setStats(data.data);
|
||||
setIsVisible(true);
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching stats:", error);
|
||||
});
|
||||
.then((data) => setStats(data.data))
|
||||
.catch(() => setError(true));
|
||||
|
||||
fetch("/api/wakatime/activity")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setActivity(data.data);
|
||||
fetch("/api/wakatime/activity")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching activity:", error);
|
||||
});
|
||||
.then((data) => setActivity(data.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!stats) return null;
|
||||
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;
|
||||
|
||||
const progressColors = [
|
||||
"bg-red-bright",
|
||||
@@ -38,138 +69,163 @@ 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 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>
|
||||
<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)",
|
||||
}}
|
||||
>
|
||||
Weekly Statistics
|
||||
</h2>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
{/* 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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
{/* 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="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>
|
||||
</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`}
|
||||
style={{
|
||||
width: visible ? `${lang.percent}%` : "0%",
|
||||
opacity: 0.85,
|
||||
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-foreground/50 min-w-[36px] text-right tabular-nums">
|
||||
{lang.percent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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={`
|
||||
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>
|
||||
{/* 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>
|
||||
</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-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 ${item.color} rounded-full transition-all duration-1000`}
|
||||
style={{
|
||||
width: item.value,
|
||||
opacity: '0.8'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-foreground/60 min-w-[70px] text-right">{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DetailedStats;
|
||||
|
||||
@@ -1,78 +1,181 @@
|
||||
import React from "react";
|
||||
import { Check, Code, GitBranch, Star } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Check, Code, GitBranch, Star, Rocket } 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} />,
|
||||
},
|
||||
{
|
||||
year: "2024",
|
||||
title: "Shipping & Scaling",
|
||||
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} />,
|
||||
},
|
||||
{
|
||||
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} />,
|
||||
},
|
||||
{
|
||||
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} />,
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
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
|
||||
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"}
|
||||
`}
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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}
|
||||
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"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Timeline() {
|
||||
const timelineItems = [
|
||||
{
|
||||
year: "2024",
|
||||
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} />
|
||||
},
|
||||
{
|
||||
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} />
|
||||
},
|
||||
{
|
||||
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} />
|
||||
},
|
||||
{
|
||||
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} />
|
||||
}
|
||||
];
|
||||
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">
|
||||
Journey Through Code
|
||||
My 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 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) => (
|
||||
<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">
|
||||
{item.icon}
|
||||
</div>
|
||||
<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}
|
||||
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">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineCard key={item.year} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
50
src/src/components/animate-in.tsx
Normal file
50
src/src/components/animate-in.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
58
src/src/components/animation-switcher/index.tsx
Normal file
58
src/src/components/animation-switcher/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
329
src/src/components/background/engines/confetti.ts
Normal file
329
src/src/components/background/engines/confetti.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
615
src/src/components/background/engines/game-of-life.ts
Normal file
615
src/src/components/background/engines/game-of-life.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
498
src/src/components/background/engines/lava-lamp.ts
Normal file
498
src/src/components/background/engines/lava-lamp.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,476 +1,98 @@
|
||||
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";
|
||||
|
||||
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
|
||||
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 Grid {
|
||||
cells: Cell[][];
|
||||
cols: number;
|
||||
rows: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
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 MousePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
isDown: boolean;
|
||||
lastClickTime: number;
|
||||
cellX: number;
|
||||
cellY: 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 BackgroundProps {
|
||||
layout?: 'index' | 'sidebar';
|
||||
position?: 'left' | 'right';
|
||||
layout?: "index" | "sidebar" | "content";
|
||||
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'
|
||||
const Background: React.FC<BackgroundProps> = ({
|
||||
layout = "index",
|
||||
position = "left",
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gridRef = useRef<Grid>();
|
||||
const engineRef = useRef<AnimationEngine | null>(null);
|
||||
const animationFrameRef = useRef<number>();
|
||||
const lastUpdateTimeRef = useRef<number>(0);
|
||||
const lastCycleTimeRef = useRef<number>(0);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const mouseRef = useRef<MousePosition>({
|
||||
x: -1000,
|
||||
y: -1000,
|
||||
isDown: false,
|
||||
lastClickTime: 0,
|
||||
cellX: -1,
|
||||
cellY: -1
|
||||
});
|
||||
const dimensionsRef = useRef({ width: 0, height: 0 });
|
||||
|
||||
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');
|
||||
const setupCanvas = (
|
||||
canvas: HTMLCanvasElement,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
|
||||
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -478,199 +100,184 @@ 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;
|
||||
|
||||
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
|
||||
const displayHeight = window.innerHeight;
|
||||
|
||||
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
|
||||
if (!ctx) return;
|
||||
const w = layout === "index" ? window.innerWidth : SIDEBAR_WIDTH;
|
||||
const h = window.innerHeight;
|
||||
|
||||
const newCtx = setupCanvas(canvas, w, h);
|
||||
if (!newCtx) return;
|
||||
|
||||
lastUpdateTimeRef.current = 0;
|
||||
lastCycleTimeRef.current = 0;
|
||||
|
||||
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);
|
||||
dimensionsRef.current = { width: w, height: h };
|
||||
|
||||
if (engineRef.current) {
|
||||
engineRef.current.handleResize(w, h);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const displayWidth = layout === 'index' ? window.innerWidth : SIDEBAR_WIDTH;
|
||||
const displayHeight = window.innerHeight;
|
||||
|
||||
const ctx = setupCanvas(canvas, displayWidth, displayHeight);
|
||||
if (!ctx) return;
|
||||
// Mouse events
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (!engineRef.current || !canvas) return;
|
||||
|
||||
// Only initialize grid if it doesn't exist
|
||||
if (!gridRef.current) {
|
||||
gridRef.current = initGrid(displayWidth, displayHeight);
|
||||
}
|
||||
// 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;
|
||||
|
||||
// Add mouse event listeners
|
||||
canvas.addEventListener('mousedown', handleMouseDown, { signal });
|
||||
canvas.addEventListener('mousemove', handleMouseMove, { signal });
|
||||
canvas.addEventListener('mouseup', handleMouseUp, { signal });
|
||||
canvas.addEventListener('mouseleave', handleMouseLeave, { signal });
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (engineRef.current) {
|
||||
engineRef.current.handleMouseLeave();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// Calculate time since last cycle update
|
||||
const cycleElapsed = currentTime - lastCycleTimeRef.current;
|
||||
|
||||
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);
|
||||
|
||||
if (gridRef.current) {
|
||||
const grid = gridRef.current;
|
||||
const cellSize = getCellSize();
|
||||
const displayCellSize = cellSize * 0.8;
|
||||
const roundness = displayCellSize * 0.2;
|
||||
const engine = engineRef.current;
|
||||
if (engine) {
|
||||
engine.update(clampedDeltaTime);
|
||||
|
||||
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;
|
||||
// Clear canvas
|
||||
const bg = readBgFromCSS();
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
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;
|
||||
const { width: rw, height: rh } = dimensionsRef.current;
|
||||
engine.render(ctx, rw, rh);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -678,27 +285,45 @@ const Background: React.FC<BackgroundProps> = ({
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [layout]); // Added layout as a dependency since it's used in the effect
|
||||
}, [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%)",
|
||||
};
|
||||
};
|
||||
|
||||
const getContainerClasses = () => {
|
||||
if (layout === 'index') {
|
||||
return 'fixed inset-0 -z-10';
|
||||
if (isIndex) {
|
||||
return "fixed inset-0 -z-10";
|
||||
}
|
||||
|
||||
const baseClasses = 'fixed top-0 bottom-0 hidden lg:block -z-10';
|
||||
return position === 'left'
|
||||
? `${baseClasses} left-0`
|
||||
|
||||
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()}>
|
||||
<div className={getContainerClasses()} style={getContainerStyle()}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full bg-black"
|
||||
style={{ cursor: 'default' }} // Changed from cursor-pointer to default
|
||||
className="w-full h-full bg-background"
|
||||
style={{ cursor: "default" }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
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">
|
||||
<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>
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
|
||||
<a
|
||||
href="/rss"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-orange hover:text-orange-bright hover:border-orange/50 transition-colors duration-200"
|
||||
>
|
||||
<RssIcon className="w-4 h-4" />
|
||||
<span>RSS Feed</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/blog/tags"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-aqua hover:text-aqua-bright hover:border-aqua/50 transition-colors duration-200"
|
||||
>
|
||||
<TagIcon className="w-4 h-4" />
|
||||
<span>Browse Tags</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/blog/popular"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-blue hover:text-blue-bright hover:border-blue/50 transition-colors duration-200"
|
||||
>
|
||||
<TrendingUpIcon className="w-4 h-4" />
|
||||
<span>Most Popular</span>
|
||||
</a>
|
||||
</div>
|
||||
<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"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-orange hover:text-orange-bright hover:border-orange/50 transition-colors duration-200"
|
||||
>
|
||||
<RssIcon className="w-4 h-4" />
|
||||
<span>RSS Feed</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/blog/tags"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-aqua hover:text-aqua-bright hover:border-aqua/50 transition-colors duration-200"
|
||||
>
|
||||
<TagIcon className="w-4 h-4" />
|
||||
<span>Browse Tags</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/blog/popular"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-blue hover:text-blue-bright hover:border-blue/50 transition-colors duration-200"
|
||||
>
|
||||
<TrendingUpIcon className="w-4 h-4" />
|
||||
<span>Most Popular</span>
|
||||
</a>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
type BlogPost = {
|
||||
slug: string;
|
||||
id: string;
|
||||
data: {
|
||||
title: string;
|
||||
author: string;
|
||||
@@ -27,71 +28,73 @@ const formatDate = (dateString: string) => {
|
||||
|
||||
export const BlogPostList = ({ posts }: BlogPostListProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto">
|
||||
<div className="w-full max-w-6xl mx-auto">
|
||||
<ul className="space-y-6 md:space-y-10">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug} className="group px-4 md:px-0">
|
||||
<a
|
||||
href={`/blog/${post.slug}`}
|
||||
className="block"
|
||||
>
|
||||
<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">
|
||||
<img
|
||||
src={post.data.image || "/blog/placeholder.png"}
|
||||
alt={post.data.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
style={{ objectPosition: post.data.imagePosition || "center center" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content container */}
|
||||
<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">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
|
||||
<span className="text-orange">{post.data.author}</span>
|
||||
<span className="text-foreground/50">•</span>
|
||||
<time dateTime={post.data.date} className="text-blue">
|
||||
{formatDate(post.data.date)}
|
||||
</time>
|
||||
{posts.map((post, i) => (
|
||||
<AnimateIn key={post.id} delay={i * 80}>
|
||||
<li className="group px-4 md:px-0">
|
||||
<a
|
||||
href={`/blog/${post.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-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">
|
||||
<img
|
||||
src={post.data.image || "/blog/placeholder.png"}
|
||||
alt={post.data.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
style={{ objectPosition: post.data.imagePosition || "center center" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content container */}
|
||||
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
|
||||
{/* 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">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
|
||||
<span className="text-orange">{post.data.author}</span>
|
||||
<span className="text-foreground/50">•</span>
|
||||
<time dateTime={post.data.date} className="text-blue">
|
||||
{formatDate(post.data.date)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2 mt-0.5 md:mt-0">
|
||||
{post.data.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
|
||||
{post.data.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `/blog/tag/${tag}`;
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{post.data.tags.length > 3 && (
|
||||
<span className="text-xs md:text-base text-foreground/60">
|
||||
+{post.data.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2 mt-0.5 md:mt-0">
|
||||
{post.data.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
|
||||
{post.data.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `/blog/tag/${tag}`;
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{post.data.tags.length > 3 && (
|
||||
<span className="text-xs md:text-base text-foreground/60">
|
||||
+{post.data.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</li>
|
||||
</article>
|
||||
</a>
|
||||
</li>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Links } from "@/components/header/links";
|
||||
|
||||
export default function Header() {
|
||||
export default function Header({ transparent = false }: { transparent?: boolean }) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [lastScrollY, setLastScrollY] = useState(0);
|
||||
@@ -34,7 +34,7 @@ export default function Header() {
|
||||
return linkHref !== "/" && path.startsWith(linkHref);
|
||||
};
|
||||
|
||||
const isIndexPage = checkIsActive("/");
|
||||
const isIndexPage = transparent || checkIsActive("/");
|
||||
const headerLinks = Links.map((link) => {
|
||||
const isActive = checkIsActive(link.href);
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function Header() {
|
||||
className={`
|
||||
relative inline-block
|
||||
${link.color}
|
||||
${!isIndexPage ? 'bg-black' : ''}
|
||||
${!isIndexPage ? 'bg-background' : ''}
|
||||
`}
|
||||
>
|
||||
<a
|
||||
@@ -94,13 +94,13 @@ export default function Header() {
|
||||
<div className={`
|
||||
w-full flex flex-row items-center justify-center
|
||||
pointer-events-none
|
||||
${!isIndexPage ? 'bg-black md:bg-transparent' : ''}
|
||||
${!isIndexPage ? 'bg-background 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-black md:px-20' : ''}
|
||||
${!isIndexPage ? 'bg-background md:px-20' : ''}
|
||||
`}>
|
||||
{headerLinks}
|
||||
</div>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,100 @@
|
||||
import React from "react";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { ProjectCard } from "@/components/projects/project-card";
|
||||
import { AnimateIn } from "@/components/animate-in";
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<div className="px-4 mb-16">
|
||||
<h2 className="text-xl font-bold text-foreground/90 mb-6">
|
||||
Featured Projects
|
||||
</h2>
|
||||
<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>
|
||||
<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">
|
||||
Here's what I've been <br className="sm:hidden" />
|
||||
building lately
|
||||
</h1>
|
||||
</AnimateIn>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</li>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,149 @@
|
||||
import React from "react";
|
||||
import {
|
||||
FileDown,
|
||||
Github,
|
||||
Linkedin,
|
||||
Globe
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
FileDown,
|
||||
Github,
|
||||
Linkedin,
|
||||
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",
|
||||
@@ -45,8 +183,8 @@ 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 enchance the user experience",
|
||||
"Created a smooth and responsive infinitely scrollable listings page",
|
||||
"Implemented a filter provider to further narrow down search results and enhance the user experience",
|
||||
"Created a smooth and responsive infinitely scrollable listings page",
|
||||
"Automated deployment & testing processes reducing downtime by 60%"
|
||||
]
|
||||
}
|
||||
@@ -57,22 +195,17 @@ const resumeData = {
|
||||
school: "Clark College",
|
||||
location: "Vancouver, WA",
|
||||
period: "Graduating 2026",
|
||||
achievements: []
|
||||
achievements: [] as string[]
|
||||
}
|
||||
],
|
||||
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");
|
||||
@@ -83,188 +216,198 @@ const Resume = () => {
|
||||
<div className="space-y-16">
|
||||
{/* Header */}
|
||||
<header className="text-center space-y-6">
|
||||
<h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
|
||||
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2>
|
||||
<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}
|
||||
</a>
|
||||
<span className="hidden md:inline">•</span>
|
||||
<a href={`tel:${resumeData.contact.phone}`} className="hover:text-foreground transition-colors duration-200">
|
||||
{resumeData.contact.phone}
|
||||
</a>
|
||||
<span className="hidden md:inline">•</span>
|
||||
<span>{resumeData.contact.location}</span>
|
||||
</div>
|
||||
<div className="flex justify-center items-center gap-6 text-lg">
|
||||
<a href={`https://${resumeData.contact.github}`}
|
||||
target="_blank"
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Linkedin size={18} />
|
||||
LinkedIn
|
||||
</a>
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<FileDown size={18} />
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
<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}
|
||||
</a>
|
||||
<span className="hidden md:inline">•</span>
|
||||
<a href={`tel:${resumeData.contact.phone}`} className="hover:text-foreground transition-colors duration-200">
|
||||
{resumeData.contact.phone}
|
||||
</a>
|
||||
<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"
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Linkedin size={18} />
|
||||
LinkedIn
|
||||
</a>
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<FileDown size={18} />
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
</header>
|
||||
|
||||
{/* Summary */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-3xl font-bold text-yellow-bright">Professional Summary</h3>
|
||||
<TypedSection heading="Professional Summary">
|
||||
<p className="text-xl leading-relaxed">{resumeData.summary}</p>
|
||||
</section>
|
||||
</TypedSection>
|
||||
|
||||
{/* Experience */}
|
||||
<section className="space-y-8">
|
||||
<h3 className="text-3xl font-bold text-yellow-bright">Experience</h3>
|
||||
{resumeData.experience.map((exp, index) => (
|
||||
<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>
|
||||
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div>
|
||||
<TypedSection heading="Experience">
|
||||
<div className="space-y-8">
|
||||
{resumeData.experience.map((exp, index) => (
|
||||
<Section key={index} delay={index * 100}>
|
||||
<div 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>
|
||||
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<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((achievement, i) => (
|
||||
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
</TypedSection>
|
||||
|
||||
{/* Contract Work */}
|
||||
<section className="space-y-8">
|
||||
<h3 className="text-3xl font-bold text-yellow-bright">Contract Work</h3>
|
||||
{resumeData.contractWork.map((project, index) => (
|
||||
<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">
|
||||
<h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4>
|
||||
{project.url && (
|
||||
<a
|
||||
href={project.url}
|
||||
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>
|
||||
<TypedSection heading="Contract Work">
|
||||
<div className="space-y-8">
|
||||
{resumeData.contractWork.map((project, index) => (
|
||||
<Section key={index} delay={index * 100}>
|
||||
<div 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">
|
||||
<h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4>
|
||||
{project.url && (
|
||||
<a
|
||||
href={project.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-bright hover:text-blue transition-colors duration-200 opacity-60 hover:opacity-100"
|
||||
>
|
||||
<Globe size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-foreground/60 text-lg">{project.type}</div>
|
||||
</div>
|
||||
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{project.responsibilities && (
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{project.achievements && (
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-foreground/60 text-lg">{project.type}</div>
|
||||
</div>
|
||||
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{project.responsibilities && (
|
||||
<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((responsibility, i) => (
|
||||
<li key={i} className="text-lg leading-relaxed">{responsibility}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{project.achievements && (
|
||||
<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((achievement, i) => (
|
||||
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
</TypedSection>
|
||||
|
||||
{/* Education */}
|
||||
<section className="space-y-8">
|
||||
<h3 className="text-3xl font-bold text-yellow-bright">Education</h3>
|
||||
{resumeData.education.map((edu, index) => (
|
||||
<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>
|
||||
<div className="text-foreground/60 text-lg">{edu.school} - {edu.location}</div>
|
||||
<TypedSection heading="Education">
|
||||
<div className="space-y-8">
|
||||
{resumeData.education.map((edu, index) => (
|
||||
<Section key={index}>
|
||||
<div 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>
|
||||
<div className="text-foreground/60 text-lg">{edu.school} - {edu.location}</div>
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-foreground/60 text-lg font-medium">{edu.period}</div>
|
||||
</div>
|
||||
<ul className="list-disc pl-6 space-y-3">
|
||||
{edu.achievements.map((achievement, i) => (
|
||||
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
</TypedSection>
|
||||
|
||||
{/* Skills */}
|
||||
<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>
|
||||
*/}
|
||||
<SkillsSection />
|
||||
</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;
|
||||
|
||||
98
src/src/components/theme-switcher/index.tsx
Normal file
98
src/src/components/theme-switcher/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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(),
|
||||
@@ -16,6 +18,7 @@ export const collections = {
|
||||
}),
|
||||
}),
|
||||
projects: defineCollection({
|
||||
loader: glob({ pattern: "**/*.mdx", base: "./src/content/projects" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
0
src/src/content/blog/json-as-machine-code.keep
Normal file
0
src/src/content/blog/json-as-machine-code.keep
Normal file
@@ -25,6 +25,7 @@ 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.
|
||||
@@ -202,22 +203,64 @@ 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
|
||||
### 1. GRUB2 (Recommended)
|
||||
|
||||
This configuration features GRUB2 as the bootloader, and contains 3 secondary payloads,
|
||||
which the user can opt in/out of:
|
||||
|
||||
* memtest built in
|
||||
* nvramcui built in
|
||||
* coreinfo built in
|
||||
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 also includes the dGPU option rom as well for T440p's featuring the gt730m on board.
|
||||
- **memtest86+** - Memory testing utility
|
||||
- **nvramcui** - CMOS/NVRAM settings editor
|
||||
- **coreinfo** - System information viewer
|
||||
|
||||
2. SeaBIOS
|
||||
If your T440p has the optional GT730M dGPU, the GRUB2 config also includes the
|
||||
necessary VGA option ROM for it.
|
||||
|
||||
3. edk2
|
||||
### 2. SeaBIOS
|
||||
|
||||
> NOTE: Show the user how to choose the appropriate config, as well as building a custom config below.
|
||||
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 (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** → Mainboard vendor: **Lenovo** → Mainboard model: **ThinkPad T440p**
|
||||
- **Chipset** → Add Intel descriptor.bin, ME firmware, and GbE configuration (set paths to your blobs)
|
||||
- **Chipset** → Add haswell MRC file (set path to mrc.bin)
|
||||
- **Payload** → Choose your preferred payload (GRUB2, SeaBIOS, or edk2)
|
||||
|
||||
## Building and Flashing
|
||||
|
||||
@@ -239,7 +282,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 skin=8"
|
||||
"dd if=coreboot.rom of=top.rom bs=1M skip=8"
|
||||
]}
|
||||
description="Split the built ROM for both EEPROM chips"
|
||||
client:load
|
||||
|
||||
@@ -5,6 +5,10 @@ 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;
|
||||
@@ -20,20 +24,16 @@ 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
|
||||
<ClientRouter
|
||||
defaultTransition={false}
|
||||
handleFocus={false}
|
||||
/>
|
||||
@@ -41,7 +41,6 @@ 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;
|
||||
}
|
||||
@@ -50,6 +49,8 @@ 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 />
|
||||
@@ -65,10 +66,9 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
<div class="mt-auto">
|
||||
<Footer client:load transition:persist />
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener("astro:after-navigation", () => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
</script>
|
||||
<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} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,10 @@ 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;
|
||||
@@ -23,28 +27,30 @@ 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 />
|
||||
<Header client:load transparent />
|
||||
<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>
|
||||
|
||||
@@ -5,6 +5,10 @@ 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;
|
||||
@@ -20,20 +24,16 @@ 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
|
||||
<ClientRouter
|
||||
defaultTransition={false}
|
||||
handleFocus={false}
|
||||
/>
|
||||
@@ -41,7 +41,6 @@ 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;
|
||||
}
|
||||
@@ -50,6 +49,8 @@ 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">
|
||||
@@ -61,10 +62,9 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
<Background layout="content" position="left" client:only="react" transition:persist />
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener("astro:after-navigation", () => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
</script>
|
||||
<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} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
20
src/src/lib/animations/engine.ts
Normal file
20
src/src/lib/animations/engine.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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];
|
||||
}
|
||||
9
src/src/lib/animations/index.ts
Normal file
9
src/src/lib/animations/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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",
|
||||
};
|
||||
7
src/src/lib/animations/loader.ts
Normal file
7
src/src/lib/animations/loader.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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}";});`;
|
||||
33
src/src/lib/animations/types.ts
Normal file
33
src/src/lib/animations/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
@@ -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.slug}/`,
|
||||
url: `${import.meta.env.SITE}/blog/${post.id}/`,
|
||||
description: post.data.excerpt,
|
||||
datePublished: post.data.date.toString(),
|
||||
publisher: {
|
||||
|
||||
59
src/src/lib/themes/engine.ts
Normal file
59
src/src/lib/themes/engine.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 } }));
|
||||
}
|
||||
58
src/src/lib/themes/index.ts
Normal file
58
src/src/lib/themes/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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]]),
|
||||
};
|
||||
25
src/src/lib/themes/loader.ts
Normal file
25
src/src/lib/themes/loader.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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}});`;
|
||||
21
src/src/lib/themes/props.ts
Normal file
21
src/src/lib/themes/props.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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"],
|
||||
];
|
||||
27
src/src/lib/themes/types.ts
Normal file
27
src/src/lib/themes/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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][];
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import Timeline from "@/components/about/timeline";
|
||||
import CurrentFocus from "@/components/about/current-focus";
|
||||
import OutsideCoding from "@/components/about/outside-coding";
|
||||
---
|
||||
<ContentLayout
|
||||
<ContentLayout
|
||||
title="About | Timothy Pidashev"
|
||||
description="A software engineer passionate about the web, open source, and building innovative solutions."
|
||||
>
|
||||
@@ -17,23 +17,23 @@ import OutsideCoding from "@/components/about/outside-coding";
|
||||
<Intro client:load />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
<AllTimeStats client:only="react" />
|
||||
<section class="min-h-[60vh] flex items-center justify-center py-16">
|
||||
<AllTimeStats client:load />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
<DetailedStats client:only="react" />
|
||||
<section class="min-h-screen flex items-center justify-center py-16">
|
||||
<DetailedStats client:load />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
|
||||
<section class="min-h-[80vh] flex items-center justify-center py-16">
|
||||
<Timeline client:load />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
<section class="min-h-[80vh] flex items-center justify-center py-16">
|
||||
<CurrentFocus client:load />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
<section class="min-h-[50vh] flex items-center justify-center py-16">
|
||||
<OutsideCoding client:load />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,14 @@ 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', {
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
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 { stdout } = await execAsync(`curl -H "Authorization: Basic ${import.meta.env.WAKATIME_API_KEY}" https://wakatime.com/api/v1/users/current/all_time_since_today`);
|
||||
|
||||
return new Response(stdout, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
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 data = await response.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: "Failed to fetch stats" }), {
|
||||
status: 500
|
||||
});
|
||||
console.error("WakaTime alltime API error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to fetch stats" }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,14 @@ 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', {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import { getCollection, render } from "astro:content";
|
||||
import { Image } from "astro:assets";
|
||||
import ContentLayout from "@/layouts/content.astro";
|
||||
import { getArticleSchema } from "@/lib/structuredData";
|
||||
@@ -11,9 +11,9 @@ const { slug } = Astro.params;
|
||||
|
||||
// Fetch blog posts
|
||||
const posts = await getCollection("blog");
|
||||
const post = posts.find(post => post.slug === slug);
|
||||
const post = posts.find(post => post.id === slug);
|
||||
|
||||
if (!post || post.data.isDraft === true) {
|
||||
if (!post || (!import.meta.env.DEV && post.data.isDraft === true)) {
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
statusText: "Not found"
|
||||
@@ -21,7 +21,7 @@ if (!post || post.data.isDraft === true) {
|
||||
}
|
||||
|
||||
// Dynamically render the content
|
||||
const { Content } = await post.render();
|
||||
const { Content } = await render(post);
|
||||
|
||||
// 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.slug}/`,
|
||||
item: `${import.meta.env.SITE}/blog/${post.id}/`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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-8">
|
||||
<div class="-mx-4 sm:mx-0 mb-4">
|
||||
<Image
|
||||
src={post.data.image}
|
||||
alt={post.data.title}
|
||||
@@ -76,20 +76,18 @@ const jsonLd = {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
|
||||
{formattedDate}
|
||||
</time>
|
||||
</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>
|
||||
<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>
|
||||
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
|
||||
{formattedDate}
|
||||
</time>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-4 md:mt-6">
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{post.data.tags.map((tag) => (
|
||||
<span
|
||||
<span
|
||||
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
||||
onclick={`window.location.href='/blog/tag/${tag}'`}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BlogHeader } from "@/components/blog/header";
|
||||
import { BlogPostList } from "@/components/blog/post-list";
|
||||
|
||||
const posts = (await getCollection("blog", ({ data }) => {
|
||||
return data.isDraft !== true;
|
||||
return import.meta.env.DEV || data.isDraft !== true;
|
||||
})).sort((a, b) => {
|
||||
return b.data.date.valueOf() - a.data.date.valueOf()
|
||||
}).map(post => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
|
||||
import TagList from "@/components/blog/tag-list";
|
||||
|
||||
const posts = (await getCollection("blog", ({ data }) => {
|
||||
return data.isDraft !== true;
|
||||
return import.meta.env.DEV || data.isDraft !== true;
|
||||
})).sort((a, b) => {
|
||||
return b.data.date.valueOf() - a.data.date.valueOf()
|
||||
}).map(post => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
export const prerender = true;
|
||||
|
||||
import { getCollection } from "astro:content";
|
||||
import { getCollection, render } 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.slug },
|
||||
params: { slug: project.id },
|
||||
props: { project },
|
||||
}));
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
const { Content } = await project.render();
|
||||
const { Content } = await render(project);
|
||||
---
|
||||
|
||||
<ContentLayout title={`${project.data.title} | Timothy Pidashev`}>
|
||||
|
||||
@@ -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.slug}/`,
|
||||
link: `/blog/${post.id}/`,
|
||||
author: post.data.author,
|
||||
categories: post.data.tags,
|
||||
enclosure: post.data.image ? {
|
||||
url: new URL(`blog/${post.slug}/thumbnail.png`, context.site).toString(),
|
||||
url: new URL(`blog/${post.id}/thumbnail.png`, context.site).toString(),
|
||||
type: 'image/jpeg',
|
||||
length: 0
|
||||
} : undefined
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
: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;
|
||||
|
||||
@@ -6,35 +6,35 @@ module.exports = {
|
||||
"comic-code": ["Comic Code", "monospace"],
|
||||
},
|
||||
colors: {
|
||||
background: "#000000",
|
||||
foreground: "#ebdbb2",
|
||||
background: "rgb(var(--color-background) / <alpha-value>)",
|
||||
foreground: "rgb(var(--color-foreground) / <alpha-value>)",
|
||||
red: {
|
||||
DEFAULT: "#cc241d",
|
||||
bright: "#fb4934"
|
||||
DEFAULT: "rgb(var(--color-red) / <alpha-value>)",
|
||||
bright: "rgb(var(--color-red-bright) / <alpha-value>)"
|
||||
},
|
||||
orange: {
|
||||
DEFAULT: "#d65d0e",
|
||||
bright: "#fe8019"
|
||||
DEFAULT: "rgb(var(--color-orange) / <alpha-value>)",
|
||||
bright: "rgb(var(--color-orange-bright) / <alpha-value>)"
|
||||
},
|
||||
green: {
|
||||
DEFAULT: "#98971a",
|
||||
bright: "#b8bb26"
|
||||
DEFAULT: "rgb(var(--color-green) / <alpha-value>)",
|
||||
bright: "rgb(var(--color-green-bright) / <alpha-value>)"
|
||||
},
|
||||
yellow: {
|
||||
DEFAULT: "#d79921",
|
||||
bright: "#fabd2f"
|
||||
DEFAULT: "rgb(var(--color-yellow) / <alpha-value>)",
|
||||
bright: "rgb(var(--color-yellow-bright) / <alpha-value>)"
|
||||
},
|
||||
blue: {
|
||||
DEFAULT: "#458588",
|
||||
bright: "#83a598"
|
||||
DEFAULT: "rgb(var(--color-blue) / <alpha-value>)",
|
||||
bright: "rgb(var(--color-blue-bright) / <alpha-value>)"
|
||||
},
|
||||
purple: {
|
||||
DEFAULT: "#b16286",
|
||||
bright: "#d3869b"
|
||||
DEFAULT: "rgb(var(--color-purple) / <alpha-value>)",
|
||||
bright: "rgb(var(--color-purple-bright) / <alpha-value>)"
|
||||
},
|
||||
aqua: {
|
||||
DEFAULT: "#689d6a",
|
||||
bright: "#8ec07c"
|
||||
DEFAULT: "rgb(var(--color-aqua) / <alpha-value>)",
|
||||
bright: "rgb(var(--color-aqua-bright) / <alpha-value>)"
|
||||
}
|
||||
},
|
||||
keyframes: {
|
||||
@@ -51,86 +51,82 @@ module.exports = {
|
||||
"draw-line": "draw-line 0.6s ease-out forwards",
|
||||
"fade-in": "fade-in 0.3s ease-in-out forwards"
|
||||
},
|
||||
typography: (theme) => ({
|
||||
typography: () => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
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"),
|
||||
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))",
|
||||
|
||||
// Headings
|
||||
h1: {
|
||||
color: theme("colors.yellow.bright"),
|
||||
color: "rgb(var(--color-yellow-bright))",
|
||||
fontWeight: "700",
|
||||
},
|
||||
h2: {
|
||||
color: theme("colors.yellow.bright"),
|
||||
color: "rgb(var(--color-yellow-bright))",
|
||||
fontWeight: "600",
|
||||
},
|
||||
h3: {
|
||||
color: theme("colors.yellow.bright"),
|
||||
color: "rgb(var(--color-yellow-bright))",
|
||||
fontWeight: "600",
|
||||
},
|
||||
h4: {
|
||||
color: theme("colors.yellow.bright"),
|
||||
color: "rgb(var(--color-yellow-bright))",
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
||||
// Links
|
||||
a: {
|
||||
color: theme("colors.blue.bright"),
|
||||
color: "rgb(var(--color-blue-bright))",
|
||||
"&:hover": {
|
||||
color: theme("colors.blue.DEFAULT"),
|
||||
color: "rgb(var(--color-blue))",
|
||||
},
|
||||
textDecoration: "none",
|
||||
borderBottom: `1px solid ${theme("colors.blue.bright")}`,
|
||||
borderBottom: "1px solid rgb(var(--color-blue-bright))",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
},
|
||||
|
||||
// Code
|
||||
'code:not([data-language])': {
|
||||
color: theme('colors.purple.bright'),
|
||||
backgroundColor: '#282828',
|
||||
color: "rgb(var(--color-purple-bright))",
|
||||
backgroundColor: "rgb(var(--color-surface))",
|
||||
padding: '0',
|
||||
borderRadius: '0.25rem',
|
||||
fontFamily: 'Comic Code, monospace',
|
||||
fontWeight: '400',
|
||||
fontSize: 'inherit', // Match the parent text size
|
||||
fontSize: 'inherit',
|
||||
'&::before': { content: 'none' },
|
||||
'&::after': { content: 'none' },
|
||||
},
|
||||
|
||||
'pre': {
|
||||
backgroundColor: '#282828',
|
||||
color: theme("colors.foreground"),
|
||||
backgroundColor: "rgb(var(--color-surface))",
|
||||
color: "rgb(var(--color-foreground))",
|
||||
borderRadius: '0.5rem',
|
||||
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
|
||||
overflow: 'visible',
|
||||
position: 'relative',
|
||||
marginTop: '1.5rem',
|
||||
fontSize: 'inherit',
|
||||
},
|
||||
|
||||
'pre code': {
|
||||
display: 'block',
|
||||
fontFamily: 'Comic Code, monospace',
|
||||
fontSize: '1em', // This will inherit from the prose-lg setting
|
||||
fontSize: '1em',
|
||||
padding: '0',
|
||||
overflow: 'auto', // Enable horizontal scrolling
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre',
|
||||
'&::before': { content: 'none' },
|
||||
'&::after': { content: 'none' },
|
||||
},
|
||||
|
||||
|
||||
'[data-rehype-pretty-code-fragment]:nth-of-type(2) pre': {
|
||||
'[data-line]::before': {
|
||||
content: 'counter(line)',
|
||||
@@ -148,7 +144,7 @@ module.exports = {
|
||||
|
||||
// Bold
|
||||
strong: {
|
||||
color: theme("colors.orange.bright"),
|
||||
color: "rgb(var(--color-orange-bright))",
|
||||
fontWeight: "600",
|
||||
},
|
||||
|
||||
@@ -156,15 +152,15 @@ module.exports = {
|
||||
ul: {
|
||||
li: {
|
||||
"&::before": {
|
||||
backgroundColor: theme("colors.foreground"),
|
||||
backgroundColor: "rgb(var(--color-foreground))",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: {
|
||||
borderLeftColor: theme("colors.green.bright"),
|
||||
color: theme("colors.green.bright"),
|
||||
borderLeftColor: "rgb(var(--color-green-bright))",
|
||||
color: "rgb(var(--color-green-bright))",
|
||||
fontStyle: "italic",
|
||||
quotes: "\"\\201C\"\"\\201D\"\"\\2018\"\"\\2019\"",
|
||||
p: {
|
||||
@@ -175,21 +171,21 @@ module.exports = {
|
||||
|
||||
// Horizontal rules
|
||||
hr: {
|
||||
borderColor: theme("colors.foreground"),
|
||||
borderColor: "rgb(var(--color-foreground))",
|
||||
opacity: "0.2",
|
||||
},
|
||||
|
||||
// Table
|
||||
table: {
|
||||
thead: {
|
||||
borderBottomColor: theme("colors.foreground"),
|
||||
borderBottomColor: "rgb(var(--color-foreground))",
|
||||
th: {
|
||||
color: theme("colors.yellow.bright"),
|
||||
color: "rgb(var(--color-yellow-bright))",
|
||||
},
|
||||
},
|
||||
tbody: {
|
||||
tr: {
|
||||
borderBottomColor: theme("colors.foreground"),
|
||||
borderBottomColor: "rgb(var(--color-foreground))",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -201,7 +197,7 @@ module.exports = {
|
||||
|
||||
// Figures
|
||||
figcaption: {
|
||||
color: theme("colors.foreground"),
|
||||
color: "rgb(var(--color-foreground))",
|
||||
opacity: "0.8",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user