feat: add TypingRoomIntro component and integrate it into Home page
This commit is contained in:
179
src/app/page.tsx
179
src/app/page.tsx
@@ -11,7 +11,8 @@ import { MiniProjectModal } from "../components/MiniProjectModal";
|
|||||||
import { Navbar } from "../components/Navbar";
|
import { Navbar } from "../components/Navbar";
|
||||||
import { Footer } from "../sections/Footer";
|
import { Footer } from "../sections/Footer";
|
||||||
import { TechStack } from "../sections/TechStack";
|
import { TechStack } from "../sections/TechStack";
|
||||||
import { useState } from "react";
|
import { TypingRoomIntro } from "../components/TypingRoomIntro";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { Experience } from "../types";
|
import type { Experience } from "../types";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -24,12 +25,21 @@ export default function Home() {
|
|||||||
} = useProfile();
|
} = useProfile();
|
||||||
|
|
||||||
const [showOldNames, setShowOldNames] = useState(false);
|
const [showOldNames, setShowOldNames] = useState(false);
|
||||||
|
const [showTypingIntro, setShowTypingIntro] = useState(true);
|
||||||
const oldUsernames = [
|
const oldUsernames = [
|
||||||
"getspaced (ingame)",
|
"getspaced (ingame)",
|
||||||
"Space (alternative)",
|
"Space (alternative)",
|
||||||
"Space-Banane (2022-2024)",
|
"Space-Banane (2022-2024)",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// keep intro visible on every full page load; no-op here
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleIntroFinish = useCallback(() => {
|
||||||
|
setShowTypingIntro(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const groupedExperiences = experiences.reduce(
|
const groupedExperiences = experiences.reduce(
|
||||||
(acc, exp) => {
|
(acc, exp) => {
|
||||||
if (!acc[exp.type]) acc[exp.type] = [];
|
if (!acc[exp.type]) acc[exp.type] = [];
|
||||||
@@ -41,95 +51,100 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<TypingRoomIntro active={showTypingIntro} onFinish={handleIntroFinish} />
|
||||||
<div className="space-y-24 pb-20 pt-20">
|
{!showTypingIntro && (
|
||||||
<Hero
|
<>
|
||||||
glowColor={glowColor}
|
<Navbar />
|
||||||
borderStatus={borderStatus}
|
<div className="space-y-24 pb-20 pt-20">
|
||||||
displayMessage={displayMessage}
|
<Hero
|
||||||
rotatingMessages={rotatingMessages}
|
glowColor={glowColor}
|
||||||
statusMessage={statusMessage}
|
borderStatus={borderStatus}
|
||||||
showOldNames={showOldNames}
|
displayMessage={displayMessage}
|
||||||
setShowOldNames={setShowOldNames}
|
rotatingMessages={rotatingMessages}
|
||||||
oldUsernames={oldUsernames}
|
statusMessage={statusMessage}
|
||||||
/>
|
showOldNames={showOldNames}
|
||||||
|
setShowOldNames={setShowOldNames}
|
||||||
|
oldUsernames={oldUsernames}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
<WorkExperience realWork={realWork} />
|
<WorkExperience realWork={realWork} />
|
||||||
|
|
||||||
<TechStack />
|
<TechStack />
|
||||||
|
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-12 px-4">
|
<section className="w-full max-w-6xl mx-auto space-y-12 px-4">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">
|
<h2 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">
|
||||||
Featured Projects
|
Featured Projects
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||||
A selection of my personal favorites. Many more on my GitHub.
|
A selection of my personal favorites. Many more on my GitHub.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{projects.map((project, index) => (
|
|
||||||
<ProjectCard key={index} project={project} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-12 px-4">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-3xl font-bold">More Projects</h2>
|
|
||||||
<p className="text-gray-400">Smaller projects or tools I've built.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{miniProjects.map((project, index) => (
|
|
||||||
<MiniProjectCard
|
|
||||||
key={index}
|
|
||||||
project={project}
|
|
||||||
onClick={() => setSelectedMiniProject(project)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-12 px-4 pb-20">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-3xl font-bold">Skills & Experience</h2>
|
|
||||||
<p className="text-gray-400">Things I've worked with over the years.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
|
||||||
{Object.entries(groupedExperiences).map(([type, items]) => (
|
|
||||||
<div key={type} className="space-y-6">
|
|
||||||
<h3 className="text-xl font-semibold border-l-4 border-blue-500 pl-4 capitalize">
|
|
||||||
{type}
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
{items.map((exp, index) => (
|
|
||||||
<ExperienceCard
|
|
||||||
key={index}
|
|
||||||
experience={exp}
|
|
||||||
onClick={() => setSelectedExperience(exp)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<ProjectCard key={index} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="w-full max-w-6xl mx-auto space-y-12 px-4">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h2 className="text-3xl font-bold">More Projects</h2>
|
||||||
|
<p className="text-gray-400">Smaller projects or tools I've built.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{miniProjects.map((project, index) => (
|
||||||
|
<MiniProjectCard
|
||||||
|
key={index}
|
||||||
|
project={project}
|
||||||
|
onClick={() => setSelectedMiniProject(project)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="w-full max-w-6xl mx-auto space-y-12 px-4 pb-20">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h2 className="text-3xl font-bold">Skills & Experience</h2>
|
||||||
|
<p className="text-gray-400">Things I've worked with over the years.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||||
|
{Object.entries(groupedExperiences).map(([type, items]) => (
|
||||||
|
<div key={type} className="space-y-6">
|
||||||
|
<h3 className="text-xl font-semibold border-l-4 border-blue-500 pl-4 capitalize">
|
||||||
|
{type}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{items.map((exp, index) => (
|
||||||
|
<ExperienceCard
|
||||||
|
key={index}
|
||||||
|
experience={exp}
|
||||||
|
onClick={() => setSelectedExperience(exp)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{selectedMiniProject && (
|
{selectedMiniProject && (
|
||||||
<MiniProjectModal
|
<MiniProjectModal
|
||||||
project={selectedMiniProject}
|
project={selectedMiniProject}
|
||||||
onClose={() => setSelectedMiniProject(null)}
|
onClose={() => setSelectedMiniProject(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{selectedExperience && (
|
{selectedExperience && (
|
||||||
<ExperienceModal
|
<ExperienceModal
|
||||||
experience={selectedExperience}
|
experience={selectedExperience}
|
||||||
onClose={() => setSelectedExperience(null)}
|
onClose={() => setSelectedExperience(null)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
220
src/components/TypingRoomIntro.tsx
Normal file
220
src/components/TypingRoomIntro.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface TypingRoomIntroProps {
|
||||||
|
active: boolean;
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypingRoomIntro({ active, onFinish }: TypingRoomIntroProps) {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [garble, setGarble] = useState("");
|
||||||
|
const garbleRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
setStep(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timers = [
|
||||||
|
window.setTimeout(() => setStep(1), 280),
|
||||||
|
window.setTimeout(() => setStep(2), 900),
|
||||||
|
window.setTimeout(() => setStep(3), 1700),
|
||||||
|
window.setTimeout(() => setStep(4), 2800),
|
||||||
|
window.setTimeout(onFinish, 3600),
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
timers.forEach((timer) => window.clearTimeout(timer));
|
||||||
|
};
|
||||||
|
}, [active, onFinish]);
|
||||||
|
|
||||||
|
// Garbled typing generator while Space is "typing"
|
||||||
|
useEffect(() => {
|
||||||
|
if (step >= 3 && step < 4) {
|
||||||
|
setGarble("");
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()[]{}<>~-=_+";
|
||||||
|
garbleRef.current = window.setInterval(() => {
|
||||||
|
// create a short random string to simulate garble
|
||||||
|
const len = 20;
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
s += charset[Math.floor(Math.random() * charset.length)];
|
||||||
|
}
|
||||||
|
setGarble(s);
|
||||||
|
}, 120);
|
||||||
|
} else {
|
||||||
|
if (garbleRef.current) {
|
||||||
|
window.clearInterval(garbleRef.current);
|
||||||
|
garbleRef.current = null;
|
||||||
|
}
|
||||||
|
setGarble("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (garbleRef.current) {
|
||||||
|
window.clearInterval(garbleRef.current);
|
||||||
|
garbleRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = originalOverflow;
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onFinish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [active, onFinish]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.45, ease: "easeInOut" } }}
|
||||||
|
className="fixed inset-0 z-[220] flex items-center justify-center bg-[#020205] px-4"
|
||||||
|
aria-live="polite"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.25),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(59,130,246,0.16),transparent_45%),#020205]" />
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(transparent_95%,rgba(255,255,255,0.03)_100%)] bg-[length:100%_6px] opacity-60" />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 32, scale: 0.96, opacity: 0 }}
|
||||||
|
animate={{ y: 0, scale: 1, opacity: 1 }}
|
||||||
|
exit={{ y: -24, scale: 0.98, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
className="relative w-full max-w-2xl overflow-hidden rounded-3xl border border-cyan-300/20 bg-black/55 backdrop-blur-xl shadow-[0_32px_90px_rgba(14,165,233,0.25)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-red-400/80" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-amber-300/80" />
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400/80" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.3em] text-cyan-100/70">
|
||||||
|
Orbital Chat
|
||||||
|
</p>
|
||||||
|
<span className="rounded-full border border-cyan-300/30 px-2 py-0.5 text-[10px] font-medium text-cyan-200/80">
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-6 md:p-8">
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-xs uppercase tracking-[0.25em] text-cyan-100/50"
|
||||||
|
>
|
||||||
|
Breaking Websockets...
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{step >= 1 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -16 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="max-w-[80%] rounded-2xl rounded-bl-md border border-white/10 bg-white/10 px-4 py-3 text-sm text-gray-100"
|
||||||
|
>
|
||||||
|
Why is this page taking so long to load??
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step >= 2 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 16 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="ml-auto max-w-[85%] rounded-2xl rounded-br-md border border-cyan-300/30 bg-cyan-400/15 px-4 py-3 text-sm text-cyan-50"
|
||||||
|
>
|
||||||
|
I have to somehow hide the fact that my discord status is taking a while to load... :D
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step >= 4 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 12 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="ml-auto max-w-[85%] rounded-2xl rounded-br-md border border-cyan-300/35 bg-cyan-400/20 px-4 py-3 text-sm text-cyan-50"
|
||||||
|
>
|
||||||
|
Ok done, just one more animation
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative border-t border-white/10 bg-black/35 px-6 pb-5 pt-4">
|
||||||
|
{/* Small badge left-above the input showing typing status (hidden once final message shows) */}
|
||||||
|
{step < 4 && (
|
||||||
|
<div className="absolute left-6 -top-6 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-cyan-100">Space is typing...</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{[0, 1, 2].map((dot) => (
|
||||||
|
<motion.span
|
||||||
|
key={dot}
|
||||||
|
animate={{ y: [0, -3, 0], opacity: [0.3, 1, 0.3] }}
|
||||||
|
transition={{ duration: 0.95, repeat: Infinity, delay: dot * 0.14, ease: "easeInOut" }}
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-cyan-200"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs text-cyan-100/70">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(110,231,183,0.75)]" />
|
||||||
|
You & Space
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-gray-400">Esc to skip</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 min-h-[44px] flex items-center">
|
||||||
|
{step >= 3 && step < 4 ? (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-2.5 font-mono text-sm text-cyan-100">
|
||||||
|
<span className="select-all">{garble || "…"}</span>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Message Space</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: step >= 4 ? 1 : step / 4 }}
|
||||||
|
transition={{ duration: 0.45, ease: "easeOut" }}
|
||||||
|
className="h-1 origin-left bg-gradient-to-r from-cyan-300 via-blue-400 to-cyan-300"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,18 +42,17 @@ export function ProfileProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const rotatingMessages = useMemo(
|
const rotatingMessages = useMemo(
|
||||||
() => [
|
() => [
|
||||||
"Yelling at Claude",
|
"Yelling at Luna",
|
||||||
"Shipping bugs",
|
"Assigning more Issues to myself and letting Luna do them",
|
||||||
"Compiling typescript files... Please wait.",
|
"NOT coding in Rust 😂✌️",
|
||||||
"Waiting for CI, this might take a while...",
|
"Rewriting the same helper 3 times",
|
||||||
"No debugger attached, but I'm sure it works ?",
|
"Micro-service-maxxing",
|
||||||
"Seems fine, must be a problem with your machine lol",
|
"Blaming SHSF for my bad code",
|
||||||
"Waiting for pip, this might take a lifetime...",
|
"Shipping fast SHSF code",
|
||||||
"Collecting Spotify hours, this might take a while...",
|
"Writing code with 1.4k+ Lines",
|
||||||
"Fighting with go modules, i lost",
|
"Love-Hate relationship with TypeScript",
|
||||||
"Nuking Python for the 50th time, it's winning",
|
"Blaming Openai",
|
||||||
"Why is uv so cool?",
|
"Undescribable Music Taste"
|
||||||
"Pulling 20-ish GB of node modules, see you next year",
|
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user