feat: add TypingRoomIntro component and integrate it into Home page

This commit is contained in:
Space-Banane
2026-04-12 17:04:49 +02:00
parent 8d2548c74c
commit 3c020928b3
3 changed files with 328 additions and 94 deletions

View File

@@ -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)}
/> />
)}
</>
)} )}
</> </>
); );

View 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>
);
}

View File

@@ -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",
], ],
[], [],
); );