Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9965bd3529
|
|||
|
f0ae0b9ce1
|
|||
|
87d3b3bfa6
|
|||
|
f6873546df
|
|||
|
e7ada63431
|
|||
|
53065a11dc
|
|||
|
2c5784c6e2
|
|||
|
9b626faba8
|
|||
|
153bd0cf39
|
|||
|
162032e3f3
|
|||
|
237cacb612
|
|||
|
f6e9e16227
|
|||
|
db46f7d6ba
|
|||
|
e640e87d3f
|
|||
|
1cd76b03df
|
|||
|
5ac736cad4
|
|||
|
997106eb92
|
|||
|
3f103c3e15
|
|||
|
16f271c1c9
|
|||
|
1a445548f2
|
|||
|
dc7ca40b9b
|
|||
|
14f9ef3ffd
|
|||
|
336c652bf7
|
|||
|
873090310a
|
|||
|
c7762f099c
|
|||
|
c2407408fa
|
|||
|
bab4a516be
|
|||
|
adc1f21204
|
@@ -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",
|
||||||
|
|||||||
11
package.json
@@ -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
BIN
public/emoji/bubbles.webp
Normal file
|
After Width: | Height: | Size: 578 KiB |
BIN
public/emoji/coffee.webp
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/emoji/eyes.webp
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
public/emoji/gift.webp
Normal file
|
After Width: | Height: | Size: 558 KiB |
BIN
public/emoji/infinity.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/emoji/lightbulb.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/emoji/memo.webp
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
public/emoji/mood-cold.webp
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
public/emoji/mood-cool.webp
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/emoji/mood-dotted.webp
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
public/emoji/mood-expressionless.webp
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
public/emoji/mood-fire.webp
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
public/emoji/mood-melting.webp
Normal file
|
After Width: | Height: | Size: 867 KiB |
BIN
public/emoji/mood-nerd.webp
Normal file
|
After Width: | Height: | Size: 728 KiB |
BIN
public/emoji/mood-neutral.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/emoji/mood-nod.webp
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
public/emoji/mood-nomouth.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/emoji/mood-salute.webp
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
public/emoji/mood-sparkles.webp
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/emoji/mood-starstruck.webp
Normal file
|
After Width: | Height: | Size: 567 KiB |
BIN
public/emoji/mood-think.webp
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
public/emoji/moon.webp
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
public/emoji/muscle.webp
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
public/emoji/point-down.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
public/emoji/robot.webp
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
public/emoji/shush.webp
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
public/emoji/sparkles.webp
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/emoji/thinking.webp
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
public/emoji/tinker.webp
Normal file
|
After Width: | Height: | Size: 588 KiB |
BIN
public/emoji/trophy.webp
Normal file
|
After Width: | Height: | Size: 582 KiB |
BIN
public/emoji/wave.webp
Normal file
|
After Width: | Height: | Size: 390 KiB |
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" : ""}`}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
11
src/components/analytics.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,47 +12,49 @@ 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',
|
'text-green-bright',
|
||||||
'text-green-bright',
|
'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,50 +66,25 @@ 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 */}
|
#{name}
|
||||||
<div className="font-medium text-center">
|
<span className="text-foreground/30 text-xs ml-1 align-super">
|
||||||
#{name}
|
{count}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
{/* Post count */}
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
123
src/components/blog/tagged-posts.tsx
Normal 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">•</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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' : ''}
|
||||||
`}>
|
`}>
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emoji = (name: string) =>
|
||||||
|
`<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>
|
||||||
|
<br><div class="mb-4"></div>
|
||||||
|
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a> ${emoji("wave")}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SECTION_2 = html`
|
||||||
|
<span>I've been turning</span>
|
||||||
|
<br><div class="mb-4"></div>
|
||||||
|
<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>
|
||||||
|
<br><div class="mb-4"></div>
|
||||||
|
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a> ${emoji("sparkles")}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SECTION_3 = html`
|
||||||
|
<span>Check out my</span>
|
||||||
|
<br><div class="mb-4"></div>
|
||||||
|
<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>
|
||||||
|
<br><div class="mb-4"></div>
|
||||||
|
<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() {
|
export default function Hero() {
|
||||||
const SECTION_1 = html`
|
const [phase, setPhase] = useState<
|
||||||
<span>Hello, I'm</span>
|
"intro" | "full" | "retired" | "countdown" | "glitch" | "void"
|
||||||
<br><div class="mb-4"></div>
|
>(() => {
|
||||||
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a></span>
|
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);
|
||||||
|
|
||||||
const SECTION_2 = html`
|
useEffect(() => {
|
||||||
<span>I've been turning</span>
|
fetch("/api/github")
|
||||||
<br><div class="mb-4"></div>
|
.then((r) => r.json())
|
||||||
<span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into
|
.then((data) => { githubRef.current = data; })
|
||||||
<a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
|
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
|
||||||
<br><div class="mb-4"></div>
|
}, []);
|
||||||
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a>!</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SECTION_3 = html`
|
// Void token + preload during countdown
|
||||||
<span>Check out my</span>
|
const voidTokenRef = useRef<string | null>(null);
|
||||||
<br><div class="mb-4"></div>
|
useEffect(() => {
|
||||||
<span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/
|
if (phase !== "countdown") return;
|
||||||
<a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span>
|
|
||||||
<br><div class="mb-4"></div>
|
|
||||||
<span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below!</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const handleInit = (typewriter: TypewriterInstance): void => {
|
// Preload the void component bundle
|
||||||
typewriter
|
voidImport();
|
||||||
.typeString(SECTION_1)
|
|
||||||
.pauseFor(2000)
|
// Fetch a signed token for the void visit
|
||||||
.deleteAll()
|
fetch("/api/void-token")
|
||||||
.typeString(SECTION_2)
|
.then(r => r.json())
|
||||||
.pauseFor(2000)
|
.then(data => { voidTokenRef.current = data.token; })
|
||||||
.deleteAll()
|
.catch(() => { voidTokenRef.current = null; });
|
||||||
.typeString(SECTION_3)
|
|
||||||
.pauseFor(2000)
|
const interval = setInterval(() => {
|
||||||
.deleteAll()
|
setCountdown(prev => {
|
||||||
.start();
|
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);
|
||||||
|
|
||||||
|
// Overlay for backdrop-filter (color distortion — works on all platforms)
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "hero-glitch-filter";
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Shake transforms on all layout elements
|
||||||
|
const targets = document.querySelectorAll<HTMLElement>(
|
||||||
|
"header, main, footer, nav"
|
||||||
|
);
|
||||||
|
targets.forEach(el => el.classList.add("hero-glitch-shake"));
|
||||||
|
|
||||||
|
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"}`}>
|
||||||
<Typewriter
|
{phase === "intro" ? (
|
||||||
options={typewriterOptions}
|
<Typewriter
|
||||||
onInit={handleInit}
|
key="intro"
|
||||||
/>
|
options={{ ...baseOptions, autoStart: true, loop: false }}
|
||||||
|
onInit={handleIntroInit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typewriter
|
||||||
|
key={`full-${cycle}`}
|
||||||
|
options={{ ...baseOptions, autoStart: true, loop: false }}
|
||||||
|
onInit={handleFullInit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
48
src/components/hero/void.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/mobile-nav/index.tsx
Normal 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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/mobile-nav/settings-sheet.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
110
src/components/stream-content.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
142
src/components/typed-text.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
src/components/void/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/void/palette.ts
Normal 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,
|
||||||
|
};
|
||||||
8
src/components/void/phases/index.ts
Normal 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,
|
||||||
|
};
|
||||||
132
src/components/void/phases/void.ts
Normal 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);
|
||||||
|
}
|
||||||
171
src/components/void/scenes/void-water.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/void/shaders/noise.ts
Normal 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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
83
src/components/void/types.ts
Normal 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);
|
||||||
|
}
|
||||||
105
src/components/void/typewriter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
}
|
}
|
||||||
::view-transition-old(:root) {
|
</script>
|
||||||
animation: 90ms ease-out both fade-out;
|
|
||||||
}
|
|
||||||
@keyframes fade-out {
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
}
|
}
|
||||||
::view-transition-old(:root) {
|
</script>
|
||||||
animation: 90ms ease-out both fade-out;
|
|
||||||
}
|
|
||||||
@keyframes fade-out {
|
|
||||||
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>
|
||||||
|
|||||||
4
src/lib/reduced-motion.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function prefersReducedMotion(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
}
|
||||||
@@ -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 } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
94
src/lib/themes/families/catppuccin.ts
Normal 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",
|
||||||
|
};
|
||||||
71
src/lib/themes/families/darkbox.ts
Normal 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",
|
||||||
|
};
|
||||||
70
src/lib/themes/families/everforest.ts
Normal 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",
|
||||||
|
};
|
||||||
74
src/lib/themes/families/github.ts
Normal 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",
|
||||||
|
};
|
||||||
70
src/lib/themes/families/gruvbox.ts
Normal 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",
|
||||||
|
};
|
||||||
74
src/lib/themes/families/kanagawa.ts
Normal 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",
|
||||||
|
};
|
||||||
136
src/lib/themes/families/monokai.ts
Normal 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",
|
||||||
|
};
|
||||||
53
src/lib/themes/families/nord.ts
Normal 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",
|
||||||
|
};
|
||||||
52
src/lib/themes/families/onedark.ts
Normal 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",
|
||||||
|
};
|
||||||
74
src/lib/themes/families/rosepine.ts
Normal 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",
|
||||||
|
};
|
||||||
55
src/lib/themes/families/solarized.ts
Normal 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",
|
||||||
|
};
|
||||||
74
src/lib/themes/families/tokyonight.ts
Normal 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",
|
||||||
|
};
|
||||||
@@ -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]]),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
276
src/pages/api/giscus-theme.ts
Normal 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
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
14
src/pages/api/hero-completions.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
};
|
||||||
25
src/pages/api/void-token.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
};
|
||||||
114
src/pages/api/void-visits.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
const SECRET = import.meta.env.VOID_SECRET || process.env.VOID_SECRET;
|
||||||
|
const TOKEN_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashIp(ip: string): Promise<string> {
|
||||||
|
const data = new TextEncoder().encode(ip + 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("").slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientIp(request: Request): string {
|
||||||
|
// x-vercel-forwarded-for is Vercel's trusted header (can't be spoofed)
|
||||||
|
// Fall back to last entry in x-forwarded-for (Vercel appends real IP at end)
|
||||||
|
return request.headers.get("x-vercel-forwarded-for")
|
||||||
|
|| request.headers.get("x-forwarded-for")?.split(",").at(-1)?.trim()
|
||||||
|
|| request.headers.get("x-real-ip")
|
||||||
|
|| "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
const r = getRedis();
|
||||||
|
|
||||||
|
// No secret or no Redis — dev mode, return 1
|
||||||
|
if (!SECRET || !r) {
|
||||||
|
return new Response(JSON.stringify({ count: 1 }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body
|
||||||
|
let body: { token?: string } = {};
|
||||||
|
try { body = await request.json(); } catch {}
|
||||||
|
|
||||||
|
const token = body.token;
|
||||||
|
if (!token || token === "dev") {
|
||||||
|
return new Response(JSON.stringify({ error: "missing token" }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ts, sig] = token.split(":");
|
||||||
|
if (!ts || !sig) {
|
||||||
|
return new Response(JSON.stringify({ error: "invalid token" }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check token age
|
||||||
|
const age = Date.now() - parseInt(ts, 10);
|
||||||
|
if (isNaN(age) || age < 0 || age > TOKEN_WINDOW_MS) {
|
||||||
|
return new Response(JSON.stringify({ error: "expired token" }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
const expected = await sign(ts);
|
||||||
|
if (sig !== expected) {
|
||||||
|
return new Response(JSON.stringify({ error: "invalid token" }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Atomic one-time-use check: SET NX returns "OK" only if key didn't exist
|
||||||
|
const tokenKey = `void:token:${sig.slice(0, 16)}`;
|
||||||
|
const isNew = await r.set(tokenKey, "pending", "EX", 600, "NX");
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
// Token already used — return the stored count
|
||||||
|
const storedCount = await r.get(tokenKey);
|
||||||
|
const count = storedCount && storedCount !== "pending" ? parseInt(storedCount, 10) : 1;
|
||||||
|
return new Response(JSON.stringify({ count }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP dedup
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const ipKey = `void:ip:${await hashIp(ip)}`;
|
||||||
|
const existingCount = await r.get(ipKey);
|
||||||
|
|
||||||
|
let count: number;
|
||||||
|
if (existingCount) {
|
||||||
|
// Same IP — return their existing number
|
||||||
|
count = parseInt(existingCount, 10);
|
||||||
|
} else {
|
||||||
|
// New visitor — increment global counter
|
||||||
|
count = await r.incr("void:count");
|
||||||
|
await r.set(ipKey, count.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update token key with the actual count (for replay lookups)
|
||||||
|
await r.set(tokenKey, count.toString(), "EX", 600);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ count }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response(JSON.stringify({ count: 1 }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ import ContentLayout from "@/layouts/content.astro";
|
|||||||
import { getArticleSchema } from "@/lib/structuredData";
|
import { getArticleSchema } from "@/lib/structuredData";
|
||||||
import { blogWebsite } from "@/lib/structuredData";
|
import { blogWebsite } from "@/lib/structuredData";
|
||||||
import { Comments } from "@/components/blog/comments";
|
import { Comments } from "@/components/blog/comments";
|
||||||
|
import { StreamContent } from "@/components/stream-content";
|
||||||
|
import { incrementViews, getViews } from "@/lib/views";
|
||||||
|
|
||||||
// This is a dynamic route in SSR mode
|
// This is a dynamic route in SSR mode
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
@@ -20,6 +22,14 @@ if (!post || (!import.meta.env.DEV && post.data.isDraft === true)) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track page view and get count
|
||||||
|
let views = 0;
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
views = await incrementViews(post.id);
|
||||||
|
} else {
|
||||||
|
views = await getViews(post.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Dynamically render the content
|
// Dynamically render the content
|
||||||
const { Content } = await render(post);
|
const { Content } = await render(post);
|
||||||
|
|
||||||
@@ -84,20 +94,28 @@ const jsonLd = {
|
|||||||
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
|
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</time>
|
</time>
|
||||||
|
{views > 0 && (
|
||||||
|
<>
|
||||||
|
<span class="text-foreground/50">•</span>
|
||||||
|
<span class="text-green">{views.toLocaleString()} view{views !== 1 ? "s" : ""}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
{post.data.tags.map((tag) => (
|
{post.data.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
|
||||||
onclick={`window.location.href='/blog/tag/${tag}'`}
|
onclick={`window.location.href='/blog/tags/${encodeURIComponent(tag)}'`}
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div class="prose prose-invert prose-lg max-w-none">
|
<StreamContent client:load>
|
||||||
<Content />
|
<div class="prose prose-invert prose-lg max-w-none">
|
||||||
</div>
|
<Content />
|
||||||
|
</div>
|
||||||
|
</StreamContent>
|
||||||
</article>
|
</article>
|
||||||
<Comments client:idle />
|
<Comments client:idle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
src/pages/blog/popular/index.astro
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import ContentLayout from "@/layouts/content.astro";
|
||||||
|
import { BlogHeader } from "@/components/blog/header";
|
||||||
|
import { BlogPostList } from "@/components/blog/post-list";
|
||||||
|
import { getAllViews } from "@/lib/views";
|
||||||
|
|
||||||
|
const posts = (await getCollection("blog", ({ data }) => {
|
||||||
|
return import.meta.env.DEV || data.isDraft !== true;
|
||||||
|
})).map(post => ({
|
||||||
|
...post,
|
||||||
|
data: {
|
||||||
|
...post.data,
|
||||||
|
date: post.data.date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get view counts and sort by popularity
|
||||||
|
const views = await getAllViews(posts.map(p => p.id));
|
||||||
|
const sorted = [...posts].sort((a, b) => (views[b.id] || 0) - (views[a.id] || 0));
|
||||||
|
---
|
||||||
|
<ContentLayout
|
||||||
|
title="Most Popular | Blog | Timothy Pidashev"
|
||||||
|
description="Most popular blog posts by view count."
|
||||||
|
>
|
||||||
|
<BlogHeader client:load />
|
||||||
|
<BlogPostList posts={sorted} client:load />
|
||||||
|
</ContentLayout>
|
||||||
38
src/pages/blog/tags/[...slug].astro
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import ContentLayout from "@/layouts/content.astro";
|
||||||
|
import TaggedPosts from "@/components/blog/tagged-posts";
|
||||||
|
|
||||||
|
const { slug } = Astro.params;
|
||||||
|
const tag = decodeURIComponent(slug || "");
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
return Astro.redirect("/blog/tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPosts = (await getCollection("blog", ({ data }) => {
|
||||||
|
return (import.meta.env.DEV || data.isDraft !== true) && data.tags.includes(tag);
|
||||||
|
})).sort((a, b) => {
|
||||||
|
return b.data.date.valueOf() - a.data.date.valueOf();
|
||||||
|
}).map(post => ({
|
||||||
|
...post,
|
||||||
|
data: {
|
||||||
|
...post.data,
|
||||||
|
date: post.data.date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (filteredPosts.length === 0) {
|
||||||
|
return Astro.redirect("/blog/tags");
|
||||||
|
}
|
||||||
|
---
|
||||||
|
<ContentLayout
|
||||||
|
title={`#${tag} | Blog | Timothy Pidashev`}
|
||||||
|
description={`Blog posts tagged with "${tag}".`}
|
||||||
|
>
|
||||||
|
<TaggedPosts tag={tag} posts={filteredPosts} client:load />
|
||||||
|
</ContentLayout>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { getCollection } from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
import ContentLayout from "@/layouts/content.astro";
|
import ContentLayout from "@/layouts/content.astro";
|
||||||
|
import { BlogHeader } from "@/components/blog/header";
|
||||||
import TagList from "@/components/blog/tag-list";
|
import TagList from "@/components/blog/tag-list";
|
||||||
|
|
||||||
const posts = (await getCollection("blog", ({ data }) => {
|
const posts = (await getCollection("blog", ({ data }) => {
|
||||||
@@ -21,8 +21,9 @@ const posts = (await getCollection("blog", ({ data }) => {
|
|||||||
}));
|
}));
|
||||||
---
|
---
|
||||||
<ContentLayout
|
<ContentLayout
|
||||||
title="Blog | Timothy Pidashev"
|
title="Browse Tags | Blog | Timothy Pidashev"
|
||||||
description="My experiences and technical insights into software development and the ever-evolving world of programming."
|
description="Browse blog posts by tag."
|
||||||
>
|
>
|
||||||
<TagList posts={posts} />
|
<BlogHeader client:load />
|
||||||
|
<TagList posts={posts} client:load />
|
||||||
</ContentLayout>
|
</ContentLayout>
|
||||||
|
|||||||