mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
Compare commits
3 Commits
53065a11dc
...
87d3b3bfa6
| Author | SHA1 | Date | |
|---|---|---|---|
|
87d3b3bfa6
|
|||
|
f6873546df
|
|||
|
e7ada63431
|
@@ -13,6 +13,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/three": "^0.175.0",
|
||||
"astro": "^6.1.2",
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
@@ -24,6 +25,9 @@
|
||||
"@giscus/react": "^3.1.0",
|
||||
"@pilcrowjs/object-parser": "^0.0.4",
|
||||
"@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",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
@@ -31,6 +35,7 @@
|
||||
"ioredis": "^5.10.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"marked": "^15.0.12",
|
||||
"postprocessing": "^6.39.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.6.0",
|
||||
@@ -40,6 +45,7 @@
|
||||
"rehype-slug": "^6.0.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"shiki": "^3.23.0",
|
||||
"three": "^0.175.0",
|
||||
"typewriter-effect": "^2.22.0",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
}
|
||||
|
||||
728
pnpm-lock.yaml
generated
728
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -351,8 +351,6 @@ const Background: React.FC<BackgroundProps> = ({
|
||||
style={{ cursor: "default" }}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||
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;
|
||||
@@ -366,16 +370,6 @@ function addComeback(tw: TypewriterInstance, onRetire: () => void, completions:
|
||||
`<span class="text-yellow">until they're the only way out</span>`
|
||||
).pauseFor(4000).deleteAll();
|
||||
|
||||
// --- Visitor count ---
|
||||
|
||||
if (completions !== null && completions > 0) {
|
||||
tw.typeString(
|
||||
`<span>You're visitor </span>` +
|
||||
`<span class="text-yellow">#${completions.toLocaleString()}</span>${BR}` +
|
||||
`<span class="text-aqua">to make it this far</span>`
|
||||
).pauseFor(5000).deleteAll();
|
||||
}
|
||||
|
||||
// --- Done for real ---
|
||||
|
||||
addDots(tw, 1000, 4000);
|
||||
@@ -446,14 +440,7 @@ function GlitchCountdown({ seconds }: { seconds: number }) {
|
||||
export default function Hero() {
|
||||
const [phase, setPhase] = useState<
|
||||
"intro" | "full" | "retired" | "countdown" | "glitch"
|
||||
>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const p = new URLSearchParams(window.location.search);
|
||||
if (p.has("debug-countdown")) return "countdown";
|
||||
if (p.has("debug-glitch")) return "glitch";
|
||||
}
|
||||
return "intro";
|
||||
});
|
||||
>("intro");
|
||||
const [fading, setFading] = useState(false);
|
||||
const [cycle, setCycle] = useState(0);
|
||||
const [countdown, setCountdown] = useState(150);
|
||||
@@ -467,9 +454,20 @@ export default function Hero() {
|
||||
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
|
||||
}, []);
|
||||
|
||||
// Countdown timer
|
||||
// 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) {
|
||||
@@ -483,13 +481,20 @@ export default function Hero() {
|
||||
return () => clearInterval(interval);
|
||||
}, [phase]);
|
||||
|
||||
// Glitch → navigate to /enlighten
|
||||
// Glitch → transition into void
|
||||
const glitchRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (phase !== "glitch") return;
|
||||
|
||||
// Apply glitch to all direct children of the layout wrapper
|
||||
const wrapper = glitchRef.current?.closest("main")?.parentElement || document.body;
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.hero-glitch-child {
|
||||
animation: hero-glitch 3s ease-in forwards;
|
||||
}
|
||||
@keyframes hero-glitch {
|
||||
0% { filter: none; transform: none; }
|
||||
0% { filter: none; transform: none; opacity: 1; }
|
||||
5% { filter: hue-rotate(90deg) saturate(3); transform: skewX(2deg); }
|
||||
10% { filter: invert(1); transform: skewX(-3deg) translateX(5px); }
|
||||
15% { filter: hue-rotate(180deg) brightness(1.5); transform: scale(1.02); }
|
||||
@@ -501,19 +506,25 @@ export default function Hero() {
|
||||
60% { filter: saturate(0) brightness(1.8); transform: scale(1.01); }
|
||||
70% { filter: hue-rotate(180deg) brightness(0.3); transform: none; }
|
||||
80% { filter: contrast(5) saturate(0); transform: skewX(-1deg); }
|
||||
90% { filter: brightness(0.1); transform: scale(0.99); }
|
||||
100% { filter: brightness(0); transform: none; }
|
||||
90% { filter: brightness(0.1); transform: scale(0.99); opacity: 0.1; }
|
||||
100% { filter: brightness(0); transform: none; opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
document.documentElement.style.animation = "hero-glitch 3s ease-in forwards";
|
||||
|
||||
const children = Array.from(wrapper.children) as HTMLElement[];
|
||||
children.forEach(child => child.classList.add("hero-glitch-child"));
|
||||
|
||||
// After glitch animation, transition to void phase
|
||||
const timeout = setTimeout(() => {
|
||||
window.location.href = "/enlighten";
|
||||
children.forEach(child => child.classList.remove("hero-glitch-child"));
|
||||
style.remove();
|
||||
setPhase("void");
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
document.documentElement.style.animation = "";
|
||||
children.forEach(child => child.classList.remove("hero-glitch-child"));
|
||||
style.remove();
|
||||
};
|
||||
}, [phase]);
|
||||
@@ -567,8 +578,16 @@ export default function Hero() {
|
||||
|
||||
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" />;
|
||||
return <div ref={glitchRef} className="min-h-screen" />;
|
||||
}
|
||||
|
||||
if (phase === "countdown") {
|
||||
|
||||
242
src/components/void/index.tsx
Normal file
242
src/components/void/index.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
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 + force fullscreen feel on mobile
|
||||
useEffect(() => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = GLITCH_CSS;
|
||||
document.head.appendChild(style);
|
||||
document.body.style.cursor = "none";
|
||||
|
||||
// Push mobile tab bar off-screen by making the page taller than viewport
|
||||
const meta = document.querySelector('meta[name="viewport"]');
|
||||
const origContent = meta?.getAttribute("content") || "";
|
||||
meta?.setAttribute("content", "width=device-width, initial-scale=1, interactive-widget=resizes-content");
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
document.body.style.overflow = "hidden";
|
||||
// Scroll down slightly to trigger mobile browsers hiding the tab bar
|
||||
window.scrollTo(0, 1);
|
||||
|
||||
return () => {
|
||||
style.remove();
|
||||
document.body.style.cursor = "";
|
||||
document.documentElement.style.overflow = "";
|
||||
document.body.style.overflow = "";
|
||||
if (meta) meta.setAttribute("content", origContent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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" 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 — filter-only glitch (no transform shift) */}
|
||||
{visitCount !== null && (
|
||||
<div className={getTextGlitch(activeSegment, dissolving)}>
|
||||
<VoidTypewriter
|
||||
startSegment={0}
|
||||
onPhaseComplete={handlePhaseComplete}
|
||||
onSegmentChange={handleSegmentChange}
|
||||
visitCount={visitCount}
|
||||
corruption={corruption}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/void/palette.ts
Normal file
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
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
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
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
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
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);
|
||||
}
|
||||
104
src/components/void/typewriter.tsx
Normal file
104
src/components/void/typewriter.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 }: 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"
|
||||
>
|
||||
<Typewriter
|
||||
key={`void-${startSegment}-${visitCount}`}
|
||||
options={{
|
||||
delay: 35,
|
||||
deleteSpeed: 15,
|
||||
cursor: "",
|
||||
autoStart: true,
|
||||
loop: false,
|
||||
}}
|
||||
onInit={handleInit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/pages/api/void-token.ts
Normal file
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
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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import "@/style/globals.css"
|
||||
import Void from "@/components/hero/void";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>...</title>
|
||||
</head>
|
||||
<body class="bg-black text-white overflow-hidden h-screen m-0">
|
||||
<Void client:only="react" />
|
||||
</body>
|
||||
</html>
|
||||
@@ -41,37 +41,6 @@
|
||||
@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) {
|
||||
*, *::before, *::after {
|
||||
|
||||
Reference in New Issue
Block a user