mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 11:03:50 +00:00
migrate to vercel; bump version
This commit is contained in:
194
src/components/about/current-focus.tsx
Normal file
194
src/components/about/current-focus.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
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 = [
|
||||
{
|
||||
title: "Darkbox",
|
||||
description: "My gruvbox theme, with a pure black background",
|
||||
href: "/projects/darkbox",
|
||||
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"],
|
||||
},
|
||||
{
|
||||
title: "Fhccenter",
|
||||
description: "Website made for a private school",
|
||||
href: "/projects/fhccenter",
|
||||
tech: ["Nextjs", "Typescript", "Prisma"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<div className="w-full max-w-6xl p-4 sm:px-6 py-6 sm:py-8">
|
||||
<AnimateIn>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12">
|
||||
Current Focus
|
||||
</h2>
|
||||
</AnimateIn>
|
||||
|
||||
{/* Recent Projects Section */}
|
||||
<div className="mb-8 sm:mb-16">
|
||||
<AnimateIn delay={100}>
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<Code2 className="text-yellow-bright" size={24} />
|
||||
<h3 className="text-xl font-bold text-foreground/90">Recent Projects</h3>
|
||||
</div>
|
||||
</AnimateIn>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-5xl mx-auto">
|
||||
{recentProjects.map((project, i) => (
|
||||
<AnimateIn key={project.title} delay={200 + i * 100}>
|
||||
<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">
|
||||
<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>
|
||||
</AnimateIn>
|
||||
|
||||
<AnimateIn delay={200}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<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>
|
||||
</AnimateIn>
|
||||
|
||||
<AnimateIn delay={300}>
|
||||
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
|
||||
<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>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/about/intro.tsx
Normal file
107
src/components/about/intro.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export default function Intro() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
|
||||
|
||||
if (inView && isReload) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (inView) {
|
||||
// Fresh navigation — animate in
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const scrollToNext = () => {
|
||||
const nextSection = document.querySelector("section")?.nextElementSibling;
|
||||
if (nextSection) {
|
||||
const offset = (nextSection as HTMLElement).offsetTop - (window.innerHeight - (nextSection as HTMLElement).offsetHeight) / 2;
|
||||
window.scrollTo({ top: offset, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const anim = (delay: number) =>
|
||||
({
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(20px)",
|
||||
transition: `all 0.7s ease-out ${delay}ms`,
|
||||
}) as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full max-w-4xl px-4">
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16">
|
||||
<div
|
||||
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"
|
||||
style={anim(0)}
|
||||
>
|
||||
<img
|
||||
src="/me.jpeg"
|
||||
alt="Timothy Pidashev"
|
||||
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
|
||||
<h2 className="text-xl sm:text-5xl font-bold text-yellow-bright">
|
||||
Timothy Pidashev
|
||||
</h2>
|
||||
<div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3">
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}>
|
||||
<span className="text-blue">Software Systems Engineer</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}>
|
||||
<span className="text-green">Open Source Enthusiast</span>
|
||||
</p>
|
||||
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}>
|
||||
<span className="text-yellow">Coffee Connoisseur</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 –
|
||||
<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" style={anim(900)}>
|
||||
<button
|
||||
onClick={scrollToNext}
|
||||
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"
|
||||
aria-label="Scroll to next section"
|
||||
>
|
||||
<ChevronDown size={40} className="animate-bounce" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/about/outside-coding.tsx
Normal file
116
src/components/about/outside-coding.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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() {
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<div className="w-full max-w-4xl px-4 py-8">
|
||||
<AnimateIn>
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8">
|
||||
Outside of Programming
|
||||
</h2>
|
||||
</AnimateIn>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{interests.map((interest, i) => (
|
||||
<AnimateIn key={interest.title} delay={100 + i * 100}>
|
||||
<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>
|
||||
</AnimateIn>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimateIn delay={500}>
|
||||
<p className="text-center text-foreground/80 mt-8 max-w-2xl mx-auto text-sm md:text-base italic">
|
||||
When I'm not writing code, you'll find me
|
||||
<span className="text-red-bright"> walking with Christ,</span>
|
||||
<span className="text-blue-bright"> out on the water,</span>
|
||||
<span className="text-green-bright"> hiking trails,</span>
|
||||
<span className="text-purple-bright"> or reading books.</span>
|
||||
</p>
|
||||
</AnimateIn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/about/stats-activity.tsx
Normal file
116
src/components/about/stats-activity.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
|
||||
interface ActivityDay {
|
||||
grand_total: { total_seconds: number };
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface ActivityGridProps {
|
||||
data: ActivityDay[];
|
||||
}
|
||||
|
||||
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"];
|
||||
|
||||
const getIntensity = (hours: number) => {
|
||||
if (hours === 0) return 0;
|
||||
if (hours < 2) return 1;
|
||||
if (hours < 4) return 2;
|
||||
if (hours < 6) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const getColorClass = (intensity: number) => {
|
||||
if (intensity === 0) return "bg-foreground/5";
|
||||
if (intensity === 1) return "bg-green-DEFAULT/30";
|
||||
if (intensity === 2) return "bg-green-DEFAULT/60";
|
||||
if (intensity === 3) return "bg-green-DEFAULT/80";
|
||||
return "bg-green-bright";
|
||||
};
|
||||
|
||||
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) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
{/* Grid */}
|
||||
<div className="flex-grow overflow-x-auto">
|
||||
<div className="flex gap-2">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-2">
|
||||
{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)}
|
||||
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
|
||||
group relative`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Months labels */}
|
||||
<div className="flex text-xs text-foreground/60 mt-2">
|
||||
{weeks.map((week, i) => {
|
||||
const date = new Date(week[0].date);
|
||||
const isFirstOfMonth = date.getDate() <= 7;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-3 mx-1"
|
||||
style={{ marginLeft: i === 0 ? "0" : undefined }}
|
||||
>
|
||||
{isFirstOfMonth && months[date.getMonth()]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<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)}`} />
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityGrid;
|
||||
166
src/components/about/stats-alltime.tsx
Normal file
166
src/components/about/stats-alltime.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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 [isVisible, setIsVisible] = useState(false);
|
||||
const [skipAnim, setSkipAnim] = useState(false);
|
||||
const hasAnimated = useRef(false);
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
fetch("/api/wakatime/alltime")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("API error");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setStats(data.data))
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
const progress = 1 - Math.pow(1 - currentStep / steps, 4);
|
||||
setCount(Math.floor(totalSeconds * progress));
|
||||
}, duration / steps);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isVisible, stats]);
|
||||
|
||||
if (error) return null;
|
||||
if (!stats) return <div ref={sectionRef} className="min-h-[50vh]" />;
|
||||
|
||||
const hours = Math.floor(count / 3600);
|
||||
const formattedHours = hours.toLocaleString("en-US", {
|
||||
minimumIntegerDigits: 4,
|
||||
useGrouping: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
|
||||
<div className={skipAnim ? "text-2xl opacity-80" : `text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
|
||||
I've spent
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="text-8xl text-center relative z-10">
|
||||
<span className="font-bold relative">
|
||||
<span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
|
||||
{formattedHours}
|
||||
</span>
|
||||
</span>
|
||||
<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={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
|
||||
writing code & building apps
|
||||
</div>
|
||||
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}>
|
||||
<span>since</span>
|
||||
<span className="text-green-bright font-bold">{stats.range.start_text}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.bg-gradient-text {
|
||||
background: linear-gradient(90deg,
|
||||
rgb(var(--color-yellow-bright)),
|
||||
rgb(var(--color-orange-bright)),
|
||||
rgb(var(--color-orange)),
|
||||
rgb(var(--color-yellow)),
|
||||
rgb(var(--color-orange-bright)),
|
||||
rgb(var(--color-yellow-bright))
|
||||
);
|
||||
background-size: 200% auto;
|
||||
color: transparent;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes fadeInFirst {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.8; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeInSecond {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes slideInHours {
|
||||
from { opacity: 0; transform: translateX(20px); margin-left: 0; }
|
||||
to { opacity: 0.6; transform: translateX(0); margin-left: 1rem; }
|
||||
}
|
||||
@keyframes fadeInThird {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.8; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeInFourth {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 0.6; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
231
src/components/about/stats-detailed.tsx
Normal file
231
src/components/about/stats-detailed.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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<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) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setStats(data.data))
|
||||
.catch(() => setError(true));
|
||||
|
||||
fetch("/api/wakatime/activity")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setActivity(data.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
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",
|
||||
"bg-orange-bright",
|
||||
"bg-yellow-bright",
|
||||
"bg-green-bright",
|
||||
"bg-blue-bright",
|
||||
"bg-purple-bright",
|
||||
"bg-aqua-bright",
|
||||
];
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{
|
||||
title: "Total Time",
|
||||
value: `${Math.round((stats.total_seconds / 3600) * 10) / 10}`,
|
||||
unit: "hours",
|
||||
subtitle: "this week",
|
||||
color: "text-yellow-bright",
|
||||
borderHover: "hover:border-yellow-bright/50",
|
||||
icon: Clock,
|
||||
iconColor: "stroke-yellow-bright",
|
||||
},
|
||||
{
|
||||
title: "Daily Average",
|
||||
value: `${Math.round((stats.daily_average / 3600) * 10) / 10}`,
|
||||
unit: "hours",
|
||||
subtitle: "per day",
|
||||
color: "text-orange-bright",
|
||||
borderHover: "hover:border-orange-bright/50",
|
||||
icon: CalendarClock,
|
||||
iconColor: "stroke-orange-bright",
|
||||
},
|
||||
{
|
||||
title: "Primary Editor",
|
||||
value: stats.editors?.[0]?.name || "None",
|
||||
unit: `${Math.round(stats.editors?.[0]?.percent || 0)}%`,
|
||||
subtitle: "of the time",
|
||||
color: "text-blue-bright",
|
||||
borderHover: "hover:border-blue-bright/50",
|
||||
icon: CodeXml,
|
||||
iconColor: "stroke-blue-bright",
|
||||
},
|
||||
{
|
||||
title: "Operating System",
|
||||
value: stats.operating_systems?.[0]?.name || "None",
|
||||
unit: `${Math.round(stats.operating_systems?.[0]?.percent || 0)}%`,
|
||||
subtitle: "of the time",
|
||||
color: "text-green-bright",
|
||||
borderHover: "hover:border-green-bright/50",
|
||||
icon: Computer,
|
||||
iconColor: "stroke-green-bright",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const languages =
|
||||
stats?.languages?.slice(0, 7).map((lang: any, index: number) => ({
|
||||
name: lang.name,
|
||||
percent: Math.round(lang.percent),
|
||||
time: Math.round((lang.total_seconds / 3600) * 10) / 10 + " hrs",
|
||||
color: progressColors[index % progressColors.length],
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4 min-h-[50vh]">
|
||||
{!stats ? null : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<h2
|
||||
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
|
||||
style={skipAnim ? {} : {
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(20px)",
|
||||
}}
|
||||
>
|
||||
Weekly Statistics
|
||||
</h2>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{statCards.map((card, i) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.title}
|
||||
className={`bg-background border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-all duration-500 ease-out"}`}
|
||||
style={skipAnim ? {} : {
|
||||
transitionDelay: `${150 + i * 100}ms`,
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="p-3 rounded-lg bg-foreground/5">
|
||||
<Icon className={`w-6 h-6 ${card.iconColor}`} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className={`${card.color} text-sm mb-1`}>{card.title}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
<div className="text-lg opacity-80">{card.unit}</div>
|
||||
</div>
|
||||
<div className="text-xs opacity-50 mt-0.5">{card.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
<div
|
||||
className={`bg-background border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
|
||||
style={skipAnim ? {} : {
|
||||
transitionDelay: "550ms",
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||
}}
|
||||
>
|
||||
<div className="text-purple-bright mb-6 text-lg">Languages</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-5">
|
||||
{languages.map((lang: any, i: number) => (
|
||||
<div key={lang.name} className="flex flex-col gap-2">
|
||||
<div className="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>
|
||||
|
||||
{/* Activity Grid */}
|
||||
{activity && (
|
||||
<div
|
||||
className={skipAnim ? "" : "transition-all duration-700 ease-out"}
|
||||
style={skipAnim ? {} : {
|
||||
transitionDelay: "750ms",
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(24px)",
|
||||
}}
|
||||
>
|
||||
<ActivityGrid data={activity} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailedStats;
|
||||
184
src/components/about/timeline.tsx
Normal file
184
src/components/about/timeline.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
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 lineRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [lineHeight, setLineHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Animate line to full height over time
|
||||
const el = lineRef.current;
|
||||
if (el) {
|
||||
setLineHeight(100);
|
||||
}
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl px-4 py-8 relative z-0">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12">
|
||||
My Journey Through Code
|
||||
</h2>
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Animated vertical line */}
|
||||
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
|
||||
<div
|
||||
ref={lineRef}
|
||||
className="w-full bg-foreground/10 transition-all duration-[1500ms] ease-out origin-top"
|
||||
style={{ height: `${lineHeight}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-8 sm:ml-0">
|
||||
{timelineItems.map((item, index) => (
|
||||
<TimelineCard key={item.year} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user