Compare commits
6 Commits
9b626faba8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f0ae0b9ce1
|
|||
|
87d3b3bfa6
|
|||
|
f6873546df
|
|||
|
e7ada63431
|
|||
|
53065a11dc
|
|||
|
2c5784c6e2
|
@@ -13,6 +13,7 @@
|
|||||||
"@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"
|
||||||
},
|
},
|
||||||
@@ -24,6 +25,9 @@
|
|||||||
"@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/analytics": "^2.0.1",
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
@@ -31,6 +35,7 @@
|
|||||||
"ioredis": "^5.10.1",
|
"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",
|
||||||
@@ -40,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"
|
||||||
}
|
}
|
||||||
|
|||||||
728
pnpm-lock.yaml
generated
BIN
public/emoji/bubbles.webp
Normal file
|
After Width: | Height: | Size: 578 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/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/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/thinking.webp
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
public/emoji/trophy.webp
Normal file
|
After Width: | Height: | Size: 582 KiB |
@@ -351,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,5 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
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 {
|
interface GithubData {
|
||||||
status: { message: string } | null;
|
status: { message: string } | null;
|
||||||
@@ -46,6 +52,10 @@ interface TypewriterInstance {
|
|||||||
const emoji = (name: string) =>
|
const emoji = (name: string) =>
|
||||||
`<img src="/emoji/${name}.webp" alt="" style="display:inline;height:1em;width:1em;vertical-align:middle">`;
|
`<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`
|
const SECTION_1 = html`
|
||||||
<span>Hello, I'm</span>
|
<span>Hello, I'm</span>
|
||||||
<br><div class="mb-4"></div>
|
<br><div class="mb-4"></div>
|
||||||
@@ -76,6 +86,8 @@ const MOODS = [
|
|||||||
"mood-nomouth", "mood-nod", "mood-melting",
|
"mood-nomouth", "mood-nod", "mood-melting",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// --- Queue builders ---
|
||||||
|
|
||||||
function addGreetings(tw: TypewriterInstance) {
|
function addGreetings(tw: TypewriterInstance) {
|
||||||
tw.typeString(SECTION_1).pauseFor(2000).deleteAll()
|
tw.typeString(SECTION_1).pauseFor(2000).deleteAll()
|
||||||
.typeString(SECTION_2).pauseFor(2000).deleteAll()
|
.typeString(SECTION_2).pauseFor(2000).deleteAll()
|
||||||
@@ -85,40 +97,362 @@ function addGreetings(tw: TypewriterInstance) {
|
|||||||
function addGithubSections(tw: TypewriterInstance, github: GithubData) {
|
function addGithubSections(tw: TypewriterInstance, github: GithubData) {
|
||||||
if (github.status) {
|
if (github.status) {
|
||||||
const moodImg = emoji(MOODS[Math.floor(Math.random() * MOODS.length)]);
|
const moodImg = emoji(MOODS[Math.floor(Math.random() * MOODS.length)]);
|
||||||
const statusStr =
|
tw.typeString(
|
||||||
`<span>My current mood ${moodImg}</span>` +
|
`<span>My current mood ${moodImg}</span>${BR}` +
|
||||||
`<br><div class="mb-4"></div>` +
|
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`
|
||||||
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`;
|
).pauseFor(3000).deleteAll();
|
||||||
tw.typeString(statusStr).pauseFor(3000).deleteAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (github.tinkering) {
|
if (github.tinkering) {
|
||||||
const tinkerImg = emoji("tinker");
|
tw.typeString(
|
||||||
const tinkerStr =
|
`<span>Currently tinkering with ${emoji("tinker")}</span>${BR}` +
|
||||||
`<span>Currently tinkering with ${tinkerImg}</span>` +
|
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`
|
||||||
`<br><div class="mb-4"></div>` +
|
).pauseFor(3000).deleteAll();
|
||||||
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`;
|
|
||||||
tw.typeString(tinkerStr).pauseFor(3000).deleteAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (github.commit) {
|
if (github.commit) {
|
||||||
const ago = timeAgo(github.commit.date);
|
const ago = timeAgo(github.commit.date);
|
||||||
const memoImg = emoji("memo");
|
|
||||||
const repoUrl = `https://github.com/timmypidashev/${github.commit.repo}`;
|
const repoUrl = `https://github.com/timmypidashev/${github.commit.repo}`;
|
||||||
const commitStr =
|
tw.typeString(
|
||||||
`<span>My latest <span class="text-foreground/40">(unbroken?)</span> commit ${memoImg}</span>` +
|
`<span>My latest <span class="text-foreground/40">(broken?)</span> commit ${emoji("memo")}</span>${BR}` +
|
||||||
`<br><div class="mb-4"></div>` +
|
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>${BR}` +
|
||||||
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>` +
|
|
||||||
`<br><div class="mb-4"></div>` +
|
|
||||||
`<a href="${repoUrl}" target="_blank" class="text-yellow hover:underline">${escapeHtml(github.commit.repo)}</a>` +
|
`<a href="${repoUrl}" target="_blank" class="text-yellow hover:underline">${escapeHtml(github.commit.repo)}</a>` +
|
||||||
`<span class="text-foreground/40"> · ${ago}</span>`;
|
`<span class="text-foreground/40"> · ${ago}</span>`
|
||||||
tw.typeString(commitStr).pauseFor(3000).deleteAll();
|
).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 [phase, setPhase] = useState<"intro" | "full">("intro");
|
const [phase, setPhase] = useState<
|
||||||
|
"intro" | "full" | "retired" | "countdown" | "glitch" | "void"
|
||||||
|
>(() => {
|
||||||
|
if (import.meta.env.DEV && typeof window !== "undefined") {
|
||||||
|
const p = new URLSearchParams(window.location.search);
|
||||||
|
if (p.has("debug-glitch")) return "glitch";
|
||||||
|
if (p.has("debug-countdown")) return "countdown";
|
||||||
|
}
|
||||||
|
return "intro";
|
||||||
|
});
|
||||||
|
const [fading, setFading] = useState(false);
|
||||||
|
const [cycle, setCycle] = useState(0);
|
||||||
|
const [countdown, setCountdown] = useState(150);
|
||||||
const githubRef = useRef<GithubData | null>(null);
|
const githubRef = useRef<GithubData | null>(null);
|
||||||
|
const completionsRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/github")
|
fetch("/api/github")
|
||||||
@@ -127,10 +461,138 @@ export default function Hero() {
|
|||||||
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
|
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Void token + preload during countdown
|
||||||
|
const voidTokenRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "countdown") return;
|
||||||
|
|
||||||
|
// Preload the void component bundle
|
||||||
|
voidImport();
|
||||||
|
|
||||||
|
// Fetch a signed token for the void visit
|
||||||
|
fetch("/api/void-token")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { voidTokenRef.current = data.token; })
|
||||||
|
.catch(() => { voidTokenRef.current = null; });
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCountdown(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setPhase("glitch");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
// Glitch → transition into void
|
||||||
|
// Apply animation directly to each visible element (works on both desktop + mobile)
|
||||||
|
// On mobile, filter/transform on <body> doesn't reach fixed-position children,
|
||||||
|
// so we target the elements themselves
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "glitch") return;
|
||||||
|
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `
|
||||||
|
.hero-glitch-shake {
|
||||||
|
animation: hero-glitch-shake 3s ease-in forwards !important;
|
||||||
|
}
|
||||||
|
@keyframes hero-glitch-shake {
|
||||||
|
0% { transform: none; }
|
||||||
|
5% { transform: skewX(2deg); }
|
||||||
|
10% { transform: skewX(-3deg) translateX(5px); }
|
||||||
|
15% { transform: scale(1.02); }
|
||||||
|
20% { transform: skewX(1deg) translateY(-2px); }
|
||||||
|
25% { transform: skewX(-2deg); }
|
||||||
|
30% { transform: scale(0.98); }
|
||||||
|
40% { transform: translateX(-3px); }
|
||||||
|
50% { transform: skewX(4deg) skewY(1deg); }
|
||||||
|
60% { transform: scale(1.01); }
|
||||||
|
70% { transform: none; }
|
||||||
|
80% { transform: skewX(-1deg); }
|
||||||
|
90% { transform: none; }
|
||||||
|
100% { transform: none; }
|
||||||
|
}
|
||||||
|
.hero-glitch-filter {
|
||||||
|
animation: hero-glitch-filter 3s ease-in forwards !important;
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
z-index: 99999 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
@keyframes hero-glitch-filter {
|
||||||
|
0% { backdrop-filter: none; background: transparent; }
|
||||||
|
5% { backdrop-filter: hue-rotate(90deg) saturate(3); }
|
||||||
|
10% { backdrop-filter: invert(1); }
|
||||||
|
15% { backdrop-filter: hue-rotate(180deg) brightness(1.5); }
|
||||||
|
20% { backdrop-filter: saturate(5) contrast(2); }
|
||||||
|
25% { backdrop-filter: invert(1) hue-rotate(270deg); }
|
||||||
|
30% { backdrop-filter: brightness(2) saturate(0); }
|
||||||
|
40% { backdrop-filter: hue-rotate(45deg) contrast(3); }
|
||||||
|
50% { backdrop-filter: invert(1) brightness(0.5); }
|
||||||
|
60% { backdrop-filter: saturate(0) brightness(1.8); }
|
||||||
|
70% { backdrop-filter: hue-rotate(180deg) brightness(0.3); }
|
||||||
|
80% { backdrop-filter: contrast(5) saturate(0); }
|
||||||
|
90% { backdrop-filter: brightness(0); background: #000; }
|
||||||
|
100% { backdrop-filter: brightness(0); background: #000; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// 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 handleIntroInit = (typewriter: TypewriterInstance): void => {
|
const handleIntroInit = (typewriter: TypewriterInstance): void => {
|
||||||
addGreetings(typewriter);
|
addGreetings(typewriter);
|
||||||
typewriter.callFunction(() => {
|
typewriter.callFunction(() => {
|
||||||
// Greetings done — data is almost certainly ready (API ~500ms, greetings ~20s)
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (githubRef.current) {
|
if (githubRef.current) {
|
||||||
setPhase("full");
|
setPhase("full");
|
||||||
@@ -143,19 +605,47 @@ export default function Hero() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFullInit = (typewriter: TypewriterInstance): void => {
|
const handleFullInit = (typewriter: TypewriterInstance): void => {
|
||||||
const github = githubRef.current!;
|
if (cycle === 0) {
|
||||||
// GitHub sections first (greetings just played in intro phase)
|
const github = githubRef.current!;
|
||||||
addGithubSections(typewriter, github);
|
addGithubSections(typewriter, github);
|
||||||
// Then greetings for the loop
|
addSelfAwareJourney(typewriter, handleRetire);
|
||||||
addGreetings(typewriter);
|
} else {
|
||||||
|
addComeback(typewriter, handleRetire, completionsRef.current);
|
||||||
|
}
|
||||||
typewriter.start();
|
typewriter.start();
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseOptions = { delay: 35, deleteSpeed: 35, cursor: "|" };
|
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-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
|
<div className={`text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto max-w-[90vw] break-words transition-opacity duration-[3000ms] ${fading ? "opacity-0" : "opacity-100"}`}>
|
||||||
{phase === "intro" ? (
|
{phase === "intro" ? (
|
||||||
<Typewriter
|
<Typewriter
|
||||||
key="intro"
|
key="intro"
|
||||||
@@ -164,8 +654,8 @@ export default function Hero() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typewriter
|
<Typewriter
|
||||||
key="full"
|
key={`full-${cycle}`}
|
||||||
options={{ ...baseOptions, autoStart: true, loop: true }}
|
options={{ ...baseOptions, autoStart: true, loop: false }}
|
||||||
onInit={handleFullInit}
|
onInit={handleFullInit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,9 +32,19 @@ export default function ThemeSwitcher() {
|
|||||||
syncLabels(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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -41,37 +41,6 @@
|
|||||||
@apply bg-purple/50
|
@apply bg-purple/50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* CRT overlay — canvas only */
|
|
||||||
.crt-scanlines {
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0px,
|
|
||||||
transparent 2px,
|
|
||||||
rgb(var(--color-foreground) / 0.06) 2px,
|
|
||||||
rgb(var(--color-foreground) / 0.06) 4px
|
|
||||||
);
|
|
||||||
animation: crt-scroll 12s linear infinite;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crt-bloom {
|
|
||||||
box-shadow: inset 0 0 100px 30px rgb(var(--color-background) / 0.3);
|
|
||||||
background: radial-gradient(
|
|
||||||
ellipse at center,
|
|
||||||
transparent 50%,
|
|
||||||
rgb(var(--color-background) / 0.25) 100%
|
|
||||||
);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes crt-scroll {
|
|
||||||
0% {
|
|
||||||
background-position: 0 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0 200px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
|
|||||||