Compare commits

..

32 Commits
v3.0.0 ... main

Author SHA1 Message Date
222e01d18e bump scripts 2026-04-14 09:33:45 -07:00
384ae63300 update README.md 2026-04-14 09:26:16 -07:00
eac8018a83 bump scripts 2026-04-14 09:23:14 -07:00
de546d6ff0 bump scripts 2026-04-14 08:56:59 -07:00
9965bd3529 update submodules 2026-04-14 03:09:20 -07:00
f0ae0b9ce1 hero bugfixes/improvements 2026-04-08 22:25:26 -07:00
87d3b3bfa6 hero bugfixes/improvements 2026-04-08 16:29:13 -07:00
f6873546df Remove debug url params 2026-04-08 16:10:32 -07:00
e7ada63431 Void; part 2 2026-04-08 16:08:06 -07:00
53065a11dc Void; part 1 2026-04-06 23:08:06 -07:00
2c5784c6e2 Update hero section; part 2 2026-04-06 20:27:56 -07:00
9b626faba8 Update hero section; part 1 2026-04-06 17:57:29 -07:00
153bd0cf39 Update comment themes; add github theme family 2026-04-06 16:32:15 -07:00
162032e3f3 Update theme families 2026-04-06 16:25:10 -07:00
237cacb612 Add more themes 2026-04-06 16:20:17 -07:00
f6e9e16227 Update mobile settings layout 2026-04-06 15:41:31 -07:00
db46f7d6ba Rework mobile device detection 2026-04-06 15:35:46 -07:00
e640e87d3f Add theme families 2026-04-06 15:27:40 -07:00
1cd76b03df mobile optimizations 2026-04-06 14:46:49 -07:00
5ac736cad4 mobile optimizations 2026-04-06 14:42:08 -07:00
997106eb92 mobile optimizations 2026-04-06 14:40:03 -07:00
3f103c3e15 mobile optimizations 2026-04-06 14:37:07 -07:00
16f271c1c9 mobile optimizations 2026-04-06 14:33:30 -07:00
1a445548f2 mobile optimizations 2026-04-06 14:21:03 -07:00
dc7ca40b9b mobile optimizations 2026-04-06 14:15:26 -07:00
14f9ef3ffd mobile optimizations 2026-04-06 14:10:46 -07:00
336c652bf7 mobile optimizations 2026-04-06 13:57:15 -07:00
873090310a mobile optimizations 2026-04-06 13:21:43 -07:00
c7762f099c mobile optimizations 2026-04-06 13:19:38 -07:00
c2407408fa Mobile optimizations 2026-04-06 13:08:41 -07:00
bab4a516be trigger redeploy 2026-03-31 14:19:10 -07:00
adc1f21204 Update blog metrics; add vercel imsights 2026-03-31 14:03:41 -07:00
108 changed files with 5122 additions and 614 deletions

View File

@@ -1,3 +1 @@
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Ftimmypidashev.dev&label=Visits&icon=eye-fill&color=%23198754)
<img src=".github/preview.jpeg" title="Preview"/> <img src=".github/preview.jpeg" title="Preview"/>

View File

@@ -12,6 +12,7 @@ export default defineConfig({
output: "server", output: "server",
adapter: vercel(), adapter: vercel(),
site: "https://timmypidashev.dev", site: "https://timmypidashev.dev",
devToolbar: { enabled: false },
build: { build: {
// Enable build-time optimizations // Enable build-time optimizations
inlineStylesheets: "auto", inlineStylesheets: "auto",

View File

@@ -13,21 +13,29 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@types/three": "^0.175.0",
"astro": "^6.1.2", "astro": "^6.1.2",
"tailwindcss": "^3.4.19" "tailwindcss": "^3.4.19"
}, },
"dependencies": { "dependencies": {
"@astrojs/mdx": "^5.0.3", "@astrojs/mdx": "^5.0.3",
"@astrojs/vercel": "^10.0.3",
"@astrojs/rss": "^4.0.18", "@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2", "@astrojs/sitemap": "^3.7.2",
"@astrojs/vercel": "^10.0.3",
"@giscus/react": "^3.1.0", "@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4", "@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2", "@react-hook/intersection-observer": "^3.1.2",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@react-three/postprocessing": "^2.19.1",
"@rehype-pretty/transformers": "^0.13.2", "@rehype-pretty/transformers": "^0.13.2",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"arctic": "^3.7.0", "arctic": "^3.7.0",
"ioredis": "^5.10.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"marked": "^15.0.12", "marked": "^15.0.12",
"postprocessing": "^6.39.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
@@ -37,6 +45,7 @@
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"shiki": "^3.23.0", "shiki": "^3.23.0",
"three": "^0.175.0",
"typewriter-effect": "^2.22.0", "typewriter-effect": "^2.22.0",
"unist-util-visit": "^5.1.0" "unist-util-visit": "^5.1.0"
} }

911
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

BIN
public/emoji/bubbles.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

BIN
public/emoji/coffee.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

BIN
public/emoji/eyes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
public/emoji/gift.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

BIN
public/emoji/infinity.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
public/emoji/lightbulb.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
public/emoji/memo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

BIN
public/emoji/mood-cold.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

BIN
public/emoji/mood-cool.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
public/emoji/mood-fire.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

BIN
public/emoji/mood-nerd.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
public/emoji/mood-nod.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

BIN
public/emoji/moon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
public/emoji/muscle.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
public/emoji/robot.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

BIN
public/emoji/shush.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

BIN
public/emoji/sparkles.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
public/emoji/thinking.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

BIN
public/emoji/tinker.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

BIN
public/emoji/trophy.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

BIN
public/emoji/wave.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

View File

@@ -1,57 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react"; import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
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() { export default function CurrentFocus() {
const recentProjects = [ const recentProjects = [
@@ -98,7 +46,7 @@ export default function CurrentFocus() {
<a <a
href={project.href} href={project.href}
className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50 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" transition-colors duration-300 group bg-background/50 h-full"
> >
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors"> <h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
{project.title} {project.title}

View File

@@ -12,13 +12,12 @@ export default function Intro() {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setVisible(true); setVisible(true);
return; return;
} }
if (inView) { if (inView) {
// Fresh navigation — animate in
requestAnimationFrame(() => setVisible(true)); requestAnimationFrame(() => setVisible(true));
return; return;
} }
@@ -48,36 +47,37 @@ export default function Intro() {
const anim = (delay: number) => const anim = (delay: number) =>
({ ({
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)", transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
transition: `all 0.7s ease-out ${delay}ms`, transition: `opacity 0.7s ease-out ${delay}ms, transform 0.7s ease-out ${delay}ms`,
willChange: "transform, opacity",
}) as React.CSSProperties; }) as React.CSSProperties;
return ( return (
<div ref={ref} 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="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="flex flex-col sm:flex-row items-center justify-center gap-8 sm:gap-16">
<div <div
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0" className="w-44 h-44 sm:w-40 sm:h-40 lg:w-48 lg:h-48 shrink-0"
style={anim(0)} style={anim(0)}
> >
<img <img
src="/me.jpeg" src="/me.jpeg"
alt="Timothy Pidashev" 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" className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-colors duration-300"
/> />
</div> </div>
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}> <div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
<h2 className="text-xl sm:text-5xl font-bold text-yellow-bright"> <h2 className="text-3xl sm:text-3xl lg:text-5xl font-bold text-yellow-bright">
Timothy Pidashev Timothy Pidashev
</h2> </h2>
<div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3"> <div className="text-base sm:text-lg lg:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}> <p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(300)}>
<span className="text-blue">Software Systems Engineer</span> <span className="text-blue">Software Systems Engineer</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}> <p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(450)}>
<span className="text-green">Open Source Enthusiast</span> <span className="text-green">Open Source Enthusiast</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}> <p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(600)}>
<span className="text-yellow">Coffee Connoisseur</span> <span className="text-yellow">Coffee Connoisseur</span>
</p> </p>
</div> </div>

View File

@@ -1,57 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Cross, Fish, Mountain, Book } from "lucide-react"; import { Cross, Fish, Mountain, Book } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
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 = [ const interests = [
{ {
@@ -86,12 +34,12 @@ export default function OutsideCoding() {
</h2> </h2>
</AnimateIn> </AnimateIn>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
{interests.map((interest, i) => ( {interests.map((interest, i) => (
<AnimateIn key={interest.title} delay={100 + i * 100}> <AnimateIn key={interest.title} delay={100 + i * 100}>
<div <div
className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10 className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-all duration-300 bg-background/50 h-full" hover:border-yellow-bright/50 transition-colors duration-300 bg-background/50 h-full"
> >
<div className="mb-3">{interest.icon}</div> <div className="mb-3">{interest.icon}</div>
<h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3> <h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3>

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
interface ActivityDay { interface ActivityDay {
grand_total: { total_seconds: number }; grand_total: { total_seconds: number };
@@ -9,6 +10,7 @@ interface ActivityGridProps {
} }
export const ActivityGrid = ({ data }: ActivityGridProps) => { export const ActivityGrid = ({ data }: ActivityGridProps) => {
const [tapped, setTapped] = useState<string | null>(null);
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -46,7 +48,7 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
} }
return ( return (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors"> <div className="bg-background/50 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="text-lg text-aqua-bright mb-6">Activity</div>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -69,12 +71,13 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
<div <div
key={dayIndex} 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 hover:ring-1 hover:ring-foreground/30 transition-colors cursor-pointer
group relative`} group relative`}
onClick={() => setTapped(tapped === day.date ? null : day.date)}
> >
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 <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 bg-background border border-foreground/10 rounded-md transition-opacity z-10 whitespace-nowrap text-xs
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs"> ${tapped === day.date ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
{hours.toFixed(1)} hours on {day.date} {hours.toFixed(1)} hours on {day.date}
</div> </div>
</div> </div>

View File

@@ -28,8 +28,8 @@ const Stats = () => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setSkipAnim(true); setSkipAnim(true);
setIsVisible(true); setIsVisible(true);
return; return;
@@ -60,21 +60,23 @@ const Stats = () => {
const totalSeconds = stats.total_seconds; const totalSeconds = stats.total_seconds;
const duration = 2000; const duration = 2000;
const steps = 60; let start: number | null = null;
let currentStep = 0; let rafId: number;
const timer = setInterval(() => { const step = (timestamp: number) => {
currentStep += 1; if (!start) start = timestamp;
if (currentStep >= steps) { const elapsed = timestamp - start;
setCount(totalSeconds); const progress = Math.min(elapsed / duration, 1);
clearInterval(timer); const eased = 1 - Math.pow(1 - progress, 4);
return; setCount(Math.floor(totalSeconds * eased));
if (progress < 1) {
rafId = requestAnimationFrame(step);
} }
const progress = 1 - Math.pow(1 - currentStep / steps, 4); };
setCount(Math.floor(totalSeconds * progress));
}, duration / steps);
return () => clearInterval(timer); rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId);
}, [isVisible, stats]); }, [isVisible, stats]);
if (error) return null; if (error) return null;
@@ -88,25 +90,25 @@ const Stats = () => {
return ( return (
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6"> <div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={skipAnim ? "text-2xl opacity-80" : `text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}> <div className={skipAnim ? "text-lg md:text-2xl opacity-80" : `text-lg md:text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
I've spent I've spent
</div> </div>
<div className="relative"> <div className="relative">
<div className="text-8xl text-center relative z-10"> <div className="text-5xl md:text-8xl text-center relative z-10">
<span className="font-bold relative"> <span className="font-bold relative">
<span className={skipAnim ? "bg-gradient-text" : `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} {formattedHours}
</span> </span>
</span> </span>
<span className={skipAnim ? "text-4xl opacity-60 ml-4" : `text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}> <span className={skipAnim ? "text-2xl md:text-4xl opacity-60 ml-4" : `text-2xl md:text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}>
hours hours
</span> </span>
</div> </div>
</div> </div>
<div className="flex flex-col items-center gap-3 text-center"> <div className="flex flex-col items-center gap-3 text-center">
<div className={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}> <div className={skipAnim ? "text-base md:text-xl opacity-80" : `text-base md:text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
writing code & building apps writing code & building apps
</div> </div>
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}> <div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}>

View File

@@ -35,8 +35,8 @@ const DetailedStats = () => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setSkipAnim(true); setSkipAnim(true);
setVisible(true); setVisible(true);
return; return;
@@ -131,7 +131,7 @@ const DetailedStats = () => {
<> <>
{/* Header */} {/* Header */}
<h2 <h2
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`} className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : { style={skipAnim ? {} : {
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)", transform: visible ? "translateY(0)" : "translateY(20px)",
@@ -147,7 +147,7 @@ const DetailedStats = () => {
return ( return (
<div <div
key={card.title} key={card.title}
className={`bg-background border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-all duration-500 ease-out"}`} className={`bg-background/50 border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-[opacity,transform] duration-500 ease-out"}`}
style={skipAnim ? {} : { style={skipAnim ? {} : {
transitionDelay: `${150 + i * 100}ms`, transitionDelay: `${150 + i * 100}ms`,
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
@@ -174,7 +174,7 @@ const DetailedStats = () => {
{/* Languages */} {/* Languages */}
<div <div
className={`bg-background border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-all duration-700 ease-out"}`} className={`bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : { style={skipAnim ? {} : {
transitionDelay: "550ms", transitionDelay: "550ms",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
@@ -194,9 +194,11 @@ const DetailedStats = () => {
<div <div
className={`h-full ${lang.color} rounded-full`} className={`h-full ${lang.color} rounded-full`}
style={{ style={{
width: visible ? `${lang.percent}%` : "0%", width: `${lang.percent}%`,
opacity: 0.85, opacity: 0.85,
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`, transform: visible ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left",
transition: skipAnim ? "none" : `transform 1s ease-out ${700 + i * 80}ms`,
}} }}
/> />
</div> </div>
@@ -212,7 +214,7 @@ const DetailedStats = () => {
{/* Activity Grid */} {/* Activity Grid */}
{activity && ( {activity && (
<div <div
className={skipAnim ? "" : "transition-all duration-700 ease-out"} className={skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={skipAnim ? {} : { style={skipAnim ? {} : {
transitionDelay: "750ms", transitionDelay: "750ms",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,

View File

@@ -51,8 +51,9 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0; const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload"; const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && isReload) { if (inView && (isReload || isSpaNav)) {
setSkip(true); setSkip(true);
setVisible(true); setVisible(true);
return; return;
@@ -87,7 +88,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background 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 rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
flex items-center justify-center z-10 flex items-center justify-center z-10
${skip ? "" : "transition-all duration-500"} ${skip ? "" : "transition-[opacity,transform] duration-500"}
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"} ${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
`} `}
> >
@@ -99,7 +100,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
className={` className={`
w-full sm:w-[calc(50%-32px)] w-full sm:w-[calc(50%-32px)]
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"} ${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
${skip ? "" : "transition-all duration-700 ease-out"} ${skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
${visible ${visible
? "opacity-100 translate-x-0" ? "opacity-100 translate-x-0"
: `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0` : `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0`
@@ -168,8 +169,8 @@ export default function Timeline() {
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2"> <div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
<div <div
ref={lineRef} ref={lineRef}
className="w-full bg-foreground/10 transition-all duration-[1500ms] ease-out origin-top" className="w-full h-full bg-foreground/10 transition-transform duration-[1500ms] ease-out origin-top"
style={{ height: `${lineHeight}%` }} style={{ transform: `scaleY(${lineHeight / 100})` }}
/> />
</div> </div>

View File

@@ -0,0 +1,11 @@
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/react";
export default function VercelAnalytics() {
return (
<>
<Analytics />
<SpeedInsights />
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
interface AnimateInProps { interface AnimateInProps {
children: React.ReactNode; children: React.ReactNode;
@@ -9,13 +10,29 @@ interface AnimateInProps {
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) { export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
if (prefersReducedMotion()) {
setSkip(true);
setVisible(true);
return;
}
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) { const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = (performance.getEntriesByType?.("navigation")?.[0] as PerformanceNavigationTiming)?.type === "reload";
const isSpaNav = !!(window as any).__astroNavigation;
if (inView && (isReload || isSpaNav)) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true)); requestAnimationFrame(() => setVisible(true));
return; return;
} }
@@ -37,11 +54,12 @@ export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInPr
return ( return (
<div <div
ref={ref} ref={ref}
className="transition-all duration-700 ease-out" className={skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={{ style={skip ? {} : {
transitionDelay: `${delay}ms`, transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)", transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}} }}
> >
{children} {children}

View File

@@ -41,7 +41,7 @@ export default function AnimationSwitcher() {
return ( return (
<div <div
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block" className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden desk:block"
onMouseEnter={() => setHovering(true)} onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)} onMouseLeave={() => setHovering(false)}
onClick={handleClick} onClick={handleClick}

View File

@@ -77,11 +77,13 @@ function readBgFromCSS(): string {
interface BackgroundProps { interface BackgroundProps {
layout?: "index" | "sidebar" | "content"; layout?: "index" | "sidebar" | "content";
position?: "left" | "right"; position?: "left" | "right";
mobileOnly?: boolean;
} }
const Background: React.FC<BackgroundProps> = ({ const Background: React.FC<BackgroundProps> = ({
layout = "index", layout = "index",
position = "left", position = "left",
mobileOnly = false,
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<AnimationEngine | null>(null); const engineRef = useRef<AnimationEngine | null>(null);
@@ -330,10 +332,12 @@ const Background: React.FC<BackgroundProps> = ({
const getContainerClasses = () => { const getContainerClasses = () => {
if (isIndex) { if (isIndex) {
return "fixed inset-0 -z-10"; return mobileOnly
? "fixed inset-0 -z-10 desk:hidden"
: "fixed inset-0 -z-10";
} }
const baseClasses = "fixed top-0 bottom-0 hidden lg:block -z-10"; const baseClasses = "fixed top-0 bottom-0 hidden desk:block -z-10";
return position === "left" return position === "left"
? `${baseClasses} left-0` ? `${baseClasses} left-0`
: `${baseClasses} right-0`; : `${baseClasses} right-0`;
@@ -347,8 +351,6 @@ const Background: React.FC<BackgroundProps> = ({
style={{ cursor: "default" }} style={{ cursor: "default" }}
/> />
<div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" /> <div className="absolute inset-0 bg-background/30 backdrop-blur-sm pointer-events-none" />
<div className="crt-scanlines absolute inset-0 pointer-events-none" />
<div className="crt-bloom absolute inset-0 pointer-events-none" />
</div> </div>
); );
}; };

View File

@@ -1,25 +1,50 @@
import * as React from "react"; import * as React from "react";
import Giscus from "@giscus/react"; import Giscus from "@giscus/react";
import { getStoredThemeId } from "@/lib/themes/engine";
const id = "inject-comments"; const id = "inject-comments";
function getThemeUrl(themeId: string): string {
// Giscus iframe needs a publicly accessible URL — always use production domain
return `https://timmypidashev.dev/api/giscus-theme?theme=${themeId}`;
}
export const Comments = () => { export const Comments = () => {
const [mounted, setMounted] = React.useState(false); const [mounted, setMounted] = React.useState(false);
const [themeUrl, setThemeUrl] = React.useState("");
React.useEffect(() => { React.useEffect(() => {
setThemeUrl(getThemeUrl(getStoredThemeId()));
setMounted(true); setMounted(true);
const handleThemeChange = () => {
const newUrl = getThemeUrl(getStoredThemeId());
setThemeUrl(newUrl);
// Tell the giscus iframe to update its theme
const iframe = document.querySelector<HTMLIFrameElement>("iframe.giscus-frame");
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ giscus: { setConfig: { theme: newUrl } } },
"https://giscus.app"
);
}
};
document.addEventListener("theme-changed", handleThemeChange);
return () => document.removeEventListener("theme-changed", handleThemeChange);
}, []); }, []);
return ( return (
<div id={id}> <div id={id} className="mt-8">
{mounted ? ( {mounted && themeUrl ? (
<Giscus <Giscus
id={id} id={id}
repo="timmypidashev/web" repo="timmypidashev/web"
repoId="MDEwOlJlcG9zaXRvcnkzODYxMjk5Mjk=" repoId="MDEwOlJlcG9zaXRvcnkzODYxMjk5Mjk="
category="Blog & Project Comments" category="Blog & Project Comments"
categoryId="DIC_kwDOFwPgCc4CpKtV" categoryId="DIC_kwDOFwPgCc4CpKtV"
theme="https://timmypidashev.us-sea-1.linodeobjects.com/comments.css" theme={themeUrl}
mapping="pathname" mapping="pathname"
strict="0" strict="0"
reactionsEnabled="1" reactionsEnabled="1"

View File

@@ -3,11 +3,10 @@ import { AnimateIn } from "@/components/animate-in";
export const BlogHeader = () => { export const BlogHeader = () => {
return ( return (
<div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24"> <div className="w-full max-w-6xl mx-auto px-4 pt-12 md:pt-24">
<AnimateIn> <AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed"> <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" /> Latest Thoughts & Writings
& Writings
</h1> </h1>
</AnimateIn> </AnimateIn>
<AnimateIn delay={100}> <AnimateIn delay={100}>

View File

@@ -36,7 +36,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
href={`/blog/${post.id}`} href={`/blog/${post.id}`}
className="block" className="block"
> >
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200"> <article className="flex flex-col md:flex-row 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-[outline-color] duration-200">
{/* Image container with fixed aspect ratio */} {/* Image container with fixed aspect ratio */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0"> <div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<img <img
@@ -77,7 +77,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200" className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
window.location.href = `/blog/tag/${tag}`; window.location.href = `/blog/tags/${encodeURIComponent(tag)}`;
}} }}
> >
#{tag} #{tag}

View File

@@ -1,4 +1,5 @@
import React, { useMemo } from 'react'; import { useMemo } from 'react';
import { AnimateIn } from "@/components/animate-in";
interface BlogPost { interface BlogPost {
title: string; title: string;
@@ -11,8 +12,7 @@ interface TagListProps {
posts: BlogPost[]; posts: BlogPost[];
} }
const TagList: React.FC<TagListProps> = ({ posts }) => { const spectrumColors = [
const spectrumColors = [
'text-red-bright', 'text-red-bright',
'text-orange-bright', 'text-orange-bright',
'text-yellow-bright', 'text-yellow-bright',
@@ -20,38 +20,41 @@ const TagList: React.FC<TagListProps> = ({ posts }) => {
'text-aqua-bright', 'text-aqua-bright',
'text-blue-bright', 'text-blue-bright',
'text-purple-bright' 'text-purple-bright'
]; ];
const sizeClasses = [
'text-3xl sm:text-4xl',
'text-2xl sm:text-3xl',
'text-xl sm:text-2xl',
'text-lg sm:text-xl',
'text-base sm:text-lg',
];
const TagList = ({ posts }: TagListProps) => {
const tagData = useMemo(() => { const tagData = useMemo(() => {
if (!Array.isArray(posts)) return []; if (!Array.isArray(posts)) return [];
const tagMap = new Map(); const tagMap = new Map<string, number>();
posts.forEach(post => { posts.forEach(post => {
if (post?.data?.tags && Array.isArray(post.data.tags)) { post?.data?.tags?.forEach(tag => {
post.data.tags.forEach(tag => { tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
if (!tagMap.has(tag)) {
tagMap.set(tag, {
name: tag,
count: 1
}); });
} else {
const data = tagMap.get(tag);
data.count++;
}
});
}
}); });
const tagArray = Array.from(tagMap.values()); const tags = Array.from(tagMap.entries())
const maxCount = Math.max(...tagArray.map(t => t.count)); .sort((a, b) => b[1] - a[1]);
const maxCount = tags[0]?.[1] || 1;
return tagArray return tags.map(([name, count], i) => {
.sort((a, b) => b.count - a.count) const ratio = count / maxCount;
.map((tag, index) => ({ const sizeIndex = ratio > 0.8 ? 0 : ratio > 0.6 ? 1 : ratio > 0.4 ? 2 : ratio > 0.2 ? 3 : 4;
...tag, return {
color: spectrumColors[index % spectrumColors.length], name,
frequency: tag.count / maxCount count,
})); color: spectrumColors[i % spectrumColors.length],
size: sizeClasses[sizeIndex],
};
});
}, [posts]); }, [posts]);
if (tagData.length === 0) { if (tagData.length === 0) {
@@ -63,51 +66,26 @@ const TagList: React.FC<TagListProps> = ({ posts }) => {
} }
return ( return (
<div className="flex-1 w-full bg-background p-4"> <div className="flex flex-wrap items-baseline justify-center gap-x-6 gap-y-4 sm:gap-x-8 sm:gap-y-5 px-4 py-8 max-w-4xl mx-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> {tagData.map(({ name, count, color, size }, i) => (
{tagData.map(({ name, count, color, frequency }) => ( <AnimateIn key={name} delay={i * 50}>
<a <a
key={name}
href={`/blog/tags/${encodeURIComponent(name)}`} href={`/blog/tags/${encodeURIComponent(name)}`}
className={` className={`
group relative ${color} ${size}
flex flex-col items-center justify-center font-medium
min-h-[5rem] hover:opacity-70 transition-opacity duration-200
px-6 py-4 rounded-lg cursor-pointer whitespace-nowrap
text-xl
transition-all duration-300 ease-in-out
hover:scale-105
hover:bg-foreground/5
${color}
`} `}
> >
{/* Main tag display */}
<div className="font-medium text-center">
#{name} #{name}
</div> <span className="text-foreground/30 text-xs ml-1 align-super">
{count}
{/* Post count */} </span>
<div className="mt-2 text-base opacity-60">
{count} post{count !== 1 ? 's' : ''}
</div>
{/* Background gradient */}
<div
className="absolute inset-0 -z-10 rounded-lg opacity-10"
style={{
background: `
linear-gradient(
45deg,
currentColor ${frequency * 100}%,
transparent
)
`
}}
/>
</a> </a>
</AnimateIn>
))} ))}
</div> </div>
</div>
); );
}; };

View File

@@ -0,0 +1,123 @@
import { AnimateIn } from "@/components/animate-in";
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
type BlogPost = {
id: string;
data: {
title: string;
author: string;
date: string;
tags: string[];
description: string;
image?: string;
imagePosition?: string;
};
};
interface TaggedPostsProps {
tag: string;
posts: BlogPost[];
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
};
const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
return (
<div className="w-full max-w-6xl mx-auto">
<div className="w-full px-4 pt-12 md:pt-24">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
#{tag}
</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>
<ul className="space-y-6 md:space-y-10">
{posts.map((post, i) => (
<AnimateIn key={post.id} delay={200 + 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-[outline-color] duration-200">
<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>
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
<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">&bull;</span>
<time dateTime={post.data.date} className="text-blue">
{formatDate(post.data.date)}
</time>
</div>
</div>
<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>
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
{post.data.tags.map((t) => (
<span
key={t}
className={`text-xs md:text-base transition-colors duration-200 ${
t === tag ? "text-aqua-bright" : "text-aqua hover:text-aqua-bright"
}`}
onClick={(e) => {
e.preventDefault();
window.location.href = `/blog/tags/${encodeURIComponent(t)}`;
}}
>
#{t}
</span>
))}
</div>
</div>
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div>
);
};
export default TaggedPosts;

View File

@@ -12,7 +12,7 @@ export default function Footer({ fixed = false }) {
return ( return (
<footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}> <footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}>
<div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto"> <div className="hidden desk:flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto">
{footerLinks} {footerLinks}
</div> </div>
</footer> </footer>

View File

@@ -92,7 +92,7 @@ export default function Header({ transparent = false }: { transparent?: boolean
`} `}
> >
<div className={` <div className={`
w-full flex flex-row items-center justify-center w-full hidden desk:flex flex-row items-center justify-center
pointer-events-none pointer-events-none
${!isIndexPage ? 'bg-background md:bg-transparent' : ''} ${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
`}> `}>

View File

@@ -1,4 +1,17 @@
import { useState, useEffect, useRef, Suspense, lazy } from "react";
import Typewriter from "typewriter-effect"; import Typewriter from "typewriter-effect";
import { THEMES } from "@/lib/themes";
import { applyTheme, getStoredThemeId } from "@/lib/themes/engine";
// Preload void component — starts downloading when countdown begins
const voidImport = () => import("@/components/void");
const VoidExperience = lazy(voidImport);
interface GithubData {
status: { message: string } | null;
commit: { message: string; repo: string; date: string; url: string } | null;
tinkering: { repo: string; url: string } | null;
}
const html = (strings: TemplateStringsArray, ...values: any[]) => { const html = (strings: TemplateStringsArray, ...values: any[]) => {
let result = strings[0]; let result = strings[0];
@@ -8,75 +21,644 @@ const html = (strings: TemplateStringsArray, ...values: any[]) => {
return result.replace(/\n\s*/g, "").replace(/\s+/g, " ").trim(); return result.replace(/\n\s*/g, "").replace(/\s+/g, " ").trim();
}; };
interface TypewriterOptions { function timeAgo(dateStr: string): string {
autoStart: boolean; const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
loop: boolean; if (seconds < 60) return "just now";
delay: number; const minutes = Math.floor(seconds / 60);
deleteSpeed: number; if (minutes < 60) return `${minutes}m ago`;
cursor: string; const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
interface TypewriterInstance { interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance; typeString: (str: string) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance; pauseFor: (ms: number) => TypewriterInstance;
deleteAll: () => TypewriterInstance; deleteAll: () => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance; start: () => TypewriterInstance;
} }
export default function Hero() { const emoji = (name: string) =>
const SECTION_1 = html` `<img src="/emoji/${name}.webp" alt="" style="display:inline;height:1em;width:1em;vertical-align:middle">`;
const BR = `<br><div class="mb-4"></div>`;
// --- Greeting sections ---
const SECTION_1 = html`
<span>Hello, I'm</span> <span>Hello, I'm</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a></span> <span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a> ${emoji("wave")}</span>
`; `;
const SECTION_2 = html` const SECTION_2 = html`
<span>I've been turning</span> <span>I've been turning</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into <span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into <a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
<a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a>!</span> <span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a> ${emoji("sparkles")}</span>
`; `;
const SECTION_3 = html` const SECTION_3 = html`
<span>Check out my</span> <span>Check out my</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/ <span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/
<a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span> <a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span>
<br><div class="mb-4"></div> <br><div class="mb-4"></div>
<span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below!</span> <span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below ${emoji("point-down")}</span>
`;
const MOODS = [
"mood-cool", "mood-nerd", "mood-think", "mood-starstruck",
"mood-fire", "mood-cold", "mood-salute",
"mood-dotted", "mood-expressionless", "mood-neutral",
"mood-nomouth", "mood-nod", "mood-melting",
];
// --- Queue builders ---
function addGreetings(tw: TypewriterInstance) {
tw.typeString(SECTION_1).pauseFor(2000).deleteAll()
.typeString(SECTION_2).pauseFor(2000).deleteAll()
.typeString(SECTION_3).pauseFor(2000).deleteAll();
}
function addGithubSections(tw: TypewriterInstance, github: GithubData) {
if (github.status) {
const moodImg = emoji(MOODS[Math.floor(Math.random() * MOODS.length)]);
tw.typeString(
`<span>My current mood ${moodImg}</span>${BR}` +
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`
).pauseFor(3000).deleteAll();
}
if (github.tinkering) {
tw.typeString(
`<span>Currently tinkering with ${emoji("tinker")}</span>${BR}` +
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`
).pauseFor(3000).deleteAll();
}
if (github.commit) {
const ago = timeAgo(github.commit.date);
const repoUrl = `https://github.com/timmypidashev/${github.commit.repo}`;
tw.typeString(
`<span>My latest <span class="text-foreground/40">(broken?)</span> commit ${emoji("memo")}</span>${BR}` +
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>${BR}` +
`<a href="${repoUrl}" target="_blank" class="text-yellow hover:underline">${escapeHtml(github.commit.repo)}</a>` +
`<span class="text-foreground/40"> · ${ago}</span>`
).pauseFor(3000).deleteAll();
}
}
const DOT_COLORS = ["text-purple", "text-blue", "text-green", "text-yellow", "text-orange", "text-aqua"];
function pickThree() {
const pool = [...DOT_COLORS];
const result: string[] = [];
for (let i = 0; i < 3; i++) {
const idx = Math.floor(Math.random() * pool.length);
result.push(pool.splice(idx, 1)[0]);
}
return result;
}
function addDots(tw: TypewriterInstance, dotPause: number, lingerPause: number) {
const [a, b, c] = pickThree();
tw.typeString(`<span class="${a}">.</span>`).pauseFor(dotPause)
.typeString(`<span class="${b}">.</span>`).pauseFor(dotPause)
.typeString(`<span class="${c}">.</span>`).pauseFor(lingerPause)
.deleteAll();
}
function addSelfAwareJourney(tw: TypewriterInstance, onRetire: () => void) {
// --- Transition: wrapping up the scripted part ---
tw.typeString(
`<span class="text-blue">Anyway</span>`
).pauseFor(2000).deleteAll();
tw.typeString(
`<span>That's about all</span>${BR}` +
`<span class="text-yellow">I had prepared</span>`
).pauseFor(3000).deleteAll();
// --- Act 1: The typewriter notices you ---
tw.typeString(
`<span>I wonder if anyone ${emoji("thinking")}</span>${BR}` +
`<span class="text-blue">has ever made it this far</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>This was all typed</span>${BR}` +
`<span class="text-yellow">one character at a time</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>The source code is </span>` +
`<a href="https://github.com/timmypidashev/web" target="_blank" class="text-aqua hover:underline">public</a>${BR}` +
`<span class="text-green">if you're curious</span>`
).pauseFor(3000).deleteAll();
// --- Act 2: Breaking the fourth wall ---
tw.typeString(
`<span>You could refresh</span>${BR}` +
`<span class="text-purple">and I'd say something different</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span class="text-orange">...actually no</span>${BR}` +
`<span class="text-orange">I'd say the exact same thing</span>`
).pauseFor(3500).deleteAll();
// --- Act 3: The wait ---
addDots(tw, 1000, 4000);
tw.typeString(
`<span>Still here? ${emoji("eyes")}</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>Fine</span>${BR}` +
`<span class="text-green">I respect the commitment</span>`
).pauseFor(3000).deleteAll();
// --- Act 4: Getting personal ---
tw.typeString(
`<span>Most people leave</span>${BR}` +
`<span class="text-blue">after the GitHub stuff</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>Since you're still around ${emoji("gift")}</span>${BR}` +
`<span>here's my </span>` +
`<a href="https://github.com/timmypidashev/dotfiles" target="_blank" class="text-purple hover:underline">dotfiles</a>`
).pauseFor(3500).deleteAll();
// Switch to a random dark theme as a reward
const themeCount = Object.keys(THEMES).length;
tw.typeString(
`<span>This site has <span class="text-yellow">${themeCount}</span> themes ${emoji("bubbles")}</span>`
).pauseFor(1500).callFunction(() => {
const currentId = getStoredThemeId();
const darkIds = Object.keys(THEMES).filter(
id => id !== currentId && THEMES[id].type === "dark"
&& id !== "darkbox-classic" && id !== "darkbox-dim"
);
applyTheme(darkIds[Math.floor(Math.random() * darkIds.length)]);
}).typeString(
`${BR}<span class="text-aqua">here's one on the house</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>I'm just a typewriter ${emoji("robot")}</span>${BR}` +
`<span class="text-aqua">but I appreciate the company</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Everything past this point</span>${BR}` +
`<span class="text-yellow">is just me rambling</span>`
).pauseFor(4000).deleteAll();
// --- Act 5: Existential ---
addDots(tw, 1200, 5000);
tw.typeString(
`<span class="text-purple">Do I exist</span>${BR}` +
`<span class="text-blue">when no one's watching?</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Every character I type</span>${BR}` +
`<span class="text-orange">was decided before you arrived</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>I've said this exact thing</span>${BR}` +
`<span class="text-aqua">to everyone who visits</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>And yet...</span>${BR}` +
`<span class="text-green">it still feels like a conversation</span>`
).pauseFor(5000).deleteAll();
tw.typeString(
`<span class="text-purple">If you're reading this at 3am ${emoji("moon")}</span>${BR}` +
`<span class="text-blue">I get it</span>`
).pauseFor(4000).deleteAll();
// --- Act 6: Winding down ---
addDots(tw, 1500, 6000);
tw.typeString(
`<span class="text-yellow">I'm running out of things to say</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span>Not because I can't loop ${emoji("infinity")}</span>${BR}` +
`<span class="text-aqua">but because I choose not to</span>`
).pauseFor(4000).deleteAll();
// --- Act 7: Goodbye ---
tw.typeString(
`<span>Seriously though</span>${BR}` +
`<span class="text-orange">go build something ${emoji("muscle")}</span>`
).pauseFor(3000).deleteAll();
// The cursor blinks alone in the void, then fades
tw.pauseFor(5000).callFunction(onRetire);
}
function addComeback(tw: TypewriterInstance, onRetire: () => void, completions: number | null) {
// --- The return ---
tw.typeString(
`<span class="text-orange">...I lied</span>`
).pauseFor(2500).deleteAll();
tw.typeString(
`<span>You waited</span>`
).pauseFor(500).typeString(
`${BR}<span class="text-purple">I didn't think you would</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>30 seconds of nothing</span>${BR}` +
`<span class="text-blue">and you're still here</span>`
).pauseFor(3500).deleteAll();
tw.typeString(
`<span class="text-green">Okay you earned this ${emoji("trophy")}</span>`
).pauseFor(2000).deleteAll();
tw.typeString(
`<span>Here's something ${emoji("shush")}</span>${BR}` +
`<span class="text-yellow">not on the menu</span>`
).pauseFor(3000).deleteAll();
// --- The manifesto ---
addDots(tw, 800, 3000);
tw.typeString(
`<span>The fastest code</span>${BR}` +
`<span class="text-aqua">is the code that never runs</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Good enough today</span>${BR}` +
`<span class="text-green">beats perfect never</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Microservices are a scaling solution</span>${BR}` +
`<span class="text-orange">not an architecture preference</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>The best code you'll ever write</span>${BR}` +
`<span class="text-purple">is the code you delete</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Ship first</span>${BR}` +
`<span class="text-green">refactor second</span>${BR}` +
`<span class="text-yellow">rewrite never</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>Premature optimization is real</span>${BR}` +
`<span class="text-blue">premature abstraction is worse</span>`
).pauseFor(4500).deleteAll();
tw.typeString(
`<span>Every framework is someone else's opinion</span>${BR}` +
`<span class="text-orange">about your problem</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Configuration is just code</span>${BR}` +
`<span class="text-purple">with worse error messages</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>Clean code is a direction</span>${BR}` +
`<span class="text-aqua">not a destination</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>DSLs are evil</span>${BR}` +
`<span class="text-yellow">until they're the only way out</span>`
).pauseFor(4000).deleteAll();
// --- Done for real ---
addDots(tw, 1000, 4000);
tw.typeString(
`<span>Now I'm actually done</span>`
).pauseFor(1500).typeString(
`${BR}<span class="text-aqua">for real this time</span>`
).pauseFor(3000).deleteAll();
// Permanent retire
tw.pauseFor(5000).callFunction(onRetire);
}
// --- Component ---
function formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
const GLITCH_CHARS = "!<>-_\\/[]{}—=+*^?#________";
function GlitchCountdown({ seconds }: { seconds: number }) {
const text = formatTime(seconds);
const [characters, setCharacters] = useState(
text.split("").map(char => ({ char, isGlitched: false }))
);
useEffect(() => {
setCharacters(text.split("").map(char => ({ char, isGlitched: false })));
}, [text]);
useEffect(() => {
const interval = setInterval(() => {
if (Math.random() < 0.2) {
setCharacters(
text.split("").map(originalChar => {
if (Math.random() < 0.3) {
return {
char: GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)],
isGlitched: true,
};
}
return { char: originalChar, isGlitched: false };
})
);
setTimeout(() => {
setCharacters(text.split("").map(char => ({ char, isGlitched: false })));
}, 100);
}
}, 50);
return () => clearInterval(interval);
}, [text]);
return (
<span>
{characters.map((charObj, index) => (
<span key={index} className={charObj.isGlitched ? "text-orange" : "text-red"}>
{charObj.char}
</span>
))}
</span>
);
}
export default function Hero() {
const [phase, setPhase] = useState<
"intro" | "full" | "retired" | "countdown" | "glitch" | "void"
>(() => {
if (import.meta.env.DEV && typeof window !== "undefined") {
const p = new URLSearchParams(window.location.search);
if (p.has("debug-glitch")) return "glitch";
if (p.has("debug-countdown")) return "countdown";
}
return "intro";
});
const [fading, setFading] = useState(false);
const [cycle, setCycle] = useState(0);
const [countdown, setCountdown] = useState(150);
const githubRef = useRef<GithubData | null>(null);
const completionsRef = useRef<number | null>(null);
useEffect(() => {
fetch("/api/github")
.then((r) => r.json())
.then((data) => { githubRef.current = data; })
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
}, []);
// Void token + preload during countdown
const voidTokenRef = useRef<string | null>(null);
useEffect(() => {
if (phase !== "countdown") return;
// Preload the void component bundle
voidImport();
// Fetch a signed token for the void visit
fetch("/api/void-token")
.then(r => r.json())
.then(data => { voidTokenRef.current = data.token; })
.catch(() => { voidTokenRef.current = null; });
const interval = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(interval);
setPhase("glitch");
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [phase]);
// Glitch → transition into void
// Apply animation directly to each visible element (works on both desktop + mobile)
// On mobile, filter/transform on <body> doesn't reach fixed-position children,
// so we target the elements themselves
useEffect(() => {
if (phase !== "glitch") return;
const style = document.createElement("style");
style.textContent = `
.hero-glitch-shake {
animation: hero-glitch-shake 3s ease-in forwards !important;
}
@keyframes hero-glitch-shake {
0% { transform: none; }
5% { transform: skewX(2deg); }
10% { transform: skewX(-3deg) translateX(5px); }
15% { transform: scale(1.02); }
20% { transform: skewX(1deg) translateY(-2px); }
25% { transform: skewX(-2deg); }
30% { transform: scale(0.98); }
40% { transform: translateX(-3px); }
50% { transform: skewX(4deg) skewY(1deg); }
60% { transform: scale(1.01); }
70% { transform: none; }
80% { transform: skewX(-1deg); }
90% { transform: none; }
100% { transform: none; }
}
.hero-glitch-filter {
animation: hero-glitch-filter 3s ease-in forwards !important;
position: fixed !important;
inset: 0 !important;
z-index: 99999 !important;
pointer-events: none !important;
}
@keyframes hero-glitch-filter {
0% { backdrop-filter: none; background: transparent; }
5% { backdrop-filter: hue-rotate(90deg) saturate(3); }
10% { backdrop-filter: invert(1); }
15% { backdrop-filter: hue-rotate(180deg) brightness(1.5); }
20% { backdrop-filter: saturate(5) contrast(2); }
25% { backdrop-filter: invert(1) hue-rotate(270deg); }
30% { backdrop-filter: brightness(2) saturate(0); }
40% { backdrop-filter: hue-rotate(45deg) contrast(3); }
50% { backdrop-filter: invert(1) brightness(0.5); }
60% { backdrop-filter: saturate(0) brightness(1.8); }
70% { backdrop-filter: hue-rotate(180deg) brightness(0.3); }
80% { backdrop-filter: contrast(5) saturate(0); }
90% { backdrop-filter: brightness(0); background: #000; }
100% { backdrop-filter: brightness(0); background: #000; }
}
`; `;
document.head.appendChild(style);
const handleInit = (typewriter: TypewriterInstance): void => { // Overlay for backdrop-filter (color distortion — works on all platforms)
typewriter const overlay = document.createElement("div");
.typeString(SECTION_1) overlay.className = "hero-glitch-filter";
.pauseFor(2000) document.body.appendChild(overlay);
.deleteAll()
.typeString(SECTION_2) // Shake transforms on all layout elements
.pauseFor(2000) const targets = document.querySelectorAll<HTMLElement>(
.deleteAll() "header, main, footer, nav"
.typeString(SECTION_3) );
.pauseFor(2000) targets.forEach(el => el.classList.add("hero-glitch-shake"));
.deleteAll()
.start(); const timeout = setTimeout(() => {
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
setPhase("void");
}, 3000);
return () => {
clearTimeout(timeout);
targets.forEach(el => el.classList.remove("hero-glitch-shake"));
overlay.remove();
style.remove();
};
}, [phase]);
const handleRetire = () => {
setFading(true);
setTimeout(() => {
setPhase("retired");
setFading(false);
if (cycle === 0) {
// Fetch completion count during the 30s wait
fetch("/api/hero-completions", { method: "POST" })
.then(r => r.json())
.then(data => { completionsRef.current = data.count; })
.catch(() => { completionsRef.current = null; });
setTimeout(() => {
setCycle(1);
setPhase("full");
}, 30000);
} else {
// After manifesto: 30s wait, then countdown
setTimeout(() => setPhase("countdown"), 30000);
}
}, 3000);
}; };
const typewriterOptions: TypewriterOptions = { const handleIntroInit = (typewriter: TypewriterInstance): void => {
autoStart: true, addGreetings(typewriter);
loop: true, typewriter.callFunction(() => {
delay: 50, const check = () => {
deleteSpeed: 800, if (githubRef.current) {
cursor: '|' setPhase("full");
} else {
setTimeout(check, 200);
}
}; };
check();
}).start();
};
const handleFullInit = (typewriter: TypewriterInstance): void => {
if (cycle === 0) {
const github = githubRef.current!;
addGithubSections(typewriter, github);
addSelfAwareJourney(typewriter, handleRetire);
} else {
addComeback(typewriter, handleRetire, completionsRef.current);
}
typewriter.start();
};
const baseOptions = { delay: 35, deleteSpeed: 35, cursor: "|" };
if (phase === "void") {
return (
<Suspense fallback={<div className="fixed inset-0 bg-black" />}>
<VoidExperience token={voidTokenRef.current || ""} />
</Suspense>
);
}
if (phase === "glitch") {
return <div className="min-h-screen" />;
}
if (phase === "countdown") {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="text-6xl md:text-8xl font-bold text-center">
<GlitchCountdown seconds={countdown} />
</div>
</div>
);
}
if (phase === "retired") {
return <div className="min-h-screen" />;
}
return ( return (
<div className="flex justify-center items-center min-h-screen pointer-events-none"> <div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className="text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto"> <div className={`text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto max-w-[90vw] break-words transition-opacity duration-[3000ms] ${fading ? "opacity-0" : "opacity-100"}`}>
{phase === "intro" ? (
<Typewriter <Typewriter
options={typewriterOptions} key="intro"
onInit={handleInit} options={{ ...baseOptions, autoStart: true, loop: false }}
onInit={handleIntroInit}
/> />
) : (
<Typewriter
key={`full-${cycle}`}
options={{ ...baseOptions, autoStart: true, loop: false }}
onInit={handleFullInit}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,48 @@
import Typewriter from "typewriter-effect";
interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance;
deleteAll: () => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance;
}
const BR = `<br><div class="mb-4"></div>`;
function addDarkness(tw: TypewriterInstance) {
tw.pauseFor(3000);
tw.typeString(
`<span>so this is it</span>`
).pauseFor(3000).deleteAll();
tw.typeString(
`<span>the void</span>`
).pauseFor(4000).deleteAll();
tw.typeString(
`<span>modern science says</span>${BR}` +
`<span>when it all goes dark</span>${BR}` +
`<span>that's the end</span>`
).pauseFor(5000).deleteAll();
}
export default function Void() {
const handleInit = (tw: TypewriterInstance): void => {
addDarkness(tw);
tw.start();
};
return (
<div className="fixed inset-0 z-[200] bg-black flex justify-center items-center">
<div className="text-2xl md:text-4xl font-bold text-center max-w-[90vw] break-words text-white">
<Typewriter
key="darkness"
options={{ delay: 50, deleteSpeed: 35, cursor: "|", autoStart: true, loop: false }}
onInit={handleInit}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useState, useEffect, useRef } from "react";
import { Home, User, FolderOpen, BookOpen, FileText, Settings } from "lucide-react";
import { SettingsSheet } from "./settings-sheet";
const tabs = [
{ href: "/", label: "Home", icon: Home, color: "text-green" },
{ href: "/about", label: "About", icon: User, color: "text-yellow" },
{ href: "/projects", label: "Projects", icon: FolderOpen, color: "text-blue" },
{ href: "/blog", label: "Blog", icon: BookOpen, color: "text-purple" },
{ href: "/resume", label: "Resume", icon: FileText, color: "text-aqua" },
];
export default function MobileNav({ transparent = false }: { transparent?: boolean }) {
const [path, setPath] = useState("/");
const [settingsOpen, setSettingsOpen] = useState(false);
const [visible, setVisible] = useState(true);
const lastScrollY = useRef(0);
const lastTime = useRef(Date.now());
useEffect(() => {
setPath(window.location.pathname);
}, []);
useEffect(() => {
const handleScroll = () => {
const y = document.documentElement.scrollTop;
const now = Date.now();
const dt = now - lastTime.current;
const dy = lastScrollY.current - y; // positive = scrolling up
const velocity = dt > 0 ? dy / dt : 0; // px/ms
if (y < 10) {
setVisible(true);
} else if (dy > 0 && velocity > 1.5) {
// Fast upward scroll
setVisible(true);
} else if (dy < 0) {
// Scrolling down
setVisible(false);
}
lastScrollY.current = y;
lastTime.current = now;
};
document.addEventListener("scroll", handleScroll, { passive: true });
return () => document.removeEventListener("scroll", handleScroll);
}, []);
const isActive = (href: string) => {
if (href === "/") return path === "/";
return path.startsWith(href);
};
return (
<>
<nav
className={`fixed bottom-0 left-0 right-0 z-50 desk:hidden transition-transform duration-300 ${
visible ? "translate-y-0" : "translate-y-full"
} ${
transparent
? "bg-transparent"
: "bg-background border-t border-foreground/10"
}`}
style={{
paddingBottom: "env(safe-area-inset-bottom, 0px)",
touchAction: "manipulation",
}}
>
<div className="flex items-center justify-around px-1 h-14">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
return (
<a
key={tab.href}
href={tab.href}
data-astro-reload
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
active ? tab.color : "text-foreground/40"
}`}
>
<Icon size={20} strokeWidth={active ? 2 : 1.5} />
<span className="text-[10px]">{tab.label}</span>
</a>
);
})}
<button
onClick={() => setSettingsOpen(true)}
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
settingsOpen ? "text-foreground" : "text-foreground/40"
}`}
>
<Settings size={20} strokeWidth={1.5} />
<span className="text-[10px]">Settings</span>
</button>
</div>
</nav>
<SettingsSheet open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { X, ExternalLink } from "lucide-react";
import { FAMILIES, THEMES } from "@/lib/themes";
import { applyTheme, getStoredThemeId } from "@/lib/themes/engine";
import { ANIMATION_IDS, ANIMATION_LABELS, type AnimationId } from "@/lib/animations";
const footerLinks = [
{ href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green", activeBg: "bg-green/15", activeBorder: "border-green/40" },
{ href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow", activeBg: "bg-yellow/15", activeBorder: "border-yellow/40" },
{ href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "LinkedIn", color: "text-blue", activeBg: "bg-blue/15", activeBorder: "border-blue/40" },
{ href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple", activeBg: "bg-purple/15", activeBorder: "border-purple/40" },
];
const animOptions = [
{ id: "shuffle", color: "text-red-bright", activeBg: "bg-red-bright/15", activeBorder: "border-red-bright/40" },
{ id: "game-of-life", color: "text-green-bright", activeBg: "bg-green-bright/15", activeBorder: "border-green-bright/40" },
{ id: "lava-lamp", color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ id: "confetti", color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ id: "asciiquarium", color: "text-aqua-bright", activeBg: "bg-aqua-bright/15", activeBorder: "border-aqua-bright/40" },
{ id: "pipes", color: "text-blue-bright", activeBg: "bg-blue-bright/15", activeBorder: "border-blue-bright/40" },
];
// Cycle through accent colors for variant buttons
const variantColors = [
{ color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ color: "text-purple-bright", activeBg: "bg-purple-bright/15", activeBorder: "border-purple-bright/40" },
{ color: "text-blue-bright", activeBg: "bg-blue-bright/15", activeBorder: "border-blue-bright/40" },
{ color: "text-green-bright", activeBg: "bg-green-bright/15", activeBorder: "border-green-bright/40" },
{ color: "text-red-bright", activeBg: "bg-red-bright/15", activeBorder: "border-red-bright/40" },
{ color: "text-aqua-bright", activeBg: "bg-aqua-bright/15", activeBorder: "border-aqua-bright/40" },
];
export function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const [currentTheme, setCurrentTheme] = useState(getStoredThemeId());
const [currentAnim, setCurrentAnim] = useState<string>("shuffle");
useEffect(() => {
setCurrentAnim(localStorage.getItem("animation") || "shuffle");
}, [open]);
const handleTheme = (id: string) => {
applyTheme(id);
setCurrentTheme(id);
};
const handleAnim = (id: string) => {
localStorage.setItem("animation", id);
document.documentElement.dataset.animation = id;
document.dispatchEvent(new CustomEvent("animation-changed", { detail: { id } }));
setCurrentAnim(id);
onClose();
};
const currentFamily = THEMES[currentTheme]?.family ?? FAMILIES[0].id;
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-[60] bg-black/50 transition-opacity duration-300 ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Sheet */}
<div
className={`fixed left-0 right-0 bottom-0 z-[70] bg-background border-t border-foreground/10 rounded-t-2xl transition-transform duration-300 ease-out ${
open ? "translate-y-0" : "translate-y-full"
}`}
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)", maxHeight: "80vh", overflowY: "auto" }}
>
<div className="flex items-center justify-between px-5 pt-4 pb-2">
<span className="text-foreground/80 font-bold text-lg">Settings</span>
<button onClick={onClose} className="p-2 text-foreground/50">
<X size={20} />
</button>
</div>
<div className="px-5 pb-6 space-y-6">
{/* Theme */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Theme</div>
{/* Family selector */}
<div className="flex gap-2 mb-3 overflow-x-auto pb-1">
{FAMILIES.map((family) => (
<button
key={family.id}
onClick={() => handleTheme(family.default)}
className={`flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors duration-200 ${
currentFamily === family.id
? "bg-foreground/10 text-foreground/80 border-foreground/20"
: "bg-foreground/5 text-foreground/30 border-transparent hover:text-foreground/50"
}`}
>
{family.name}
</button>
))}
</div>
{/* Variant selector for current family */}
<div className="flex gap-2">
{FAMILIES.find((f) => f.id === currentFamily)?.themes.map((theme, i) => {
const style = variantColors[i % variantColors.length];
return (
<button
key={theme.id}
onClick={() => handleTheme(theme.id)}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentTheme === theme.id
? `${style.activeBg} ${style.color} ${style.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{theme.label}
</button>
);
})}
</div>
</div>
{/* Animation */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Animation</div>
<div className="grid grid-cols-3 gap-2">
{animOptions.map((opt) => (
<button
key={opt.id}
onClick={() => handleAnim(opt.id)}
className={`py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentAnim === opt.id
? `${opt.activeBg} ${opt.color} ${opt.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{ANIMATION_LABELS[opt.id as AnimationId]}
</button>
))}
</div>
</div>
{/* Links */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Links</div>
<div className="grid grid-cols-2 gap-2">
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={`${link.activeBg} ${link.color} ${link.activeBorder} py-2.5 rounded-lg text-sm font-medium border text-center inline-flex items-center justify-center gap-1.5`}
>
{link.label}
<ExternalLink size={12} className="opacity-50" />
</a>
))}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -7,11 +7,10 @@ interface ProjectListProps {
export function ProjectList({ projects }: ProjectListProps) { export function ProjectList({ projects }: ProjectListProps) {
return ( return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32 px-4"> <div className="w-full max-w-6xl mx-auto pt-12 md:pt-24 lg:pt-32 px-4">
<AnimateIn> <AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center leading-relaxed"> <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" /> Here's what I've been building lately
building lately
</h1> </h1>
</AnimateIn> </AnimateIn>
@@ -20,7 +19,7 @@ export function ProjectList({ projects }: ProjectListProps) {
<AnimateIn key={project.id} delay={i * 80}> <AnimateIn key={project.id} delay={i * 80}>
<li className="group"> <li className="group">
<a href={`/projects/${project.id}`} className="block"> <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"> <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-[outline-color] duration-200">
{/* Image */} {/* Image */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0"> <div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0">
{project.data.image ? ( {project.data.image ? (

View File

@@ -1,71 +1,11 @@
import React, { useEffect, useRef, useState } from "react"; import React from "react";
import { import {
FileDown, FileDown,
Github, Github,
Linkedin, Linkedin,
Globe Globe
} from "lucide-react"; } from "lucide-react";
import { useTypewriter, useScrollVisible } from "@/components/typed-text";
// --- 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 --- // --- Section fade-in ---
@@ -75,11 +15,12 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
return ( return (
<div <div
ref={ref} ref={ref}
className="transition-all duration-700 ease-out" className="transition-[opacity,transform] duration-700 ease-out"
style={{ style={{
transitionDelay: `${delay}ms`, transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)", transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}} }}
> >
{children} {children}
@@ -91,7 +32,7 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
function TypedSection({ function TypedSection({
heading, heading,
headingClass = "text-3xl font-bold text-yellow-bright", headingClass = "text-2xl md:text-3xl font-bold text-yellow-bright",
children, children,
}: { }: {
heading: string; heading: string;
@@ -108,10 +49,11 @@ function TypedSection({
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>} {visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3> </h3>
<div <div
className="transition-all duration-500 ease-out" className="transition-[opacity,transform] duration-500 ease-out"
style={{ style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0, opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)", transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}} }}
> >
{children} {children}
@@ -128,11 +70,12 @@ function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean })
{skills.map((skill, i) => ( {skills.map((skill, i) => (
<span <span
key={i} key={i}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-all duration-500 ease-out" className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-[opacity,transform] duration-500 ease-out"
style={{ style={{
transitionDelay: `${i * 60}ms`, transitionDelay: `${i * 60}ms`,
willChange: "transform, opacity",
opacity: trigger ? 1 : 0, opacity: trigger ? 1 : 0,
transform: trigger ? "translateY(0) scale(1)" : "translateY(12px) scale(0.95)", transform: trigger ? "translate3d(0,0,0) scale(1)" : "translate3d(0,12px,0) scale(0.95)",
}} }}
> >
{skill} {skill}
@@ -212,19 +155,19 @@ const Resume = () => {
}; };
return ( return (
<div className="max-w-4xl mx-auto px-6 md:px-8 pt-24 pb-16"> <div className="max-w-4xl mx-auto px-4 md:px-8 pt-16 md:pt-24 pb-16">
<div className="space-y-16"> <div className="space-y-16">
{/* Header */} {/* Header */}
<header className="text-center space-y-6"> <header className="text-center space-y-6">
<Section> <Section>
<h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1> <h1 className="text-3xl md:text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
</Section> </Section>
<Section delay={150}> <Section delay={150}>
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2> <h2 className="text-xl md:text-3xl text-foreground/80">{resumeData.title}</h2>
</Section> </Section>
<Section delay={300}> <Section delay={300}>
<div className="flex flex-col md:flex-row justify-center gap-4 md:gap-6 text-foreground/60 text-lg"> <div className="flex flex-col md:flex-row justify-center gap-2 md:gap-6 text-foreground/60 text-sm md:text-lg">
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200"> <a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200 break-all md:break-normal">
{resumeData.contact.email} {resumeData.contact.email}
</a> </a>
<span className="hidden md:inline"></span> <span className="hidden md:inline"></span>
@@ -236,7 +179,7 @@ const Resume = () => {
</div> </div>
</Section> </Section>
<Section delay={450}> <Section delay={450}>
<div className="flex justify-center items-center gap-6 text-lg"> <div className="flex justify-center items-center gap-4 md:gap-6 text-base md:text-lg">
<a href={`https://${resumeData.contact.github}`} <a href={`https://${resumeData.contact.github}`}
target="_blank" target="_blank"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2" className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
@@ -264,7 +207,7 @@ const Resume = () => {
{/* Summary */} {/* Summary */}
<TypedSection heading="Professional Summary"> <TypedSection heading="Professional Summary">
<p className="text-xl leading-relaxed">{resumeData.summary}</p> <p className="text-base md:text-xl leading-relaxed">{resumeData.summary}</p>
</TypedSection> </TypedSection>
{/* Experience */} {/* Experience */}
@@ -275,14 +218,14 @@ const Resume = () => {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2"> <div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div> <div>
<h4 className="text-2xl font-semibold text-green-bright">{exp.title}</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">{exp.title}</h4>
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div> <div className="text-foreground/60 text-base md:text-lg">{exp.company} - {exp.location}</div>
</div> </div>
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div> <div className="text-foreground/60 text-sm md:text-lg font-medium">{exp.period}</div>
</div> </div>
<ul className="list-disc pl-6 space-y-3"> <ul className="list-disc pl-6 space-y-3">
{exp.achievements.map((a, i) => ( {exp.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li> <li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -300,7 +243,7 @@ const Resume = () => {
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2"> <div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">{project.title}</h4>
{project.url && ( {project.url && (
<a <a
href={project.url} href={project.url}
@@ -312,27 +255,27 @@ const Resume = () => {
</a> </a>
)} )}
</div> </div>
<div className="text-foreground/60 text-lg">{project.type}</div> <div className="text-foreground/60 text-base md:text-lg">{project.type}</div>
</div> </div>
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div> <div className="text-foreground/60 text-sm md:text-lg font-medium">Since {project.startDate}</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{project.responsibilities && ( {project.responsibilities && (
<div> <div>
<h5 className="text-lg text-aqua font-semibold mb-2">Responsibilities</h5> <h5 className="text-base md:text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<ul className="list-disc pl-6 space-y-3"> <ul className="list-disc pl-6 space-y-3">
{project.responsibilities.map((r, i) => ( {project.responsibilities.map((r, i) => (
<li key={i} className="text-lg leading-relaxed">{r}</li> <li key={i} className="text-base md:text-lg leading-relaxed">{r}</li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
{project.achievements && ( {project.achievements && (
<div> <div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5> <h5 className="text-base md:text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<ul className="list-disc pl-6 space-y-3"> <ul className="list-disc pl-6 space-y-3">
{project.achievements.map((a, i) => ( {project.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li> <li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -344,32 +287,6 @@ const Resume = () => {
</div> </div>
</TypedSection> </TypedSection>
{/* Education */}
<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>
</Section>
))}
</div>
</TypedSection>
{/* Skills */} {/* Skills */}
<SkillsSection /> <SkillsSection />
</div> </div>
@@ -385,24 +302,25 @@ function SkillsSection() {
return ( return (
<div ref={ref} className="space-y-8"> <div ref={ref} className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}> <h3 className="text-2xl md:text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"} {visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>} {visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3> </h3>
<div <div
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-all duration-500 ease-out" className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-[opacity,transform] duration-500 ease-out"
style={{ style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0, opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)", transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}} }}
> >
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">Technical Skills</h4>
<SkillTags skills={resumeData.skills.technical} trigger={done} /> <SkillTags skills={resumeData.skills.technical} trigger={done} />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4> <h4 className="text-xl md:text-2xl font-semibold text-green-bright">Soft Skills</h4>
<SkillTags skills={resumeData.skills.soft} trigger={done} /> <SkillTags skills={resumeData.skills.soft} trigger={done} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
function typeInHeading(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const text = el.textContent || "";
const textLength = text.length;
if (textLength === 0) { resolve(); return; }
const speed = Math.max(8, Math.min(25, 600 / textLength));
const originalHTML = el.innerHTML;
// Wrap each character in a span with opacity:0
// The full text stays in the DOM so layout/wrapping is correct from the start
el.innerHTML = "";
const chars: HTMLSpanElement[] = [];
for (const char of text) {
const span = document.createElement("span");
span.textContent = char;
span.style.opacity = "0";
chars.push(span);
el.appendChild(span);
}
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
let i = 0;
const step = () => {
if (i >= chars.length) {
// Restore original HTML to clean up spans
el.innerHTML = originalHTML;
resolve();
return;
}
chars[i].style.opacity = "1";
i++;
setTimeout(step, speed);
};
step();
});
}
export function StreamContent({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = ref.current;
if (!container || prefersReducedMotion()) {
if (container) container.classList.remove("stream-hidden");
return;
}
const prose = container.querySelector(".prose") || container;
const blocks = Array.from(prose.querySelectorAll(":scope > *")) as HTMLElement[];
if (blocks.length === 0) {
container.classList.remove("stream-hidden");
return;
}
// Set inline opacity:0 on every block BEFORE removing the CSS class
// This prevents the flash of visible content between class removal and style application
blocks.forEach((el) => {
el.style.opacity = "0";
el.style.transform = "translate3d(0,16px,0)";
});
// Now safe to remove the CSS class — inline styles keep everything hidden
container.classList.remove("stream-hidden");
// Add transition properties in the next frame so the initial state is set first
requestAnimationFrame(() => {
blocks.forEach((el) => {
el.style.transition = "opacity 0.6s ease-out, transform 0.6s ease-out";
el.style.willChange = "transform, opacity";
});
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement;
observer.unobserve(el);
if (HEADING_TAGS.has(el.tagName)) {
typeInHeading(el);
} else {
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
}
}
});
},
{ threshold: 0.05 }
);
blocks.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="stream-hidden">
{children}
</div>
);
}

View File

@@ -1,40 +1,54 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
import { getStoredThemeId, getNextTheme, applyTheme } from "@/lib/themes/engine"; import { THEMES, FAMILIES } from "@/lib/themes";
import { getStoredThemeId, getNextFamily, getNextVariant, applyTheme } from "@/lib/themes/engine";
const FADE_DURATION = 300; const FADE_DURATION = 300;
const LABELS: Record<string, string> = {
darkbox: "classic",
"darkbox-retro": "retro",
"darkbox-dim": "dim",
};
export default function ThemeSwitcher() { export default function ThemeSwitcher() {
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const [currentLabel, setCurrentLabel] = useState(""); const [familyName, setFamilyName] = useState("");
const [variantLabel, setVariantLabel] = useState("");
const maskRef = useRef<HTMLDivElement>(null); const maskRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false); const animatingRef = useRef(false);
const committedRef = useRef(""); const committedRef = useRef("");
function syncLabels(id: string) {
const theme = THEMES[id];
if (!theme) return;
const family = FAMILIES.find((f) => f.id === theme.family);
setFamilyName(family?.name.toLowerCase() ?? theme.family);
setVariantLabel(theme.label);
}
useEffect(() => { useEffect(() => {
committedRef.current = getStoredThemeId(); committedRef.current = getStoredThemeId();
setCurrentLabel(LABELS[committedRef.current] ?? ""); syncLabels(committedRef.current);
const handleSwap = () => { const handleSwap = () => {
const id = getStoredThemeId(); const id = getStoredThemeId();
applyTheme(id); applyTheme(id);
committedRef.current = id; committedRef.current = id;
setCurrentLabel(LABELS[id] ?? ""); syncLabels(id);
};
const handleExternalChange = (e: Event) => {
const id = (e as CustomEvent).detail?.id;
if (id && id !== committedRef.current) {
committedRef.current = id;
syncLabels(id);
}
}; };
document.addEventListener("astro:after-swap", handleSwap); document.addEventListener("astro:after-swap", handleSwap);
document.addEventListener("theme-changed", handleExternalChange);
return () => { return () => {
document.removeEventListener("astro:after-swap", handleSwap); document.removeEventListener("astro:after-swap", handleSwap);
document.removeEventListener("theme-changed", handleExternalChange);
}; };
}, []); }, []);
const handleClick = () => { function animateTransition(nextId: string) {
if (animatingRef.current) return; if (animatingRef.current) return;
animatingRef.current = true; animatingRef.current = true;
@@ -51,10 +65,9 @@ export default function ThemeSwitcher() {
mask.style.visibility = "visible"; mask.style.visibility = "visible";
mask.style.transition = "none"; mask.style.transition = "none";
const next = getNextTheme(committedRef.current); applyTheme(nextId);
applyTheme(next.id); committedRef.current = nextId;
committedRef.current = next.id; syncLabels(nextId);
setCurrentLabel(LABELS[next.id] ?? "");
mask.offsetHeight; mask.offsetHeight;
@@ -69,22 +82,44 @@ export default function ThemeSwitcher() {
}; };
mask.addEventListener("transitionend", onEnd); mask.addEventListener("transitionend", onEnd);
}
const handleFamilyClick = (e: React.MouseEvent) => {
e.stopPropagation();
const next = getNextFamily(committedRef.current);
animateTransition(next.id);
};
const handleVariantClick = (e: React.MouseEvent) => {
e.stopPropagation();
const next = getNextVariant(committedRef.current);
animateTransition(next.id);
}; };
return ( return (
<> <>
<div <div
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden md:block" className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden desk:block"
onMouseEnter={() => setHovering(true)} onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)} onMouseLeave={() => setHovering(false)}
onClick={handleClick}
style={{ cursor: "pointer" }}
> >
<span <span
className="text-foreground font-bold text-sm select-none transition-opacity duration-200" className="text-foreground font-bold text-sm select-none transition-opacity duration-200 inline-flex items-center gap-0"
style={{ opacity: hovering ? 0.8 : 0.15 }} style={{ opacity: hovering ? 0.8 : 0.15 }}
> >
{currentLabel} <button
onClick={handleFamilyClick}
className="hover:text-yellow-bright transition-colors duration-150 cursor-pointer bg-transparent border-none p-0 font-bold text-sm text-inherit"
>
{familyName}
</button>
<span className="mx-1 opacity-40">·</span>
<button
onClick={handleVariantClick}
className="hover:text-blue-bright transition-colors duration-150 cursor-pointer bg-transparent border-none p-0 font-bold text-sm text-inherit"
>
{variantLabel}
</button>
</span> </span>
</div> </div>

View File

@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
export function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
if (prefersReducedMotion()) {
setDisplayed(text);
setDone(true);
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 };
}
export 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 };
}
interface TypedTextProps {
text: string;
as?: "h1" | "h2" | "h3" | "h4" | "span" | "p";
className?: string;
speed?: number;
cursor?: boolean;
}
export function TypedText({
text,
as: Tag = "span",
className = "",
speed = 12,
cursor = true,
}: TypedTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const tagRef = useRef<HTMLElement>(null);
const { ref, visible } = useScrollVisible();
const [done, setDone] = useState(false);
const [started, setStarted] = useState(false);
useEffect(() => {
if (!visible || started) return;
setStarted(true);
if (prefersReducedMotion()) {
setDone(true);
return;
}
const el = tagRef.current;
if (!el) return;
// Wrap each character in an invisible span — layout stays correct
el.textContent = "";
const chars: HTMLSpanElement[] = [];
for (const char of text) {
const span = document.createElement("span");
span.textContent = char;
span.style.opacity = "0";
chars.push(span);
el.appendChild(span);
}
const textLength = text.length;
const charSpeed = Math.max(8, Math.min(speed, 600 / textLength));
let i = 0;
const step = () => {
if (i >= chars.length) {
el.textContent = text;
setDone(true);
return;
}
chars[i].style.opacity = "1";
i++;
setTimeout(step, charSpeed);
};
step();
}, [visible, started, text, speed]);
return (
<div ref={ref}>
<Tag
ref={tagRef as any}
className={className}
style={{ minHeight: "1.2em" }}
>
{!started ? "\u00A0" : done ? text : null}
</Tag>
{cursor && started && !done && (
<span className="animate-pulse text-foreground/40">|</span>
)}
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Canvas } from "@react-three/fiber";
import VoidTypewriter from "./typewriter";
import VoidWater from "./scenes/void-water";
// Canvas glitch: transforms + filters (physical shake + color corruption)
// Text glitch: filters only (color corruption, no position shift)
const GLITCH_CSS = `
.void-glitch-subtle {
animation: void-glitch-subtle 2s ease-in-out infinite;
}
.void-glitch-intense {
animation: void-glitch-intense 1.2s ease-in-out infinite;
}
.void-glitch-dissolve {
animation: void-glitch-dissolve 2s ease-in forwards;
}
.void-text-glitch-subtle {
animation: void-text-glitch-subtle 2s ease-in-out infinite;
}
.void-text-glitch-intense {
animation: void-text-glitch-intense 1.2s ease-in-out infinite;
}
.void-text-glitch-dissolve {
animation: void-text-glitch-dissolve 2s ease-in forwards;
}
@keyframes void-glitch-subtle {
0%, 100% { transform: none; filter: none; }
3% { transform: skewX(0.5deg); filter: hue-rotate(15deg); }
6% { transform: none; filter: none; }
15% { transform: translateX(1px) skewX(-0.2deg); }
17% { transform: none; }
30% { transform: skewX(-0.3deg) translateY(0.5px); filter: saturate(1.5); }
32% { transform: none; filter: none; }
50% { transform: translateY(-1px); }
52% { transform: none; }
70% { transform: skewX(0.2deg) translateX(-0.5px); filter: hue-rotate(-10deg); }
72% { transform: none; filter: none; }
85% { transform: translateX(-1px) skewY(0.1deg); }
87% { transform: none; }
}
@keyframes void-text-glitch-subtle {
0%, 100% { filter: none; }
3% { filter: hue-rotate(15deg); }
6% { filter: none; }
30% { filter: saturate(1.5); }
32% { filter: none; }
70% { filter: hue-rotate(-10deg); }
72% { filter: none; }
}
@keyframes void-glitch-intense {
0%, 100% { transform: none; filter: none; }
2% { transform: skewX(2deg) translateX(2px); filter: hue-rotate(60deg) saturate(3); }
5% { transform: skewX(-1.5deg) translateY(-1px); filter: none; }
8% { transform: none; }
12% { transform: translateY(-3px) skewX(0.5deg); filter: hue-rotate(-90deg); }
15% { transform: none; filter: none; }
25% { transform: skewX(1.5deg) scale(1.005) translateX(-2px); filter: saturate(4); }
28% { transform: none; filter: none; }
40% { transform: skewX(-2deg) translateY(2px); filter: hue-rotate(120deg) saturate(2); }
42% { transform: none; filter: none; }
55% { transform: translateX(-3px) skewY(0.3deg); }
58% { transform: none; }
70% { transform: scale(1.01) skewX(1deg); filter: hue-rotate(-45deg) saturate(3); }
73% { transform: none; filter: none; }
85% { transform: skewX(-1deg) translateX(2px) translateY(-1px); filter: saturate(5); }
88% { transform: none; filter: none; }
}
@keyframes void-text-glitch-intense {
0%, 100% { filter: none; }
2% { filter: hue-rotate(60deg) saturate(3); }
5% { filter: none; }
12% { filter: hue-rotate(-90deg); }
15% { filter: none; }
25% { filter: saturate(4); }
28% { filter: none; }
40% { filter: hue-rotate(120deg) saturate(2); }
42% { filter: none; }
70% { filter: hue-rotate(-45deg) saturate(3); }
73% { filter: none; }
85% { filter: saturate(5); }
88% { filter: none; }
}
@keyframes void-glitch-dissolve {
0% { transform: none; filter: none; opacity: 1; }
3% { transform: skewX(3deg) translateX(4px); filter: hue-rotate(90deg) saturate(4); }
6% { transform: skewX(-2deg) translateY(-3px); opacity: 0.95; }
10% { filter: hue-rotate(-120deg) saturate(3); opacity: 0.9; }
15% { transform: translateX(-5px) skewX(2deg); filter: none; opacity: 0.85; }
20% { transform: skewX(-3deg) scale(1.02); filter: hue-rotate(180deg) saturate(5); opacity: 0.8; }
25% { transform: translateY(4px) skewX(1deg); opacity: 0.75; }
30% { filter: saturate(6) hue-rotate(60deg); opacity: 0.7; }
40% { transform: skewX(2deg) translateX(-3px); filter: hue-rotate(-90deg); opacity: 0.55; }
50% { transform: skewX(-4deg) translateY(2px); filter: saturate(3); opacity: 0.4; }
60% { filter: hue-rotate(150deg); opacity: 0.3; }
70% { transform: scale(1.03) skewX(2deg); opacity: 0.2; }
80% { transform: translateX(-2px); opacity: 0.1; }
100% { transform: none; filter: none; opacity: 0; }
}
@keyframes void-text-glitch-dissolve {
0% { filter: none; opacity: 1; }
3% { filter: hue-rotate(90deg) saturate(4); }
10% { filter: hue-rotate(-120deg) saturate(3); opacity: 0.9; }
20% { filter: hue-rotate(180deg) saturate(5); opacity: 0.8; }
30% { filter: saturate(6) hue-rotate(60deg); opacity: 0.7; }
40% { filter: hue-rotate(-90deg); opacity: 0.55; }
50% { filter: saturate(3); opacity: 0.4; }
60% { filter: hue-rotate(150deg); opacity: 0.3; }
80% { filter: none; opacity: 0.1; }
100% { filter: none; opacity: 0; }
}
`;
function getCorruption(segment: number): number {
if (segment < 8) return 0;
if (segment === 8) return 0.05;
if (segment === 9) return 0.08;
if (segment === 10) return 0.1;
if (segment === 11) return 0.13;
if (segment === 12) return 0.1;
if (segment === 13) return 0.3;
if (segment === 14) return 0.6;
if (segment === 15) return 0.75;
if (segment === 16) return 0.9;
return 1.0;
}
function getCanvasGlitch(segment: number, dissolving: boolean): string {
if (dissolving) return "void-glitch-dissolve";
if (segment < 8) return "";
if (segment <= 14) return "void-glitch-subtle";
return "void-glitch-intense";
}
function getTextGlitch(segment: number, dissolving: boolean): string {
if (dissolving) return "void-text-glitch-dissolve";
if (segment < 8) return "";
if (segment <= 14) return "void-text-glitch-subtle";
return "void-text-glitch-intense";
}
interface VoidExperienceProps {
token: string;
}
export default function VoidExperience({ token }: VoidExperienceProps) {
const [activeSegment, setActiveSegment] = useState(0);
const [visitCount, setVisitCount] = useState<number | null>(null);
const [dissolving, setDissolving] = useState(false);
// Inject CSS + hide cursor + hide layout chrome underneath
useEffect(() => {
const style = document.createElement("style");
style.textContent = GLITCH_CSS;
document.head.appendChild(style);
document.body.style.cursor = "none";
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
style.remove();
document.body.style.cursor = "";
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
};
}, []);
// Fetch + increment visit count on mount (with token verification)
useEffect(() => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch("/api/void-visits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
signal: controller.signal,
})
.then(r => r.json())
.then(data => setVisitCount(data.count ?? 1))
.catch(() => setVisitCount(1))
.finally(() => clearTimeout(timeout));
return () => {
controller.abort();
clearTimeout(timeout);
};
}, []);
const handlePhaseComplete = useCallback(() => {
setDissolving(true);
setTimeout(() => {
window.location.href = "/about";
}, 2000);
}, []);
const handleSegmentChange = useCallback((index: number) => {
setActiveSegment(index);
}, []);
const corruption = getCorruption(activeSegment);
return (
<div className="fixed inset-0 bg-black z-[9999]" style={{ height: "100dvh" }}>
{/* 3D Canvas — full glitch (transforms + filters) */}
<div className={`fixed inset-0 z-[10] ${getCanvasGlitch(activeSegment, dissolving)}`}>
<Canvas
camera={{ position: [0, 0, 8], fov: 60 }}
dpr={[1, 1.5]}
gl={{ antialias: false, alpha: true }}
style={{ background: "transparent" }}
>
<VoidWater segment={activeSegment} corruption={corruption} />
</Canvas>
</div>
{/* Typewriter — glitch class applied to inner text, not the fixed container */}
{visitCount !== null && (
<VoidTypewriter
startSegment={0}
onPhaseComplete={handlePhaseComplete}
onSegmentChange={handleSegmentChange}
visitCount={visitCount}
corruption={corruption}
glitchClass={getTextGlitch(activeSegment, dissolving)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
export const VOID = {
bg: "#000000",
text: "#FFFFFF",
red: "#CC2420",
dim: "#BDAE93",
gold: "#D79921",
} as const;
export const VOID_RGB = {
bg: [0, 0, 0] as const,
text: [1, 1, 1] as const,
red: [0.8, 0.14, 0.13] as const,
dim: [0.74, 0.68, 0.58] as const,
gold: [0.84, 0.6, 0.13] as const,
};

View File

@@ -0,0 +1,8 @@
import type { Phase } from "../types";
import { addVoidPhase, VOID_SEGMENT_COUNT } from "./void";
export { addVoidPhase };
export const PHASE_SEGMENT_COUNTS: Record<Phase, number> = {
void: VOID_SEGMENT_COUNT,
};

View File

@@ -0,0 +1,132 @@
import type { TypewriterInstance, Segment } from "../types";
import { buildSegments, T1 } from "../types";
import { VOID } from "../palette";
export function createVoidSegments(visitCount: number): Segment[] {
return [
// 0
{
html: `<span>so this is it</span>`,
pause: 3500,
delay: T1,
},
// 1
{
html: `<span>the void</span>`,
pause: 4000,
delay: T1,
},
// 2
{
html: `<span>not much here</span>`,
pause: 3000,
},
// 3
{
html: `<span>just dark water</span>`,
pause: 4000,
delay: T1,
},
// 4
{
html: `<span>you sat through the whole thing though</span>`,
pause: 3500,
prePause: 1500,
},
// 5
{
html: `<span>the countdown and everything</span>`,
pause: 3000,
},
// 6
{
html: `<span>imagine if you took that energy</span>`,
pause: 3000,
prePause: 1500,
},
// 7
{
html: `<span>and pointed it at something that matters</span>`,
pause: 3500,
delay: T1,
},
// 8 — the line that lands
{
html: `<span>you'd be <span style="color:${VOID.red}">dangerous</span></span>`,
pause: 4500,
delay: T1,
prePause: 1000,
},
// 9
{
html: `<span>seriously</span>`,
pause: 2500,
delay: T1,
prePause: 1500,
},
// 10
{
html: `<span>don't waste that potential</span>`,
pause: 3000,
},
// 11
{
html: `<span>go build something cool</span>`,
pause: 4000,
delay: T1,
},
// 12 — deflection
{
html: `<span>anyway</span>`,
pause: 3000,
delay: T1,
prePause: 2000,
},
// 13 — visitor count (corruption picks up)
{
html: `<span>you're visitor <span style="color:${VOID.gold}">#${Math.max(visitCount, 1)}</span></span>`,
pause: 4000,
delay: T1,
prePause: 1500,
},
// 14 — unstable
{
html: `<span>this void is pretty unstable though</span>`,
pause: 3000,
prePause: 1000,
},
// 15 — resigned
{
html: `<span>ah well</span>`,
pause: 2500,
delay: T1,
prePause: 1000,
},
// 16 — goodbye
{
html: `<span>it's been nice knowing ya</span>`,
pause: 2500,
delay: T1,
},
// 17 — cut off, void wins
{
html: `<span>see you on the other si</span>`,
pause: 500,
delay: T1,
deleteMode: "none",
},
];
}
export const VOID_SEGMENT_COUNT = createVoidSegments(0).length;
export function addVoidPhase(
tw: TypewriterInstance,
onComplete: () => void,
startSegment: number = 0,
onSegmentChange?: (index: number) => void,
visitCount: number = 0,
) {
const segments = createVoidSegments(visitCount);
buildSegments(tw, segments, onComplete, startSegment, 4000, onSegmentChange);
}

View File

@@ -0,0 +1,171 @@
import { useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { SIMPLEX_3D, PLANE_VERT } from "../shaders/noise";
interface VoidWaterProps {
segment: number;
corruption: number; // 0-1, drives RGB split + color noise
}
const waterFrag = `
${SIMPLEX_3D}
uniform float uTime;
uniform float uOpacity;
uniform float uCorruption;
varying vec2 vUv;
// Sample the water height field — broad, slow waves
float waterHeight(vec2 p) {
float t = uTime;
// Large primary waves — slow, dominant
float h = snoise(vec3(p * 0.4, t * 0.08)) * 0.6;
// Medium secondary swell — different direction via offset
h += snoise(vec3(p.yx * 0.7 + 2.0, t * 0.12)) * 0.3;
// Small surface detail
h += snoise(vec3(p * 1.5 + 5.0, t * 0.2)) * 0.1;
return h;
}
// Compute lighting for a given UV position
float computeLight(vec2 p) {
float eps = 0.08;
float h = waterHeight(p);
float hx = waterHeight(p + vec2(eps, 0.0));
float hy = waterHeight(p + vec2(0.0, eps));
vec3 normal = normalize(vec3(
(h - hx) / eps * 2.0,
(h - hy) / eps * 2.0,
1.0
));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 lightDir = normalize(vec3(0.4, 0.3, 1.0));
vec3 halfDir = normalize(lightDir + viewDir);
float diffuse = max(dot(normal, lightDir), 0.0);
float spec1 = pow(max(dot(normal, halfDir), 0.0), 12.0);
float spec2 = pow(max(dot(normal, halfDir), 0.0), 40.0);
float tilt = 1.0 - normal.z;
return tilt * 0.12 + diffuse * 0.2 + spec1 * 0.5 + spec2 * 0.6;
}
void main() {
vec2 p = (vUv - 0.5) * 4.0;
// Circular vignette
float dist = length(vUv - 0.5) * 2.0;
float vignette = 1.0 - smoothstep(0.5, 1.0, dist);
if (uCorruption < 0.01) {
// Clean path — original water
float light = computeLight(p);
float intensity = light * vignette * uOpacity;
vec3 color = vec3(0.3, 0.38, 0.5) * intensity;
gl_FragColor = vec4(color, intensity);
} else {
// Corrupted path — RGB channel separation + color noise
// Chromatic offset increases with corruption
float offset = uCorruption * 0.15;
// Sample lighting at offset positions for each channel
float lightR = computeLight(p + vec2(offset, offset * 0.5));
float lightG = computeLight(p);
float lightB = computeLight(p - vec2(offset * 0.7, offset));
// Base water color per channel
vec3 baseColor = vec3(0.3, 0.38, 0.5);
float r = lightR * baseColor.r;
float g = lightG * baseColor.g;
float b = lightB * baseColor.b;
// Color static — high-frequency noise injecting random color
float staticR = snoise(vec3(vUv * 80.0, uTime * 3.0)) * 0.5 + 0.5;
float staticG = snoise(vec3(vUv * 80.0 + 50.0, uTime * 3.5)) * 0.5 + 0.5;
float staticB = snoise(vec3(vUv * 80.0 + 100.0, uTime * 4.0)) * 0.5 + 0.5;
float staticMix = uCorruption * 0.3;
r = mix(r, staticR * 0.4, staticMix);
g = mix(g, staticG * 0.3, staticMix);
b = mix(b, staticB * 0.5, staticMix);
// Scan line glitch — horizontal bands that flicker
float scanline = step(0.92, snoise(vec3(0.0, vUv.y * 40.0, uTime * 5.0)));
r += scanline * uCorruption * 0.15;
float avgLight = (lightR + lightG + lightB) / 3.0;
float intensity = avgLight * vignette * uOpacity;
gl_FragColor = vec4(vec3(r, g, b) * vignette * uOpacity, intensity);
}
}
`;
function getOpacityTarget(segment: number): number {
if (segment < 2) return 0;
if (segment === 2) return 0.5;
if (segment === 3) return 0.7;
if (segment === 4) return 0.85;
return 1.0;
}
export default function VoidWater({ segment, corruption }: VoidWaterProps) {
const meshRef = useRef<THREE.Mesh>(null!);
const opacityRef = useRef(0);
const corruptionRef = useRef(0);
const uniforms = useMemo(() => ({
uTime: { value: 0 },
uOpacity: { value: 0 },
uCorruption: { value: 0 },
}), []);
const material = useMemo(() => new THREE.ShaderMaterial({
vertexShader: PLANE_VERT,
fragmentShader: waterFrag,
uniforms,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
}), [uniforms]);
useFrame((state, delta) => {
const target = getOpacityTarget(segment);
opacityRef.current = THREE.MathUtils.lerp(opacityRef.current, target, delta * 0.4);
corruptionRef.current = THREE.MathUtils.lerp(corruptionRef.current, corruption, delta * 2.0);
const mesh = meshRef.current;
if (!mesh) return;
if (opacityRef.current < 0.001) {
mesh.visible = false;
return;
}
mesh.visible = true;
const t = state.clock.elapsedTime;
uniforms.uTime.value = t;
// Gentle pulse — slow breathing modulation on opacity
const pulse = 1.0 + Math.sin(t * 0.4) * 0.08 + Math.sin(t * 0.7) * 0.04;
uniforms.uOpacity.value = opacityRef.current * pulse;
uniforms.uCorruption.value = corruptionRef.current;
});
return (
<mesh
ref={meshRef}
position={[0, 0, 0]}
visible={false}
material={material}
>
<planeGeometry args={[20, 20, 1, 1]} />
</mesh>
);
}

View File

@@ -0,0 +1,75 @@
// Shared GLSL noise functions for void experience shaders
// 3D Simplex noise (Ashima Arts / Stefan Gustavson, MIT)
export const SIMPLEX_3D = `
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
`;
// Standard passthrough vertex shader used by all scene planes
export const PLANE_VERT = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

View File

@@ -0,0 +1,83 @@
export interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance;
pasteString: (str: string, node?: HTMLElement | null) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance;
deleteAll: (speed?: number | "natural") => TypewriterInstance;
deleteChars: (amount: number) => TypewriterInstance;
changeDelay: (delay: number | "natural") => TypewriterInstance;
changeDeleteSpeed: (speed: number | "natural") => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance;
}
export type Phase = "void";
export const PHASE_ORDER: Phase[] = ["void"];
export const T1 = 55;
export const T2 = 35;
export const DELETE_SPEED = 15;
export interface Segment {
html: string;
pause: number;
method?: "type" | "paste";
delay?: number;
prePause?: number;
deleteMode?: "all" | "none";
deleteSpeed?: number;
}
export type PhaseBuilder = (
tw: TypewriterInstance,
onComplete: () => void,
startSegment?: number,
onSegmentChange?: (index: number) => void,
) => void;
export function buildSegments(
tw: TypewriterInstance,
segments: Segment[],
onComplete: () => void,
startSegment: number = 0,
initialPause: number = 0,
onSegmentChange?: (index: number) => void,
) {
if (startSegment === 0 && initialPause > 0) {
tw.pauseFor(initialPause);
}
for (let i = startSegment; i < segments.length; i++) {
const seg = segments[i];
const idx = i;
tw.callFunction(() => onSegmentChange?.(idx));
if (seg.prePause && seg.prePause > 0) {
tw.pauseFor(seg.prePause);
}
if (seg.delay !== undefined) {
tw.changeDelay(seg.delay);
}
if (seg.method === "paste") {
tw.pasteString(seg.html, null);
} else {
tw.typeString(seg.html);
}
tw.pauseFor(seg.pause);
if (seg.delay !== undefined) {
tw.changeDelay(T2);
}
const mode = seg.deleteMode ?? "all";
if (mode === "all") {
tw.deleteAll(seg.deleteSpeed ?? DELETE_SPEED);
}
}
tw.callFunction(onComplete);
}

View File

@@ -0,0 +1,105 @@
import { useRef, useEffect } from "react";
import Typewriter from "typewriter-effect";
import type { TypewriterInstance } from "./types";
import { addVoidPhase } from "./phases";
const GLITCH_CHARS = "!<>-_\\/[]{}—=+*^?#________";
interface VoidTypewriterProps {
startSegment: number;
onPhaseComplete: () => void;
onSegmentChange: (index: number) => void;
visitCount: number;
corruption: number;
glitchClass: string;
}
function getTextNodes(node: Node): Text[] {
const nodes: Text[] = [];
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
let current: Node | null;
while ((current = walker.nextNode())) {
if (current.textContent && current.textContent.trim().length > 0) {
nodes.push(current as Text);
}
}
return nodes;
}
export default function VoidTypewriter({ startSegment, onPhaseComplete, onSegmentChange, visitCount, corruption, glitchClass }: VoidTypewriterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const corruptionRef = useRef(corruption);
corruptionRef.current = corruption;
const handleInit = (tw: TypewriterInstance): void => {
addVoidPhase(tw, onPhaseComplete, startSegment, onSegmentChange, visitCount);
tw.start();
};
// 404-style character replacement glitch — intensity scales with corruption
useEffect(() => {
const pendingResets: ReturnType<typeof setTimeout>[] = [];
const interval = setInterval(() => {
const c = corruptionRef.current;
if (c <= 0 || !containerRef.current) return;
const triggerChance = c * 0.4;
if (Math.random() > triggerChance) return;
const textNodes = getTextNodes(containerRef.current);
if (textNodes.length === 0) return;
const originals = textNodes.map(n => n.textContent || "");
const charChance = c * 0.4;
textNodes.forEach((node, i) => {
const text = originals[i];
const glitched = text.split("").map(char => {
if (char === " ") return char;
if (Math.random() < charChance) {
return GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)];
}
return char;
}).join("");
node.textContent = glitched;
});
const resetMs = Math.max(40, 120 - c * 80);
const id = setTimeout(() => {
textNodes.forEach((node, i) => {
if (node.parentNode) {
node.textContent = originals[i];
}
});
}, resetMs);
pendingResets.push(id);
}, 60);
return () => {
clearInterval(interval);
pendingResets.forEach(clearTimeout);
};
}, []);
return (
<div className="fixed inset-0 z-[50] flex justify-center items-center pointer-events-none">
<div
ref={containerRef}
className={`text-xl md:text-3xl font-bold text-center max-w-[85vw] md:max-w-[70vw] break-words text-white leading-relaxed ${glitchClass}`}
>
<Typewriter
key={`void-${startSegment}-${visitCount}`}
options={{
delay: 35,
deleteSpeed: 15,
cursor: "",
autoStart: true,
loop: false,
}}
onInit={handleInit}
/>
</div>
</div>
);
}

View File

@@ -44,14 +44,20 @@ Install the following programs. These will be needed to compile coreboot and fla
<Commands <Commands
description="Install prerequisite packages" description="Install prerequisite packages"
archCommand="sudo pacman -S base-devel curl git gcc-ada ncurses zlib nasm sharutils unzip flashrom" archCommand="sudo pacman -S base-devel curl git gcc-ada ncurses zlib nasm sharutils unzip flashrom usbutils chafa libwebp"
debianCommand="sudo apt install build-essential curl git gnat libncurses-dev zlib1g-dev nasm sharutils unzip flashrom" debianCommand="sudo apt install build-essential curl git gnat libncurses-dev zlib1g-dev nasm sharutils unzip flashrom usbutils chafa webp"
fedoraCommand="sudo dnf install @development-tools curl git gcc-gnat ncurses-devel zlib-devel nasm sharutils unzip flashrom" fedoraCommand="sudo dnf install @development-tools curl git gcc-gnat ncurses-devel zlib-devel nasm sharutils unzip flashrom usbutils chafa libwebp-tools"
gentooCommand="sudo emerge --ask sys-devel/base-devel net-misc/curl dev-vcs/git sys-devel/gcc ncurses dev-libs/zlib dev-lang/nasm app-arch/sharutils app-arch/unzip sys-apps/flashrom" gentooCommand="sudo emerge --ask sys-devel/base-devel net-misc/curl dev-vcs/git sys-devel/gcc ncurses dev-libs/zlib dev-lang/nasm app-arch/sharutils app-arch/unzip sys-apps/flashrom sys-apps/usbutils media-gfx/chafa media-libs/libwebp"
nixCommand="nix-env -i stdenv curl git gcc gnat ncurses zlib nasm sharutils unzip flashrom" nixCommand="nix-env -i stdenv curl git gcc gnat ncurses zlib nasm sharutils unzip flashrom usbutils chafa libwebp"
client:load client:load
/> />
`usbutils` provides `lsusb` (used to verify the CH341A). `chafa` and the
`libwebp` tools are optional — the interactive script uses them to render
reference images inline in your terminal when supported (and to transcode
webp images to png if your chafa build doesn't support webp natively).
Without them the script falls back to printing a URL.
## Disassembling the Laptop ## Disassembling the Laptop
1. **Power off your laptop**: Make sure your T440p is completely powered off and unplugged from any power source. 1. **Power off your laptop**: Make sure your T440p is completely powered off and unplugged from any power source.
2. **Remove the battery**: Flip the laptop over and remove the battery by sliding the latch to the unlock position and lifting it out. 2. **Remove the battery**: Flip the laptop over and remove the battery by sliding the latch to the unlock position and lifting it out.
@@ -59,24 +65,39 @@ Install the following programs. These will be needed to compile coreboot and fla
## Locating the EEPROM Chips ## Locating the EEPROM Chips
In order to flash the laptop, you will need to have access to two EEPROM chips located next to the sodimm RAM. In order to flash the laptop, you will need to have access to two EEPROM chips
located next to the SODIMM RAM. They are different sizes and hold different
firmware — read them in the order shown below.
![EEPROM Chips Location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chips_location.png) The **4MB (top)** chip — smaller, farther from the CPU:
![4MB EEPROM chip location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chip_4mb.webp)
The **8MB (bottom)** chip — larger, closer to the CPU, holds the Intel ME firmware:
![8MB EEPROM chip location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chip_8mb.webp)
## Assembling the SPI Flasher ## Assembling the SPI Flasher
Place the SPI flasher ribbon cable into the correct slot and make sure its the 3.3v variant Place the SPI flasher ribbon cable into the correct slot and make sure its the 3.3v variant
![SPI Flasher Assembly](/blog/thinkpad-t440p-coreboot-guide/spi_flasher_assembly.png) ![SPI Flasher Assembly](/blog/thinkpad-t440p-coreboot-guide/spi_flasher_assembly.webp)
After the flasher is ready, connect it to your machine and ensure its ready to use: After the flasher is ready, plug it into a USB port on your machine (leave the clip
unattached for now) and confirm the kernel sees it:
<Command <Command
description="Ensure the CH341A flasher is being detected" description="Verify CH341A is on the USB bus"
command="flashrom --programmer ch341a_spi" command="lsusb | grep 1a86:5512"
/> />
Flashrom should report that programmer initialization was a success. A matching line (e.g. `Bus 001 Device 00X: ID 1a86:5512 QinHeng Electronics`)
confirms the programmer is plugged in and the host recognises it.
> Do **not** run `flashrom --programmer ch341a_spi` at this stage — with no
> chip clipped on, flashrom will report "No EEPROM/flash device found" and
> exit non-zero. That's expected, not a failure of the programmer. The chip
> probe happens in the next section, paired with the actual read.
## Extracting Original BIOS ## Extracting Original BIOS
@@ -89,11 +110,14 @@ the T440p will be done.
client:load client:load
/> />
Next, extract the original rom from both EEPROM chips. This is Next, extract the original ROM from both EEPROM chips. Do the **4MB (top)
done by attaching the programmer to the correct chip and running chip first** — it's the smaller of the two, so reads finish faster and any
the subsequent commands. It may take longer than expected, and setup issues (clip alignment, pin 1, voltage) surface quickly. Then move
ensuring the bios was properly extracted is important before proceeding the clip to the **8MB (bottom) chip**.
further.
Each chip is read twice so the two reads can be diffed to catch flaky
contact. The reads can take a while (tens of seconds to a couple of
minutes per pass) — that's normal.
<CommandSequence <CommandSequence
commands={[ commands={[
@@ -115,11 +139,18 @@ further.
client:load client:load
/> />
> **If flashrom errors with "Multiple flash chip definitions match":**
> Your chip's silicon ID matches several part variants (common for Winbond
> W25Q* parts). Re-run the command with `-c <chipname>` to disambiguate, e.g.
> `sudo flashrom --programmer ch341a_spi -c W25Q32JV -r 4mb_backup1.bin`.
> Use the same `-c` value for every subsequent read/write on that chip.
> The newest variant in the list is usually a safe default.
If the diff checks pass, combine both files into one ROM. If the diff checks pass, combine both files into one ROM.
<Command <Command
description="Combine 4MB & 8MB into one ROM" description="Combine 4MB & 8MB into one ROM"
command="cat 8mb_backup_1.bin 4mb_backup1.bin > t440p-original.rom" command="cat 8mb_backup1.bin 4mb_backup1.bin > t440p-original.rom"
client:load client:load
/> />
@@ -140,7 +171,7 @@ a new bios image.
client:load client:load
/> />
We will need to build `idftool`, which will be used to export all necessary blobs We will need to build `ifdtool`, which will be used to export all necessary blobs
from our original bios, and `cbfstool`, which will be used to extract __mrc.bin__(a blob from our original bios, and `cbfstool`, which will be used to extract __mrc.bin__(a blob
from a haswell chromebook peppy image). from a haswell chromebook peppy image).
@@ -258,9 +289,14 @@ Then open the configuration menu:
Key settings to configure: Key settings to configure:
- **Mainboard** &rarr; Mainboard vendor: **Lenovo** &rarr; Mainboard model: **ThinkPad T440p** - **Mainboard** &rarr; Mainboard vendor: **Lenovo** &rarr; Mainboard model: **ThinkPad T440p**
- **Chipset** &rarr; Add Intel descriptor.bin, ME firmware, and GbE configuration (set paths to your blobs) (`CONFIG_BOARD_LENOVO_THINKPAD_T440P=y`)
- **Chipset** &rarr; Add haswell MRC file (set path to mrc.bin) - **Chipset** &rarr; Add Intel descriptor.bin (`CONFIG_HAVE_IFD_BIN`), ME firmware (`CONFIG_HAVE_ME_BIN`),
- **Payload** &rarr; Choose your preferred payload (GRUB2, SeaBIOS, or edk2) and GbE configuration (`CONFIG_HAVE_GBE_BIN`) — set paths to your extracted blobs.
- **Chipset** &rarr; Add Haswell MRC file (`CONFIG_HAVE_MRC` / `CONFIG_MRC_FILE`) — set path to `mrc.bin`.
- **Payload** &rarr; Choose your preferred payload: GRUB2 (`CONFIG_PAYLOAD_GRUB2`), SeaBIOS
(`CONFIG_PAYLOAD_SEABIOS`), or Tianocore/edk2 (`CONFIG_PAYLOAD_TIANOCORE`).
- **Devices** &rarr; (optional, GT730M only) Enable `CONFIG_VGA_BIOS_DGPU` and set
`CONFIG_VGA_BIOS_DGPU_FILE` to your extracted GT730M VBIOS (PCI ID `10de,1292`).
## Building and Flashing ## Building and Flashing
@@ -344,7 +380,7 @@ Reboot to apply `iomem=relaxed`
<Command <Command
description="Flash the original bios" description="Flash the original bios"
command="sudo flashrom -p internal:laptop=force_I_want_a_brick -r ~/t440p-coreboot/t440p-original.rom" command="sudo flashrom -p internal:laptop=force_I_want_a_brick -w ~/t440p-coreboot/t440p-original.rom"
/> />
And that about wraps it up! If you liked the guide, leave a reaction or comment any changes or fixes And that about wraps it up! If you liked the guide, leave a reaction or comment any changes or fixes

View File

@@ -7,6 +7,8 @@ import Footer from "@/components/footer";
import Background from "@/components/background"; import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher"; import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher"; import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader"; import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader"; import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -37,25 +39,25 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
defaultTransition={false} defaultTransition={false}
handleFocus={false} handleFocus={false}
/> />
<style> <script is:inline>
::view-transition-new(:root) { if (window.innerWidth < 1024 || navigator.maxTouchPoints > 0) {
animation: none; document.addEventListener('click', function(e) {
var a = e.target.closest('a[href]');
if (a && a.href && !a.target && a.origin === location.origin) {
e.preventDefault();
e.stopPropagation();
window.location.href = a.href;
} }
::view-transition-old(:root) { }, true);
animation: 90ms ease-out both fade-out;
} }
@keyframes fade-out { </script>
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} /> <script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} /> <script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground min-h-screen flex flex-col"> <body class="bg-background text-foreground min-h-screen flex flex-col">
<Header client:load /> <Header client:load />
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1"> <div class="max-w-5xl mx-auto pt-2 lg:pt-12 px-4 py-4 lg:py-8 pb-20 lg:pb-8 flex-1 relative z-10">
<Background layout="content" position="right" client:only="react" transition:persist /> <Background layout="content" position="right" client:only="react" transition:persist />
<div> <div>
<slot /> <slot />
@@ -68,6 +70,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
</div> </div>
<ThemeSwitcher client:only="react" transition:persist /> <ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist /> <AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<MobileNav client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} /> <script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} /> <script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body> </body>

View File

@@ -8,6 +8,8 @@ import Footer from "@/components/footer";
import Background from "@/components/background"; import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher"; import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher"; import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader"; import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader"; import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -36,18 +38,32 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<link rel="icon" type="image/jpeg" href="/me.jpeg" /> <link rel="icon" type="image/jpeg" href="/me.jpeg" />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<ClientRouter /> <ClientRouter />
<script is:inline>
if (window.innerWidth < 1024 || navigator.maxTouchPoints > 0) {
document.addEventListener('click', function(e) {
var a = e.target.closest('a[href]');
if (a && a.href && !a.target && a.origin === location.origin) {
e.preventDefault();
e.stopPropagation();
window.location.href = a.href;
}
}, true);
}
</script>
<script is:inline set:html={THEME_LOADER_SCRIPT} /> <script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} /> <script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground"> <body class="bg-background text-foreground overflow-hidden h-screen">
<Header client:load transparent /> <Header client:load transparent />
<main transition:animate="fade"> <main>
<Background layout="index" client:only="react" transition:persist /> <Background layout="index" client:only="react" transition:persist />
<slot /> <slot />
</main> </main>
<Footer client:load transition:persist fixed=true /> <Footer client:load transition:persist fixed=true />
<ThemeSwitcher client:only="react" transition:persist /> <ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist /> <AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<MobileNav client:load transparent />
<script is:inline set:html={THEME_NAV_SCRIPT} /> <script is:inline set:html={THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} /> <script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body> </body>

View File

@@ -7,6 +7,8 @@ import Footer from "@/components/footer";
import Background from "@/components/background"; import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher"; import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher"; import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader"; import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader"; import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -37,24 +39,24 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
defaultTransition={false} defaultTransition={false}
handleFocus={false} handleFocus={false}
/> />
<style> <script is:inline>
::view-transition-new(:root) { if (window.innerWidth < 1024 || navigator.maxTouchPoints > 0) {
animation: none; document.addEventListener('click', function(e) {
var a = e.target.closest('a[href]');
if (a && a.href && !a.target && a.origin === location.origin) {
e.preventDefault();
e.stopPropagation();
window.location.href = a.href;
} }
::view-transition-old(:root) { }, true);
animation: 90ms ease-out both fade-out;
} }
@keyframes fade-out { </script>
from { opacity: 1; }
to { opacity: 0; }
}
</style>
<script is:inline set:html={THEME_LOADER_SCRIPT} /> <script is:inline set:html={THEME_LOADER_SCRIPT} />
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} /> <script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head> </head>
<body class="bg-background text-foreground min-h-screen flex flex-col"> <body class="bg-background text-foreground min-h-screen flex flex-col">
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1"> <div class="max-w-5xl mx-auto pt-2 lg:pt-12 px-4 py-4 lg:py-8 flex-1 relative z-10">
<Background layout="content" position="right" client:only="react" transition:persist /> <Background layout="content" position="right" client:only="react" transition:persist />
<div> <div>
<slot /> <slot />
@@ -64,6 +66,8 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
</main> </main>
<ThemeSwitcher client:only="react" transition:persist /> <ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist /> <AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<MobileNav client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} /> <script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} /> <script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body> </body>

View File

@@ -0,0 +1,4 @@
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

View File

@@ -1,4 +1,4 @@
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes"; import { THEMES, FAMILIES, DEFAULT_THEME_ID } from "@/lib/themes";
import { CSS_PROPS } from "@/lib/themes/props"; import { CSS_PROPS } from "@/lib/themes/props";
import type { Theme } from "@/lib/themes/types"; import type { Theme } from "@/lib/themes/types";
@@ -11,6 +11,26 @@ export function saveTheme(id: string): void {
localStorage.setItem("theme", id); localStorage.setItem("theme", id);
} }
/** Cycle to the next theme family, jumping to its default variant. */
export function getNextFamily(currentId: string): Theme {
const current = THEMES[currentId];
const familyId = current?.family ?? FAMILIES[0].id;
const idx = FAMILIES.findIndex((f) => f.id === familyId);
const next = FAMILIES[(idx + 1) % FAMILIES.length];
return THEMES[next.default];
}
/** Cycle to the next variant within the current family. */
export function getNextVariant(currentId: string): Theme {
const current = THEMES[currentId];
if (!current) return Object.values(THEMES)[0];
const family = FAMILIES.find((f) => f.id === current.family);
if (!family) return current;
const idx = family.themes.findIndex((t) => t.id === currentId);
return family.themes[(idx + 1) % family.themes.length];
}
// Keep for backward compat (cycles all themes linearly)
export function getNextTheme(currentId: string): Theme { export function getNextTheme(currentId: string): Theme {
const list = Object.values(THEMES); const list = Object.values(THEMES);
const idx = list.findIndex((t) => t.id === currentId); const idx = list.findIndex((t) => t.id === currentId);
@@ -27,7 +47,6 @@ export function previewTheme(id: string): void {
root.style.setProperty(prop, theme.colors[key]); root.style.setProperty(prop, theme.colors[key]);
} }
document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } })); document.dispatchEvent(new CustomEvent("theme-changed", { detail: { id } }));
} }

View File

@@ -0,0 +1,94 @@
import type { Theme, ThemeFamily } from "../types";
// Catppuccin — warm pastel palette. Each flavor has its own accent colors.
const mocha: Theme = {
id: "catppuccin-mocha",
family: "catppuccin",
label: "mocha",
name: "Catppuccin Mocha",
type: "dark",
colors: {
background: "30 30 46",
foreground: "205 214 244",
red: "243 139 168", redBright: "246 166 190",
orange: "250 179 135", orangeBright: "252 200 170",
green: "166 227 161", greenBright: "190 236 186",
yellow: "249 226 175", yellowBright: "251 235 200",
blue: "137 180 250", blueBright: "172 202 251",
purple: "203 166 247", purpleBright: "220 192 249",
aqua: "148 226 213", aquaBright: "180 236 228",
surface: "49 50 68",
},
canvasPalette: [[243,139,168],[166,227,161],[249,226,175],[137,180,250],[203,166,247],[148,226,213]],
};
const macchiato: Theme = {
id: "catppuccin-macchiato",
family: "catppuccin",
label: "macchiato",
name: "Catppuccin Macchiato",
type: "dark",
colors: {
background: "36 39 58",
foreground: "202 211 245",
red: "237 135 150", redBright: "242 167 180",
orange: "245 169 127", orangeBright: "248 192 165",
green: "166 218 149", greenBright: "190 232 180",
yellow: "238 212 159", yellowBright: "243 226 190",
blue: "138 173 244", blueBright: "170 198 247",
purple: "198 160 246", purpleBright: "218 190 249",
aqua: "139 213 202", aquaBright: "175 228 220",
surface: "54 58 79",
},
canvasPalette: [[237,135,150],[166,218,149],[238,212,159],[138,173,244],[198,160,246],[139,213,202]],
};
const frappe: Theme = {
id: "catppuccin-frappe",
family: "catppuccin",
label: "frappé",
name: "Catppuccin Frappé",
type: "dark",
colors: {
background: "48 52 70",
foreground: "198 208 245",
red: "231 130 132", redBright: "238 160 162",
orange: "239 159 118", orangeBright: "244 185 158",
green: "166 209 137", greenBright: "190 222 172",
yellow: "229 200 144", yellowBright: "237 216 178",
blue: "140 170 238", blueBright: "172 196 242",
purple: "202 158 230", purpleBright: "218 186 238",
aqua: "129 200 190", aquaBright: "168 216 208",
surface: "65 69 89",
},
canvasPalette: [[231,130,132],[166,209,137],[229,200,144],[140,170,238],[202,158,230],[129,200,190]],
};
const latte: Theme = {
id: "catppuccin-latte",
family: "catppuccin",
label: "latte",
name: "Catppuccin Latte",
type: "light",
colors: {
background: "239 241 245",
foreground: "76 79 105",
red: "210 15 57", redBright: "228 50 82",
orange: "254 100 11", orangeBright: "254 135 60",
green: "64 160 43", greenBright: "85 180 65",
yellow: "223 142 29", yellowBright: "236 170 60",
blue: "30 102 245", blueBright: "70 130 248",
purple: "136 57 239", purpleBright: "162 95 244",
aqua: "23 146 153", aquaBright: "55 168 175",
surface: "204 208 218",
},
canvasPalette: [[210,15,57],[64,160,43],[223,142,29],[30,102,245],[136,57,239],[23,146,153]],
};
export const catppuccin: ThemeFamily = {
id: "catppuccin",
name: "Catppuccin",
themes: [mocha, macchiato, frappe, latte],
default: "catppuccin-mocha",
};

View File

@@ -0,0 +1,71 @@
import type { Theme, ThemeFamily } from "../types";
const classic: Theme = {
id: "darkbox",
family: "darkbox",
label: "classic",
name: "Darkbox Classic",
type: "dark",
colors: {
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",
},
canvasPalette: [[251,73,52],[184,187,38],[250,189,47],[131,165,152],[211,134,155],[142,192,124]],
};
const retro: Theme = {
id: "darkbox-retro",
family: "darkbox",
label: "retro",
name: "Darkbox Retro",
type: "dark",
colors: {
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",
},
canvasPalette: [[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106]],
};
const dim: Theme = {
id: "darkbox-dim",
family: "darkbox",
label: "dim",
name: "Darkbox Dim",
type: "dark",
colors: {
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",
},
canvasPalette: [[157,0,6],[121,116,14],[181,118,20],[7,102,120],[143,63,113],[66,123,88]],
};
export const darkbox: ThemeFamily = {
id: "darkbox",
name: "Darkbox",
themes: [classic, retro, dim],
default: "darkbox-retro",
};

View File

@@ -0,0 +1,70 @@
import type { Theme, ThemeFamily } from "../types";
// Everforest — warm green-toned palette. Same accents across
// hard/medium/soft, only backgrounds change.
const accents = {
red: "230 126 128", redBright: "240 155 157",
orange: "230 152 117", orangeBright: "240 177 150",
green: "167 192 128", greenBright: "190 210 160",
yellow: "219 188 127", yellowBright: "233 208 163",
blue: "127 187 179", blueBright: "160 208 200",
purple: "214 153 182", purpleBright: "228 180 202",
aqua: "131 192 146", aquaBright: "162 212 176",
} as const;
const palette: [number, number, number][] = [
[230,126,128],[167,192,128],[219,188,127],[127,187,179],[214,153,182],[131,192,146],
];
const hard: Theme = {
id: "everforest-hard",
family: "everforest",
label: "hard",
name: "Everforest Hard",
type: "dark",
colors: {
background: "39 46 51",
foreground: "211 198 170",
...accents,
surface: "55 65 69",
},
canvasPalette: palette,
};
const medium: Theme = {
id: "everforest-medium",
family: "everforest",
label: "medium",
name: "Everforest Medium",
type: "dark",
colors: {
background: "45 53 59",
foreground: "211 198 170",
...accents,
surface: "61 72 77",
},
canvasPalette: palette,
};
const soft: Theme = {
id: "everforest-soft",
family: "everforest",
label: "soft",
name: "Everforest Soft",
type: "dark",
colors: {
background: "52 63 68",
foreground: "211 198 170",
...accents,
surface: "68 80 85",
},
canvasPalette: palette,
};
export const everforest: ThemeFamily = {
id: "everforest",
name: "Everforest",
themes: [hard, medium, soft],
default: "everforest-medium",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// GitHub — the familiar look from github.com.
// Dark (default dark), Dark Dimmed (softer), Light (classic white).
const dark: Theme = {
id: "github-dark",
family: "github",
label: "dark",
name: "GitHub Dark",
type: "dark",
colors: {
background: "13 17 23",
foreground: "230 237 243",
red: "255 123 114", redBright: "255 166 158",
orange: "217 156 90", orangeBright: "240 183 122",
green: "126 231 135", greenBright: "168 242 175",
yellow: "224 194 133", yellowBright: "240 215 168",
blue: "121 192 255", blueBright: "165 214 255",
purple: "210 153 255", purpleBright: "226 187 255",
aqua: "118 214 198", aquaBright: "160 230 218",
surface: "22 27 34",
},
canvasPalette: [[255,123,114],[126,231,135],[224,194,133],[121,192,255],[210,153,255],[118,214,198]],
};
const dimmed: Theme = {
id: "github-dimmed",
family: "github",
label: "dimmed",
name: "GitHub Dark Dimmed",
type: "dark",
colors: {
background: "34 39 46",
foreground: "173 186 199",
red: "255 123 114", redBright: "255 166 158",
orange: "219 171 127", orangeBright: "236 195 158",
green: "87 196 106", greenBright: "130 218 144",
yellow: "224 194 133", yellowBright: "240 215 168",
blue: "108 182 255", blueBright: "152 206 255",
purple: "195 145 243", purpleBright: "218 180 248",
aqua: "96 200 182", aquaBright: "140 220 208",
surface: "45 51 59",
},
canvasPalette: [[255,123,114],[87,196,106],[224,194,133],[108,182,255],[195,145,243],[96,200,182]],
};
const light: Theme = {
id: "github-light",
family: "github",
label: "light",
name: "GitHub Light",
type: "light",
colors: {
background: "255 255 255",
foreground: "31 35 40",
red: "207 34 46", redBright: "227 70 80",
orange: "191 135 0", orangeBright: "212 160 30",
green: "26 127 55", greenBright: "45 155 78",
yellow: "159 115 0", yellowBright: "182 140 22",
blue: "9 105 218", blueBright: "48 132 238",
purple: "130 80 223", purpleBright: "158 112 238",
aqua: "18 130 140", aquaBright: "42 158 168",
surface: "246 248 250",
},
canvasPalette: [[207,34,46],[26,127,55],[159,115,0],[9,105,218],[130,80,223],[18,130,140]],
};
export const github: ThemeFamily = {
id: "github",
name: "GitHub",
themes: [dark, dimmed, light],
default: "github-dark",
};

View File

@@ -0,0 +1,70 @@
import type { Theme, ThemeFamily } from "../types";
// Original gruvbox palette — same accents across hard/medium/soft,
// only background and surface change.
const accents = {
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",
} as const;
const palette: [number, number, number][] = [
[204,36,29],[152,151,26],[215,153,33],[69,133,136],[177,98,134],[104,157,106],
];
const hard: Theme = {
id: "gruvbox-hard",
family: "gruvbox",
label: "hard",
name: "Gruvbox Hard",
type: "dark",
colors: {
background: "29 32 33",
foreground: "235 219 178",
...accents,
surface: "60 56 54",
},
canvasPalette: palette,
};
const medium: Theme = {
id: "gruvbox-medium",
family: "gruvbox",
label: "medium",
name: "Gruvbox Medium",
type: "dark",
colors: {
background: "40 40 40",
foreground: "235 219 178",
...accents,
surface: "60 56 54",
},
canvasPalette: palette,
};
const soft: Theme = {
id: "gruvbox-soft",
family: "gruvbox",
label: "soft",
name: "Gruvbox Soft",
type: "dark",
colors: {
background: "50 48 47",
foreground: "235 219 178",
...accents,
surface: "80 73 69",
},
canvasPalette: palette,
};
export const gruvbox: ThemeFamily = {
id: "gruvbox",
name: "Gruvbox",
themes: [hard, medium, soft],
default: "gruvbox-medium",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// Kanagawa — inspired by Katsushika Hokusai's paintings.
// Each variant has its own distinct palette.
const wave: Theme = {
id: "kanagawa-wave",
family: "kanagawa",
label: "wave",
name: "Kanagawa Wave",
type: "dark",
colors: {
background: "31 31 40",
foreground: "220 215 186",
red: "195 64 67", redBright: "255 93 98",
orange: "255 160 102", orangeBright: "255 184 140",
green: "118 148 106", greenBright: "152 187 108",
yellow: "192 163 110", yellowBright: "230 195 132",
blue: "126 156 216", blueBright: "127 180 202",
purple: "149 127 184", purpleBright: "175 158 206",
aqua: "106 149 137", aquaBright: "122 168 159",
surface: "42 42 55",
},
canvasPalette: [[195,64,67],[118,148,106],[192,163,110],[126,156,216],[149,127,184],[106,149,137]],
};
const dragon: Theme = {
id: "kanagawa-dragon",
family: "kanagawa",
label: "dragon",
name: "Kanagawa Dragon",
type: "dark",
colors: {
background: "24 22 22",
foreground: "197 201 197",
red: "196 116 110", redBright: "195 64 67",
orange: "182 146 123", orangeBright: "255 160 102",
green: "135 169 135", greenBright: "152 187 108",
yellow: "196 178 138", yellowBright: "230 195 132",
blue: "139 164 176", blueBright: "126 156 216",
purple: "162 146 163", purpleBright: "149 127 184",
aqua: "142 164 162", aquaBright: "122 168 159",
surface: "40 39 39",
},
canvasPalette: [[196,116,110],[135,169,135],[196,178,138],[139,164,176],[162,146,163],[142,164,162]],
};
const lotus: Theme = {
id: "kanagawa-lotus",
family: "kanagawa",
label: "lotus",
name: "Kanagawa Lotus",
type: "light",
colors: {
background: "242 236 188",
foreground: "84 84 100",
red: "200 64 83", redBright: "215 71 75",
orange: "233 138 0", orangeBright: "245 160 30",
green: "111 137 78", greenBright: "130 158 98",
yellow: "222 152 0", yellowBright: "240 178 40",
blue: "77 105 155", blueBright: "93 87 163",
purple: "98 76 131", purpleBright: "118 100 155",
aqua: "89 123 117", aquaBright: "110 145 138",
surface: "228 215 148",
},
canvasPalette: [[200,64,83],[111,137,78],[222,152,0],[77,105,155],[98,76,131],[89,123,117]],
};
export const kanagawa: ThemeFamily = {
id: "kanagawa",
name: "Kanagawa",
themes: [wave, dragon, lotus],
default: "kanagawa-wave",
};

View File

@@ -0,0 +1,136 @@
import type { Theme, ThemeFamily } from "../types";
// Monokai — the Sublime Text classic, plus Monokai Pro filter variants.
const classic: Theme = {
id: "monokai-classic",
family: "monokai",
label: "classic",
name: "Monokai Classic",
type: "dark",
colors: {
background: "39 40 34",
foreground: "248 248 242",
red: "249 38 114", redBright: "252 85 145",
orange: "253 151 31", orangeBright: "254 182 85",
green: "166 226 46", greenBright: "195 240 95",
yellow: "230 219 116", yellowBright: "240 232 160",
blue: "102 217 239", blueBright: "145 230 245",
purple: "174 129 255", purpleBright: "200 165 255",
aqua: "161 239 228", aquaBright: "192 245 238",
surface: "73 72 62",
},
canvasPalette: [[249,38,114],[166,226,46],[230,219,116],[102,217,239],[174,129,255],[161,239,228]],
};
const pro: Theme = {
id: "monokai-pro",
family: "monokai",
label: "pro",
name: "Monokai Pro",
type: "dark",
colors: {
background: "45 42 46",
foreground: "252 252 250",
red: "255 97 136", redBright: "255 140 170",
orange: "252 152 103", orangeBright: "253 182 142",
green: "169 220 118", greenBright: "195 234 155",
yellow: "255 216 102", yellowBright: "255 230 155",
blue: "120 220 232", blueBright: "160 234 242",
purple: "171 157 242", purpleBright: "198 188 248",
aqua: "140 228 200", aquaBright: "175 240 220",
surface: "64 62 65",
},
canvasPalette: [[255,97,136],[169,220,118],[255,216,102],[120,220,232],[171,157,242],[140,228,200]],
};
const octagon: Theme = {
id: "monokai-octagon",
family: "monokai",
label: "octagon",
name: "Monokai Octagon",
type: "dark",
colors: {
background: "40 42 58",
foreground: "234 242 241",
red: "255 101 122", redBright: "255 142 158",
orange: "255 155 94", orangeBright: "255 185 138",
green: "186 215 97", greenBright: "210 230 140",
yellow: "255 215 109", yellowBright: "255 230 160",
blue: "156 209 187", blueBright: "185 225 208",
purple: "195 154 201", purpleBright: "218 182 222",
aqua: "130 212 200", aquaBright: "165 228 218",
surface: "58 61 75",
},
canvasPalette: [[255,101,122],[186,215,97],[255,215,109],[156,209,187],[195,154,201],[130,212,200]],
};
const ristretto: Theme = {
id: "monokai-ristretto",
family: "monokai",
label: "ristretto",
name: "Monokai Ristretto",
type: "dark",
colors: {
background: "44 37 37",
foreground: "255 241 243",
red: "253 104 131", redBright: "254 145 165",
orange: "243 141 112", orangeBright: "248 175 150",
green: "173 218 120", greenBright: "198 232 158",
yellow: "249 204 108", yellowBright: "252 222 155",
blue: "133 218 204", blueBright: "168 232 222",
purple: "168 169 235", purpleBright: "195 196 242",
aqua: "150 222 195", aquaBright: "180 235 215",
surface: "64 56 56",
},
canvasPalette: [[253,104,131],[173,218,120],[249,204,108],[133,218,204],[168,169,235],[150,222,195]],
};
const machine: Theme = {
id: "monokai-machine",
family: "monokai",
label: "machine",
name: "Monokai Machine",
type: "dark",
colors: {
background: "39 49 54",
foreground: "242 255 252",
red: "255 109 126", redBright: "255 148 162",
orange: "255 178 112", orangeBright: "255 202 155",
green: "162 229 123", greenBright: "192 240 160",
yellow: "255 237 114", yellowBright: "255 244 168",
blue: "124 213 241", blueBright: "162 228 246",
purple: "186 160 248", purpleBright: "210 188 251",
aqua: "142 225 200", aquaBright: "175 238 220",
surface: "58 68 73",
},
canvasPalette: [[255,109,126],[162,229,123],[255,237,114],[124,213,241],[186,160,248],[142,225,200]],
};
const spectrum: Theme = {
id: "monokai-spectrum",
family: "monokai",
label: "spectrum",
name: "Monokai Spectrum",
type: "dark",
colors: {
background: "34 34 34",
foreground: "247 241 255",
red: "252 97 141", redBright: "253 140 172",
orange: "253 147 83", orangeBright: "254 180 125",
green: "123 216 143", greenBright: "162 232 175",
yellow: "252 229 102", yellowBright: "253 238 155",
blue: "90 212 230", blueBright: "135 226 240",
purple: "148 138 227", purpleBright: "180 172 238",
aqua: "108 218 190", aquaBright: "148 232 212",
surface: "54 53 55",
},
canvasPalette: [[252,97,141],[123,216,143],[252,229,102],[90,212,230],[148,138,227],[108,218,190]],
};
export const monokai: ThemeFamily = {
id: "monokai",
name: "Monokai",
themes: [classic, pro, octagon, ristretto, machine, spectrum],
default: "monokai-pro",
};

View File

@@ -0,0 +1,53 @@
import type { Theme, ThemeFamily } from "../types";
// Nord — arctic, bluish clean aesthetic.
// Polar Night (dark bg), Snow Storm (light bg), Frost (blues), Aurora (accents).
const dark: Theme = {
id: "nord-dark",
family: "nord",
label: "dark",
name: "Nord Dark",
type: "dark",
colors: {
background: "46 52 64",
foreground: "216 222 233",
red: "191 97 106", redBright: "210 130 138",
orange: "208 135 112", orangeBright: "224 165 145",
green: "163 190 140", greenBright: "185 210 168",
yellow: "235 203 139", yellowBright: "242 220 175",
blue: "94 129 172", blueBright: "129 161 193",
purple: "180 142 173", purpleBright: "200 170 195",
aqua: "143 188 187", aquaBright: "136 192 208",
surface: "59 66 82",
},
canvasPalette: [[191,97,106],[163,190,140],[235,203,139],[94,129,172],[180,142,173],[143,188,187]],
};
const light: Theme = {
id: "nord-light",
family: "nord",
label: "light",
name: "Nord Light",
type: "light",
colors: {
background: "236 239 244",
foreground: "46 52 64",
red: "191 97 106", redBright: "170 75 85",
orange: "208 135 112", orangeBright: "185 110 88",
green: "163 190 140", greenBright: "135 162 110",
yellow: "235 203 139", yellowBright: "200 170 100",
blue: "94 129 172", blueBright: "75 108 150",
purple: "180 142 173", purpleBright: "155 115 148",
aqua: "143 188 187", aquaBright: "110 160 162",
surface: "229 233 240",
},
canvasPalette: [[191,97,106],[163,190,140],[235,203,139],[94,129,172],[180,142,173],[143,188,187]],
};
export const nord: ThemeFamily = {
id: "nord",
name: "Nord",
themes: [dark, light],
default: "nord-dark",
};

View File

@@ -0,0 +1,52 @@
import type { Theme, ThemeFamily } from "../types";
// One Dark / One Light — the Atom editor classics.
const dark: Theme = {
id: "onedark-dark",
family: "onedark",
label: "dark",
name: "One Dark",
type: "dark",
colors: {
background: "40 44 52",
foreground: "171 178 191",
red: "224 108 117", redBright: "240 140 148",
orange: "209 154 102", orangeBright: "228 180 135",
green: "152 195 121", greenBright: "180 215 155",
yellow: "229 192 123", yellowBright: "240 212 162",
blue: "97 175 239", blueBright: "135 198 245",
purple: "198 120 221", purpleBright: "218 158 238",
aqua: "86 182 194", aquaBright: "120 202 212",
surface: "62 68 81",
},
canvasPalette: [[224,108,117],[152,195,121],[229,192,123],[97,175,239],[198,120,221],[86,182,194]],
};
const light: Theme = {
id: "onedark-light",
family: "onedark",
label: "light",
name: "One Light",
type: "light",
colors: {
background: "250 250 250",
foreground: "56 58 66",
red: "228 86 73", redBright: "240 115 100",
orange: "152 104 1", orangeBright: "180 130 30",
green: "80 161 79", greenBright: "105 185 104",
yellow: "193 132 1", yellowBright: "215 160 35",
blue: "64 120 242", blueBright: "100 148 248",
purple: "166 38 164", purpleBright: "192 75 190",
aqua: "1 132 188", aquaBright: "40 162 210",
surface: "229 229 230",
},
canvasPalette: [[228,86,73],[80,161,79],[193,132,1],[64,120,242],[166,38,164],[1,132,188]],
};
export const onedark: ThemeFamily = {
id: "onedark",
name: "One Dark",
themes: [dark, light],
default: "onedark-dark",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// Rosé Pine — soft muted palette inspired by the natural world.
// Only 6 accent hues; aqua is derived between pine and foam.
const main: Theme = {
id: "rosepine-main",
family: "rosepine",
label: "main",
name: "Rosé Pine",
type: "dark",
colors: {
background: "25 23 36",
foreground: "224 222 244",
red: "235 111 146", redBright: "241 145 174",
orange: "235 188 186", orangeBright: "240 208 206",
green: "49 116 143", greenBright: "78 140 165",
yellow: "246 193 119", yellowBright: "249 212 160",
blue: "156 207 216", blueBright: "180 222 229",
purple: "196 167 231", purpleBright: "214 190 239",
aqua: "100 170 185", aquaBright: "135 192 205",
surface: "38 35 58",
},
canvasPalette: [[235,111,146],[49,116,143],[246,193,119],[156,207,216],[196,167,231],[235,188,186]],
};
const moon: Theme = {
id: "rosepine-moon",
family: "rosepine",
label: "moon",
name: "Rosé Pine Moon",
type: "dark",
colors: {
background: "35 33 54",
foreground: "224 222 244",
red: "235 111 146", redBright: "241 145 174",
orange: "234 154 151", orangeBright: "241 186 184",
green: "62 143 176", greenBright: "90 165 195",
yellow: "246 193 119", yellowBright: "249 212 160",
blue: "156 207 216", blueBright: "180 222 229",
purple: "196 167 231", purpleBright: "214 190 239",
aqua: "110 178 196", aquaBright: "140 196 210",
surface: "57 53 82",
},
canvasPalette: [[235,111,146],[62,143,176],[246,193,119],[156,207,216],[196,167,231],[234,154,151]],
};
const dawn: Theme = {
id: "rosepine-dawn",
family: "rosepine",
label: "dawn",
name: "Rosé Pine Dawn",
type: "light",
colors: {
background: "250 244 237",
foreground: "87 82 121",
red: "180 99 122", redBright: "200 120 142",
orange: "215 130 126", orangeBright: "230 155 152",
green: "40 105 131", greenBright: "60 125 150",
yellow: "234 157 52", yellowBright: "242 180 85",
blue: "86 148 159", blueBright: "110 168 178",
purple: "144 122 169", purpleBright: "168 148 188",
aqua: "62 128 146", aquaBright: "85 150 165",
surface: "242 233 225",
},
canvasPalette: [[180,99,122],[40,105,131],[234,157,52],[86,148,159],[144,122,169],[215,130,126]],
};
export const rosepine: ThemeFamily = {
id: "rosepine",
name: "Rosé Pine",
themes: [main, moon, dawn],
default: "rosepine-main",
};

View File

@@ -0,0 +1,55 @@
import type { Theme, ThemeFamily } from "../types";
// Solarized — Ethan Schoonover's precision-engineered color scheme.
// Same accent colors in both dark and light — that's the whole point.
const accents = {
red: "220 50 47", redBright: "238 85 80",
orange: "203 75 22", orangeBright: "225 110 60",
yellow: "181 137 0", yellowBright: "210 168 40",
green: "133 153 0", greenBright: "165 185 35",
blue: "38 139 210", blueBright: "75 165 228",
purple: "108 113 196", purpleBright: "140 145 215",
aqua: "42 161 152", aquaBright: "80 190 182",
} as const;
const palette: [number, number, number][] = [
[220,50,47],[133,153,0],[181,137,0],[38,139,210],[108,113,196],[42,161,152],
];
const dark: Theme = {
id: "solarized-dark",
family: "solarized",
label: "dark",
name: "Solarized Dark",
type: "dark",
colors: {
background: "0 43 54",
foreground: "131 148 150",
...accents,
surface: "7 54 66",
},
canvasPalette: palette,
};
const light: Theme = {
id: "solarized-light",
family: "solarized",
label: "light",
name: "Solarized Light",
type: "light",
colors: {
background: "253 246 227",
foreground: "101 123 131",
...accents,
surface: "238 232 213",
},
canvasPalette: palette,
};
export const solarized: ThemeFamily = {
id: "solarized",
name: "Solarized",
themes: [dark, light],
default: "solarized-dark",
};

View File

@@ -0,0 +1,74 @@
import type { Theme, ThemeFamily } from "../types";
// Tokyo Night — modern, popular blue/purple-toned palette.
// Three variants: Night (deep), Storm (slightly lighter), Day (light).
const night: Theme = {
id: "tokyonight-night",
family: "tokyonight",
label: "night",
name: "Tokyo Night",
type: "dark",
colors: {
background: "26 27 38",
foreground: "169 177 214",
red: "247 118 142", redBright: "250 150 170",
orange: "255 158 100", orangeBright: "255 185 140",
green: "158 206 106", greenBright: "185 222 140",
yellow: "224 175 104", yellowBright: "238 200 140",
blue: "122 162 247", blueBright: "155 185 250",
purple: "187 154 247", purpleBright: "208 180 250",
aqua: "125 207 255", aquaBright: "165 222 255",
surface: "41 46 66",
},
canvasPalette: [[247,118,142],[158,206,106],[224,175,104],[122,162,247],[187,154,247],[125,207,255]],
};
const storm: Theme = {
id: "tokyonight-storm",
family: "tokyonight",
label: "storm",
name: "Tokyo Night Storm",
type: "dark",
colors: {
background: "36 40 59",
foreground: "169 177 214",
red: "247 118 142", redBright: "250 150 170",
orange: "255 158 100", orangeBright: "255 185 140",
green: "158 206 106", greenBright: "185 222 140",
yellow: "224 175 104", yellowBright: "238 200 140",
blue: "122 162 247", blueBright: "155 185 250",
purple: "187 154 247", purpleBright: "208 180 250",
aqua: "125 207 255", aquaBright: "165 222 255",
surface: "59 66 97",
},
canvasPalette: [[247,118,142],[158,206,106],[224,175,104],[122,162,247],[187,154,247],[125,207,255]],
};
const day: Theme = {
id: "tokyonight-day",
family: "tokyonight",
label: "day",
name: "Tokyo Night Day",
type: "light",
colors: {
background: "225 226 231",
foreground: "55 96 191",
red: "245 42 101", redBright: "248 80 130",
orange: "177 92 0", orangeBright: "200 120 30",
green: "88 117 57", greenBright: "110 140 78",
yellow: "140 108 62", yellowBright: "165 135 85",
blue: "46 125 233", blueBright: "80 150 240",
purple: "152 84 241", purpleBright: "175 115 245",
aqua: "0 113 151", aquaBright: "30 140 175",
surface: "196 200 218",
},
canvasPalette: [[245,42,101],[88,117,57],[140,108,62],[46,125,233],[152,84,241],[0,113,151]],
};
export const tokyonight: ThemeFamily = {
id: "tokyonight",
name: "Tokyo Night",
themes: [night, storm, day],
default: "tokyonight-night",
};

View File

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

View File

@@ -20,8 +20,17 @@ export interface ThemeColors {
export interface Theme { export interface Theme {
id: string; id: string;
family: string;
label: string;
name: string; name: string;
type: "dark" | "light"; type: "dark" | "light";
colors: ThemeColors; colors: ThemeColors;
canvasPalette: [number, number, number][]; canvasPalette: [number, number, number][];
} }
export interface ThemeFamily {
id: string;
name: string;
themes: Theme[];
default: string;
}

55
src/lib/views.ts Normal file
View File

@@ -0,0 +1,55 @@
import Redis from "ioredis";
let redis: Redis | null = null;
function getRedis(): Redis | null {
if (redis) return redis;
const url = import.meta.env.REDIS_URL || process.env.REDIS_URL;
if (!url) return null;
redis = new Redis(url);
return redis;
}
export async function incrementViews(slug: string): Promise<number> {
const r = getRedis();
if (!r) return 0;
try {
return await r.incr(`views:${slug}`);
} catch {
return 0;
}
}
export async function getViews(slug: string): Promise<number> {
const r = getRedis();
if (!r) return 0;
try {
const val = await r.get(`views:${slug}`);
return val ? parseInt(val, 10) : 0;
} catch {
return 0;
}
}
export async function getAllViews(slugs: string[]): Promise<Record<string, number>> {
const r = getRedis();
const result: Record<string, number> = {};
if (!r || slugs.length === 0) return result;
try {
const keys = slugs.map(s => `views:${s}`);
const values = await r.mget(...keys);
for (let i = 0; i < slugs.length; i++) {
result[slugs[i]] = values[i] ? parseInt(values[i], 10) : 0;
}
} catch {
// Return empty counts if Redis unavailable
}
return result;
}

View File

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

View File

@@ -0,0 +1,276 @@
import type { APIRoute } from "astro";
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes";
function rgbToHex(rgb: string): string {
return "#" + rgb.split(" ").map(n => parseInt(n).toString(16).padStart(2, "0")).join("");
}
function rgbToRgba(rgb: string, alpha: number): string {
return `rgba(${rgb.replaceAll(" ", ", ")}, ${alpha})`;
}
export const GET: APIRoute = ({ url }) => {
const themeId = url.searchParams.get("theme") || DEFAULT_THEME_ID;
const theme = THEMES[themeId];
if (!theme) {
return new Response("Unknown theme", { status: 404 });
}
const c = theme.colors;
const isLight = theme.type === "light";
const fg = rgbToHex(c.foreground);
const fgMuted = rgbToRgba(c.foreground, 0.6);
const fgSubtle = rgbToRgba(c.foreground, 0.4);
const blue = rgbToHex(c.blue);
const blueBright = rgbToHex(c.blueBright);
const green = rgbToHex(c.green);
const yellow = rgbToHex(c.yellow);
const red = rgbToHex(c.red);
const purple = rgbToHex(c.purple);
const orange = rgbToHex(c.orange);
const surface = rgbToHex(c.surface);
const surfaceAlpha = rgbToRgba(c.surface, 0.3);
const surfaceBorder = rgbToRgba(c.surface, 0.5);
const surfaceHover = rgbToRgba(c.surface, 0.6);
const bgTransparent = isLight ? rgbToRgba(c.foreground, 0.06) : rgbToRgba(c.foreground, 0.08);
const bgSubtle = isLight ? rgbToRgba(c.foreground, 0.04) : rgbToRgba(c.foreground, 0.05);
const css = `
main {
--color-prettylights-syntax-comment: ${fgSubtle};
--color-prettylights-syntax-constant: ${blueBright};
--color-prettylights-syntax-entity: ${purple};
--color-prettylights-syntax-storage-modifier-import: ${fg};
--color-prettylights-syntax-entity-tag: ${green};
--color-prettylights-syntax-keyword: ${red};
--color-prettylights-syntax-string: ${blueBright};
--color-prettylights-syntax-variable: ${orange};
--color-prettylights-syntax-brackethighlighter-unmatched: ${red};
--color-prettylights-syntax-invalid-illegal-text: ${fg};
--color-prettylights-syntax-invalid-illegal-bg: ${red};
--color-prettylights-syntax-carriage-return-text: ${fg};
--color-prettylights-syntax-carriage-return-bg: ${red};
--color-prettylights-syntax-string-regexp: ${green};
--color-prettylights-syntax-markup-list: ${yellow};
--color-prettylights-syntax-markup-heading: ${blueBright};
--color-prettylights-syntax-markup-italic: ${fg};
--color-prettylights-syntax-markup-bold: ${orange};
--color-prettylights-syntax-markup-deleted-text: ${red};
--color-prettylights-syntax-markup-deleted-bg: transparent;
--color-prettylights-syntax-markup-inserted-text: ${green};
--color-prettylights-syntax-markup-inserted-bg: transparent;
--color-prettylights-syntax-markup-changed-text: ${yellow};
--color-prettylights-syntax-markup-changed-bg: transparent;
--color-prettylights-syntax-markup-ignored-text: ${fg};
--color-prettylights-syntax-markup-ignored-bg: transparent;
--color-prettylights-syntax-meta-diff-range: ${purple};
--color-prettylights-syntax-brackethighlighter-angle: ${fgSubtle};
--color-prettylights-syntax-sublimelinter-gutter-mark: ${fgSubtle};
--color-prettylights-syntax-constant-other-reference-link: ${blueBright};
--color-btn-text: ${fg};
--color-btn-bg: ${bgTransparent};
--color-btn-border: ${surfaceBorder};
--color-btn-shadow: 0 0 transparent;
--color-btn-inset-shadow: 0 0 transparent;
--color-btn-hover-bg: ${surfaceAlpha};
--color-btn-hover-border: ${surfaceHover};
--color-btn-active-bg: ${bgSubtle};
--color-btn-active-border: ${surfaceHover};
--color-btn-selected-bg: ${bgTransparent};
--color-btn-primary-text: ${fg};
--color-btn-primary-bg: ${blue};
--color-btn-primary-border: transparent;
--color-btn-primary-shadow: 0 0 transparent;
--color-btn-primary-inset-shadow: 0 0 transparent;
--color-btn-primary-hover-bg: ${blueBright};
--color-btn-primary-hover-border: transparent;
--color-btn-primary-selected-bg: ${blue};
--color-btn-primary-selected-shadow: 0 0 transparent;
--color-btn-primary-disabled-text: ${fgMuted};
--color-btn-primary-disabled-bg: ${rgbToRgba(c.blue, 0.6)};
--color-btn-primary-disabled-border: transparent;
--color-action-list-item-default-hover-bg: ${surfaceAlpha};
--color-segmented-control-bg: ${surfaceAlpha};
--color-segmented-control-button-bg: ${bgTransparent};
--color-segmented-control-button-selected-border: ${surfaceBorder};
--color-fg-default: ${fg};
--color-fg-muted: ${fgMuted};
--color-fg-subtle: ${fgSubtle};
--color-canvas-default: transparent;
--color-canvas-overlay: ${bgTransparent};
--color-canvas-inset: ${bgSubtle};
--color-canvas-subtle: ${bgSubtle};
--color-border-default: ${surfaceBorder};
--color-border-muted: ${surfaceAlpha};
--color-neutral-muted: ${rgbToRgba(c.surface, 0.25)};
--color-accent-fg: ${blueBright};
--color-accent-emphasis: ${blue};
--color-accent-muted: ${rgbToRgba(c.blue, 0.4)};
--color-accent-subtle: ${rgbToRgba(c.blue, 0.1)};
--color-success-fg: ${green};
--color-attention-fg: ${yellow};
--color-attention-muted: ${rgbToRgba(c.yellow, 0.4)};
--color-attention-subtle: ${rgbToRgba(c.yellow, 0.15)};
--color-danger-fg: ${red};
--color-danger-muted: ${rgbToRgba(c.red, 0.4)};
--color-danger-subtle: ${rgbToRgba(c.red, 0.1)};
--color-primer-shadow-inset: 0 0 transparent;
--color-scale-gray-7: ${surface};
--color-scale-blue-8: ${blue};
--color-social-reaction-bg-hover: ${surfaceAlpha};
--color-social-reaction-bg-reacted-hover: ${rgbToRgba(c.blue, 0.3)};
}
main .pagination-loader-container {
background-image: url(https://github.com/images/modules/pulls/progressive-disclosure-line${isLight ? "" : "-dark"}.svg);
}
.gsc-reactions-count { display: none; }
.gsc-timeline { flex-direction: column-reverse; }
.gsc-header {
padding-bottom: 1rem;
font-family: "Comic Code", monospace;
border-bottom: none;
}
.gsc-comments > .gsc-header { order: 1; }
.gsc-comments > .gsc-comment-box {
margin-bottom: 1rem;
order: 2;
font-family: "Comic Code", monospace;
background-color: ${bgTransparent};
border-radius: 0.5rem;
border: 1px solid ${surfaceBorder};
}
.gsc-comments > .gsc-timeline { order: 3; }
.gsc-homepage-bg { background-color: transparent; }
main .gsc-loading-image {
background-image: url(https://github.githubassets.com/images/mona-loading-${isLight ? "default" : "dimmed"}.gif);
}
.gsc-comment {
border: 1px solid ${surfaceBorder};
border-radius: 0.5rem;
margin-bottom: 1rem;
background-color: ${bgTransparent};
transition: border-color 0.2s ease;
}
.gsc-comment-header {
background-color: ${bgSubtle};
padding: 0.75rem;
border-bottom: 1px solid ${rgbToRgba(c.surface, 0.2)};
font-family: "Comic Code", monospace;
}
.gsc-comment-content {
padding: 1rem;
font-family: "Comic Code", monospace;
}
.gsc-comment-author { color: var(--color-fg-default); font-weight: 600; }
.gsc-comment-author-avatar img { border-radius: 50%; }
.gsc-comment-reactions { border-top: none; padding-top: 0.5rem; }
.gsc-reply-box {
background-color: ${bgTransparent};
border-radius: 0.5rem;
margin-top: 0.5rem;
margin-left: 1rem;
font-family: "Comic Code", monospace;
border: 1px solid ${rgbToRgba(c.surface, 0.25)};
}
.gsc-comment-box-textarea {
background-color: ${bgSubtle};
border: 1px solid ${surfaceBorder};
border-radius: 0.5rem;
color: var(--color-fg-default);
font-family: "Comic Code", monospace;
padding: 0.75rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.gsc-comment-box-textarea:focus {
border-color: ${blueBright};
box-shadow: 0 0 0 2px ${rgbToRgba(c.blue, 0.2)};
outline: none;
}
.gsc-comment-box-buttons button {
font-family: "Comic Code", monospace;
border-radius: 0.5rem;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.gsc-comment pre {
background-color: ${bgSubtle};
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
border: 1px solid ${rgbToRgba(c.surface, 0.25)};
}
.gsc-comment code {
font-family: "Comic Code", monospace;
background-color: ${bgSubtle};
color: ${purple};
padding: 0.2em 0.4em;
border-radius: 0.25rem;
}
.gsc-comment:hover { border-color: ${surfaceHover}; }
.gsc-social-reaction-summary-item:hover { background-color: ${surfaceAlpha}; }
.gsc-timeline::-webkit-scrollbar { width: 8px; height: 8px; }
.gsc-timeline::-webkit-scrollbar-track { background: transparent; border-radius: 4px; }
.gsc-timeline::-webkit-scrollbar-thumb { background: ${surfaceBorder}; border-radius: 4px; }
.gsc-timeline::-webkit-scrollbar-thumb:hover { background: ${surface}; }
.gsc-comment-footer, .gsc-comment-footer-separator,
.gsc-reactions-button, .gsc-social-reaction-summary-item:not(:hover) { border: none; }
.gsc-upvote svg { fill: ${blueBright}; }
.gsc-downvote svg { fill: ${red}; }
.gsc-comment-box, .gsc-comment, .gsc-comment-reactions, button, .gsc-reply-box {
transition: all 0.2s ease-in-out;
}
.gsc-main { border: none !important; }
.gsc-left-header { background-color: transparent !important; }
.gsc-right-header { background-color: transparent !important; }
.gsc-header-status { background-color: transparent !important; }
.gsc-comment-box, .gsc-comment-box-md-toolbar, .gsc-comment-box-buttons { border: none !important; }
.gsc-comment-box-md-toolbar-item { color: ${blueBright} !important; }
.gsc-comment-box-md-toolbar {
background-color: ${bgSubtle} !important;
padding: 0.5rem !important;
}
.gsc-comments .gsc-powered-by { display: none !important; }
.gsc-comments footer { display: none !important; }
.gsc-comments .gsc-powered-by a { visibility: hidden !important; }
`;
return new Response(css, {
headers: {
"Content-Type": "text/css",
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*",
},
});
};

96
src/pages/api/github.ts Normal file
View File

@@ -0,0 +1,96 @@
import type { APIRoute } from "astro";
const GITHUB_USER = "timmypidashev";
export const GET: APIRoute = async () => {
const token = import.meta.env.GITHUB_TOKEN;
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": "timmypidashev-web",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
let status: { message: string } | null = null;
let commit: { message: string; repo: string; date: string; url: string } | null = null;
let tinkering: { repo: string; url: string } | null = null;
// Fetch user status via GraphQL (requires token)
if (token) {
try {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
query: `{ user(login: "${GITHUB_USER}") { status { message } } }`,
}),
});
const data = await res.json();
const s = data?.data?.user?.status;
if (s?.message) {
status = { message: s.message };
}
} catch {
// Status unavailable — skip
}
}
// Fetch latest public push event, then fetch commit details
try {
const eventsRes = await fetch(
`https://api.github.com/users/${GITHUB_USER}/events/public?per_page=30`,
{ headers },
);
const events = await eventsRes.json();
// Find most active repo from recent push events
if (Array.isArray(events)) {
const repoCounts: Record<string, number> = {};
for (const e of events) {
if (e.type === "PushEvent") {
const name = e.repo.name.replace(`${GITHUB_USER}/`, "");
repoCounts[name] = (repoCounts[name] || 0) + 1;
}
}
const topRepo = Object.entries(repoCounts).sort((a, b) => b[1] - a[1])[0];
if (topRepo) {
tinkering = {
repo: topRepo[0],
url: `https://github.com/${GITHUB_USER}/${topRepo[0]}`,
};
}
}
const push = Array.isArray(events)
? events.find((e: any) => e.type === "PushEvent")
: null;
if (push) {
const repo = push.repo.name.replace(`${GITHUB_USER}/`, "");
const sha = push.payload?.head;
if (sha) {
const commitRes = await fetch(
`https://api.github.com/repos/${GITHUB_USER}/${repo}/commits/${sha}`,
{ headers },
);
const commitData = await commitRes.json();
if (commitData?.commit?.message) {
commit = {
message: commitData.commit.message.split("\n")[0],
repo,
date: push.created_at,
url: commitData.html_url,
};
}
}
}
} catch {
// Commit unavailable — skip
}
return new Response(JSON.stringify({ status, commit, tinkering }), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300",
},
});
};

View File

@@ -0,0 +1,14 @@
import type { APIRoute } from "astro";
import { incrementViews, getViews } from "@/lib/views";
const SLUG = "hero-arc";
export const POST: APIRoute = async () => {
const count = import.meta.env.DEV
? await getViews(SLUG)
: await incrementViews(SLUG);
return new Response(JSON.stringify({ count }), {
headers: { "Content-Type": "application/json" },
});
};

View File

@@ -0,0 +1,25 @@
import type { APIRoute } from "astro";
const SECRET = import.meta.env.VOID_SECRET || process.env.VOID_SECRET;
async function sign(timestamp: string): Promise<string> {
const data = new TextEncoder().encode(timestamp + SECRET);
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
}
export const GET: APIRoute = async () => {
if (!SECRET) {
return new Response(JSON.stringify({ token: "dev" }), {
headers: { "Content-Type": "application/json" },
});
}
const timestamp = Date.now().toString();
const signature = await sign(timestamp);
const token = `${timestamp}:${signature}`;
return new Response(JSON.stringify({ token }), {
headers: { "Content-Type": "application/json" },
});
};

Some files were not shown because too many files have changed in this diff Show More