update website

This commit is contained in:
2026-07-30 23:45:01 +02:00
parent 7f7d98d63b
commit 8ecec92aec
9 changed files with 719 additions and 1252 deletions
+26 -295
View File
@@ -1,298 +1,29 @@
"use client"
import {useRef, useState, useEffect} from "react";
import Link from "next/link";
import FadeInSection from "@/app/components/FadeInSection";
export const metadata = {title: "About"};
const timeline = [
{year: "2018", title: "The first spark", text: "I discovered programming through Scratch, turning small ideas into simple, playable games.", skills: ["Scratch", "Game logic"]},
{year: "2022", title: "Into the web", text: "I began building websites and learned the foundations of HTML, CSS, and accessible interface design.", skills: ["HTML", "CSS", "JavaScript"]},
{year: "2022", title: "From screen to hardware", text: "A Raspberry Pi opened the door to Python scripting, Linux, and the fun of connecting software with real hardware.", skills: ["Python", "Linux", "Raspberry Pi"]},
{year: "2024", title: "Full stack & beyond", text: "I expanded into databases, backend systems, Java, and larger projects—while continuing to sharpen my eye for frontend craft.", skills: ["SQL", "Python", "Java", "JavaScript"]},
];
export default function About() {
const timelineData = [
{
date: "2018",
title: "First Contact with Coding",
content:
"I discovered my passion for technology by experimenting with Scratch and creating some simple Games.",
},
{
date: "2022",
title: "Exploring Web & Scripting",
content:
"Started creating small websites and got into HTML, CSS",
},
{
date: "2022",
title: "Raspberry Pi & Python",
content:
"Created some simple Python scripts for my Raspberry Pi, experimenting with the Raspberry Pi OS",
},
{
date: "2024",
title: "Full Stack & Beyond",
content:
"Ventured into backend (SQL, Python, Javascript, Java) and continued refining my frontend abilities.",
},
];
function getSkillEmoji(name) {
switch (name) {
case "JavaScript": return "🟨";
case "Python": return "🐍";
case "Java": return "☕";
case "SQL": return "💾";
case "HTML & CSS": return "🎨";
default: return "💡";
}
}
const skills = [
{
name: "JavaScript",
level: 80,
waveSpeed: "2s",
progression: [0, 30, 60, 80],
},
{
name: "Python",
level: 75,
waveSpeed: "2.5s",
progression: [10, 25, 50, 75],
},
{
name: "Java",
level: 50,
waveSpeed: "3s",
progression: [0, 0, 0, 60],
},
{
name: "SQL",
level: 85,
waveSpeed: "2.2s",
progression: [0, 0, 0, 85],
},
{
name: "HTML & CSS",
level: 70,
waveSpeed: "2.7s",
progression: [0, 30, 50, 80],
},
];
const getSkillLabel = (level) => {
if (level < 25) return "Beginner";
if (level < 50) return "Intermediate";
if (level < 75) return "Advanced";
return "Expert";
};
const sectionRefs = useRef([]);
const aboutMeRef = useRef(null);
const buttonSectionRef = useRef(null);
const scrollContainerRef = useRef(null);
const [currentSection, setCurrentSection] = useState(0);
const [timelineFinished, setTimelineFinished] = useState(false);
const [showScrollPrompt, setShowScrollPrompt] = useState(false);
useEffect(() => {
setCurrentSection(0);
setTimelineFinished(false);
setTimeout(() => {
sectionRefs.current[0]?.scrollIntoView({behavior: "auto", inline: "start"});
window.scrollTo({top: 0, behavior: "smooth"});
}, 100);
}, []);
useEffect(() => {
let scrollListener;
if (timelineFinished) {
setShowScrollPrompt(true);
scrollListener = () => {
setShowScrollPrompt(false);
window.removeEventListener("scroll", scrollListener);
};
window.addEventListener("scroll", scrollListener);
}
return () => {
if (scrollListener) {
window.removeEventListener("scroll", scrollListener);
}
};
}, [timelineFinished]);
const [isNavigating, setIsNavigating] = useState(false);
const handleButtonClick = (direction) => {
if (isNavigating) return;
setIsNavigating(true);
setCurrentSection((prev) => {
const newSection = Math.max(0, Math.min(prev + direction, timelineData.length - 1));
sectionRefs.current[newSection]?.scrollIntoView({behavior: "smooth", inline: "start"});
return newSection;
});
setTimeout(() => {
setIsNavigating(false);
}, 600);
};
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const index = sectionRefs.current.findIndex((el) => el === entry.target);
if (entry.isIntersecting && index !== -1) {
setCurrentSection(index);
}
if (entry.isIntersecting && index === timelineData.length - 1) {
setTimelineFinished(true);
}
});
},
{threshold: 0.6}
);
sectionRefs.current.forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [timelineData.length]);
useEffect(() => {
setTimelineFinished(currentSection === timelineData.length - 1);
}, [currentSection, timelineData.length]);
return (
<div style={{
width: "100%",
position: "relative",
overflowX: "hidden"
}}>
<FadeInSection>
<section
ref={aboutMeRef}
style={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
boxSizing: "border-box",
}}
>
<h1 style={{fontSize: "3.5rem", marginBottom: "1rem"}}>About Me</h1>
<p style={{maxWidth: "600px", textAlign: "center", fontWeight: "300", fontSize: "1.5rem"}}>
I’m a passionate developer always excited about new challenges.
Scroll down to learn more about me!
</p>
</section>
</FadeInSection>
{/* Timeline Section */}
<section style={{position: "relative", height: "100vh", overflowY: "hidden"}}>
{/* Navigation buttons and timeline content here */}
<div ref={scrollContainerRef} style={{
display: "flex",
overflowX: "auto",
scrollSnapType: "x mandatory",
width: "100%",
height: "100%",
scrollBehavior: "smooth",
scrollbarWidth: "none",
msOverflowStyle: "none"
}}>
{timelineData.map((item, index) => {
const adjustedSkills = skills.map((s) => {
const stepLevel = s.progression?.[index] ?? 0;
return {
...s,
level: Math.floor((s.level * stepLevel) / 100),
};
});
return (
<section
key={index}
ref={(el) => (sectionRefs.current[index] = el)}
className="timeline-page"
>
<div className="timeline-card">
<div className="timeline-card-header">
<span className="timeline-date">{item.date}</span>
<h2>{item.title}</h2>
</div>
<p className="timeline-content">{item.content}</p>
<div className="timeline-skills">
{adjustedSkills.filter((s) => s.level > 0).map((skill, sIndex) => (
<span
key={`${skill.name}-${index}`}
className={`skill-badge skill-${getSkillLabel(skill.level).toLowerCase()}`}
style={{ animationDelay: `${sIndex * 80}ms` }}
>
{getSkillEmoji(skill.name)} {skill.name}
<span className="skill-level">{getSkillLabel(skill.level)}</span>
</span>
))}
</div>
</div>
</section>
);
})}
</div>
{currentSection > 0 && (
<button
onClick={() => handleButtonClick(-1)}
className="timeline-arrow-btn left"
aria-label="Previous"
>
&larr;
</button>
)}
{currentSection < timelineData.length - 1 && (
<button
onClick={() => handleButtonClick(1)}
className="timeline-arrow-btn right"
aria-label="Next"
>
&rarr;
</button>
)}
</section>
{timelineFinished && showScrollPrompt && (
<div className="scrollPrompt">
⬇ Scroll down to continue ⬇
</div>
)}
{/* FINAL BUTTON SECTION - MOVED OUTSIDE */}
{timelineFinished && (
// Fügen Sie dem Button-Section einen z-Index hinzu
<section
ref={buttonSectionRef}
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
padding: "2rem",
boxSizing: "border-box",
position: "relative", // Hinzugefügt
zIndex: 1 // Hinzugefügt
}}
>
<Link href="/projects">
<button
className="learnMoreButton"
style={{
padding: "1rem 2rem",
fontSize: "1.25rem",
backgroundColor: "#3d003d",
border: "none",
borderRadius: "5px",
color: "#fff",
cursor: "pointer",
transition: "background-color 0.3s ease",
}}
onMouseOver={(e) => (e.currentTarget.style.backgroundColor = "#2e002e")}
onMouseOut={(e) => (e.currentTarget.style.backgroundColor = "#3d003d")}
>
View Projects
</button>
</Link>
</section>
)}
return (<>
<header className="page-head"><div className="shell page-head-row">
<div><p className="eyebrow">About me</p><h1 className="page-title">Always<br/>learning.</h1></div>
<p className="page-intro">I’m a developer driven by curiosity, thoughtful details, and the satisfying moment when a complex problem finally clicks.</p>
</div></header>
<div className="shell about-grid">
<aside className="about-sticky">
<p className="eyebrow">My path so far</p><h2>From playful experiments to complete products.</h2>
<p>Every project has added a new tool, a sharper instinct, or a better question to ask.</p>
<Link href="/projects" className="button">View projects</Link>
</aside>
<div className="timeline">{timeline.map(item => (
<article className="timeline-item" key={`${item.year}-${item.title}`}>
<div className="timeline-year">{item.year}</div>
<div><h3>{item.title}</h3><p>{item.text}</p><div className="skills">{item.skills.map(skill => <span className="skill" key={skill}>{skill}</span>)}</div></div>
</article>
))}</div>
</div>
)
}
</>);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import React, {useEffect, useRef, useState} from "react";
export default function CustomCursor() {
const cursorRef = useRef(null);
const rafRef = useRef(0);
const targetPos = useRef({ x: 0, y: 0 });
const currentPos = useRef({ x: 0, y: 0 });
const [hidden, setHidden] = useState(true);
const [active, setActive] = useState(false);
const [clicking, setClicking] = useState(false);
const animate = () => {
const lerp = 0.18;
currentPos.current.x += (targetPos.current.x - currentPos.current.x) * lerp;
currentPos.current.y += (targetPos.current.y - currentPos.current.y) * lerp;
if (cursorRef.current) {
cursorRef.current.style.transform = `translate3d(${currentPos.current.x}px, ${currentPos.current.y}px, 0)`;
}
rafRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
const move = (e) => {
targetPos.current.x = e.clientX;
targetPos.current.y = e.clientY;
setHidden(false);
};
const mouseEnter = () => setHidden(false);
const mouseLeave = () => setHidden(true);
const mouseDown = () => setClicking(true);
const mouseUp = () => setClicking(false);
const updateActive = (e) => {
const el = e.target;
const clickable = el.closest?.(
'a, button, [role="button"], input[type="submit"], input[type="button"], .clickable'
);
setActive(Boolean(clickable));
};
window.addEventListener("mousemove", move, { passive: true });
window.addEventListener("mouseenter", mouseEnter, { passive: true });
window.addEventListener("mouseleave", mouseLeave, { passive: true });
window.addEventListener("mousedown", mouseDown, { passive: true });
window.addEventListener("mouseup", mouseUp, { passive: true });
window.addEventListener("mouseover", updateActive, { passive: true });
rafRef.current = requestAnimationFrame(animate);
return () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseenter", mouseEnter);
window.removeEventListener("mouseleave", mouseLeave);
window.removeEventListener("mousedown", mouseDown);
window.removeEventListener("mouseup", mouseUp);
window.removeEventListener("mouseover", updateActive);
cancelAnimationFrame(rafRef.current);
};
}, []);
let className = "custom-cursor";
if (hidden) className += " custom-cursor--hidden";
if (active) className += " custom-cursor--active";
if (clicking) className += " custom-cursor--click";
return (
<div aria-hidden="true" ref={cursorRef} className={className}>
<span className="custom-cursor__ring" />
<span className="custom-cursor__dot" />
</div>
);
}
+5 -10
View File
@@ -1,15 +1,10 @@
export default function Footer() {
return (
<footer style={{
padding: "2rem",
textAlign: "center",
background: "#1a001a",
borderTop: "2px solid #3d003d",
marginTop: "auto"
}}>
<p style={{ margin: 0, color: "#ccc" }}>
© {new Date().getFullYear()} Frederik Spirgi. All rights reserved.
</p>
<footer className="footer">
<div className="shell footer-inner">
<p>© {new Date().getFullYear()} Frederik Spirgi</p>
<p>Designed & built in Switzerland</p>
</div>
</footer>
);
}
+8 -5
View File
@@ -1,15 +1,18 @@
"use client";
import Link from "next/link";
import {usePathname} from "next/navigation";
export default function Nav() {
const pathname = usePathname();
const links = [["/about", "About"], ["/projects", "Projects"], ["/contact", "Contact"]];
return (
<nav className="navbar">
<Link href="/" className="nav-logo">Fretux</Link>
<Link href="/" className="nav-logo">Fretux<span>.</span></Link>
<div className="nav-links">
<Link href="/about" className="nav-link">About</Link>
<Link href="/projects" className="nav-link">Projects</Link>
<Link href="/contact" className="nav-link">Contact</Link>
{links.map(([href, label]) => (
<Link key={href} href={href} className={`nav-link ${pathname === href ? "active" : ""}`}>{label}</Link>
))}
</div>
</nav>
);
}
}
+14 -68
View File
@@ -1,73 +1,19 @@
"use client";
import { useEffect } from "react";
import Image from "next/image";
export const metadata = {title: "Contact"};
export default function ContactPage() {
useEffect(() => {
if (!window.customElements.get("ion-icon")) {
const script = document.createElement("script");
script.setAttribute("type", "module");
script.setAttribute("src", "https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js");
document.head.appendChild(script);
const noModuleScript = document.createElement("script");
noModuleScript.setAttribute("nomodule", "");
noModuleScript.setAttribute("src", "https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js");
document.head.appendChild(noModuleScript);
}
}, []);
return (
<div className="css_body">
<main>
<h1>Kontakt & Impressum</h1>
<article>
<h2>Kontakt</h2>
<section className="contact-list">
<div className="contact-item">
<Image
className="contact-pic"
src="https://avatars.githubusercontent.com/u/143030312?v=4"
alt="Frederik Spirgi"
width={180}
height={180}
/>
<p className="contact-name">Frederik Spirgi</p>
<div className="contact-links">
<a
className="contact-link"
href="https://github.com/IM23a-spirgif"
target="_blank"
rel="noopener noreferrer"
>
<ion-icon name="logo-github"></ion-icon>
<span>GitHub</span>
</a>
<a className="contact-link" href="mailto:Frederik@spirgi.com">
<ion-icon name="mail-outline"></ion-icon>
<span>Email</span>
</a>
<a className="contact-link" href="tel:+41775337036">
<ion-icon name="call-outline"></ion-icon>
<span>Phone</span>
</a>
</div>
</div>
</section>
</article>
<article>
<h2>Impressum</h2>
<section className="impressum">
<div className="impressum-item">
<strong>Frederik Spirgi</strong><br />
Frederik@spirgi.com<br />
077 533 70 36
</div>
</section>
</article>
</main>
<div className="shell contact-layout">
<section>
<p className="eyebrow">Contact</p>
<h1 className="contact-big">Let’s make<br/>something<br/>good.</h1>
<a className="contact-email" href="mailto:fretux@fretux.ch">fretux@fretux.ch ↗</a>
</section>
<aside className="contact-card">
<h2>Details</h2>
<div className="contact-row"><span>Name</span><strong>Frederik Spirgi</strong></div>
<div className="contact-row"><span>Based in</span><strong>Switzerland</strong></div>
<div className="contact-row"><span>Email</span><a href="mailto:fretux@fretux.ch">fretux@fretux.ch</a></div>
<div className="contact-row"><span>Elsewhere</span><a href="https://github.com/IM23a-spirgif" target="_blank" rel="noopener noreferrer">GitHub ↗</a></div>
</aside>
</div>
);
}
+536 -681
View File
File diff suppressed because it is too large Load Diff
+12 -29
View File
@@ -1,42 +1,25 @@
import { Geist, Geist_Mono } from "next/font/google";
import {Geist, Geist_Mono} from "next/font/google";
import "./globals.css";
import Nav from "./components/Nav";
import Footer from "./components/Footer";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const geistSans = Geist({variable: "--font-geist-sans", subsets: ["latin"]});
const geistMono = Geist_Mono({variable: "--font-geist-mono", subsets: ["latin"]});
export const metadata = {
title: "Fretux",
description: "Frederik Spirgi – Frontend developer",
icons: {
icon: "/favicon.ico",
},
title: {default: "Fretux — Developer & digital maker", template: "%s — Fretux"},
description: "Portfolio of Frederik Spirgi, a developer building thoughtful digital experiences.",
icons: {icon: "/favicon.ico"},
};
export default function RootLayout({ children }) {
export default function RootLayout({children}) {
return (
<html lang="en">
<body style={{
display: "flex",
flexDirection: "column",
minHeight: "100vh",
background: "linear-gradient(135deg, #000, #1a001a)",
color: "#fff",
}}>
<Nav />
<main style={{ flex: 1 }}>
{children}
</main>
<Footer />
</body>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<Nav/>
<main className="site-main">{children}</main>
<Footer/>
</body>
</html>
);
}
+31 -77
View File
@@ -1,88 +1,42 @@
"use client";
import {useState, useEffect} from "react";
import Image from "next/image";
import Link from "next/link";
import FadeInSection from "./components/FadeInSection";
export default function Home() {
const [showScrollPrompt, setShowScrollPrompt] = useState(false);
const [title, setTitle] = useState("Fretux");
const [phase, setPhase] = useState("normal");
useEffect(() => {
let fadeTimeout;
const interval = setInterval(() => {
setPhase("fadeOut");
fadeTimeout = setTimeout(() => {
setTitle(prev => prev === "Fretux" ? "Frederik Spirgi" : "Fretux");
setPhase("fadeIn");
fadeTimeout = setTimeout(() => setPhase("normal"), 500);
}, 500);
}, 3000);
return () => {
clearInterval(interval);
clearTimeout(fadeTimeout);
};
}, []);
useEffect(() => {
if (typeof window !== "undefined") {
window.scrollTo(0, 0);
}
}, []);
useEffect(() => {
const timer = setTimeout(() => {
setShowScrollPrompt(true);
}, 1500);
const handleScroll = () => {
setShowScrollPrompt(false);
clearTimeout(timer);
window.removeEventListener("scroll", handleScroll);
};
window.addEventListener("scroll", handleScroll);
return () => {
clearTimeout(timer);
window.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<div className="container">
{showScrollPrompt && (
<div className="scrollPrompt">
<p>Scroll subtly</p>
</div>
)}
<FadeInSection>
<header className="header">
<h1 className={`title ${phase}`}>{title}</h1>
<p className="intro">
Welcome to my portfolio. I’m a developer passionate about building modern and responsive
applications.
</p>
</header>
</FadeInSection>
<FadeInSection>
<section className="section">
<div className="textLeft">
<div>
I&#39;m a passionate developer exploring web, game, and backend technologies with a focus on
innovation and creativity.
<div className="buttonLeft">
<Link href="/about">
<button className="learnMoreButton">Learn More</button>
</Link>
</div>
</div>
<>
<div className="shell">
<section className="hero">
<div>
<p className="eyebrow">Developer · Digital maker</p>
<h1>Code with<br/><em>character.</em></h1>
</div>
<div className="imageRight">
<Image
src="/images/portfolio1.jpg"
alt="Screenshot of a portfolio project"
width={500}
height={300}
/>
<div className="hero-side">
<div className="availability">Open to opportunities</div>
<p>I’m Frederik, a developer from Switzerland creating useful, expressive experiences across web, games, and software.</p>
<Link className="button" href="/projects">Explore my work</Link>
</div>
</section>
</div>
<div className="marquee" aria-hidden="true">
<span>JavaScript</span><b>✦</b><span>Python</span><b>✦</b><span>Java</span><b>✦</b>
<span>Creative development</span><b>✦</b><span>Web experiences</span><b>✦</b><span>SQL</span>
</div>
<FadeInSection>
<section className="shell featured">
<div className="featured-grid">
<div>
<p className="eyebrow">A little about me</p>
<h2>Curious by default.</h2>
<p>I like learning how things work, then making them work better. My projects move between polished interfaces, practical backend systems, and playful experiments.</p>
<Link className="button light" href="/about">My journey</Link>
</div>
<div className="image-frame">
<Image src="/images/portfolio1.jpg" alt="A featured development project" fill sizes="(max-width: 800px) 100vw, 60vw"/>
</div>
</div>
</section>
</FadeInSection>
</div>
</>
);
}
}
+20 -87
View File
@@ -1,90 +1,23 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import FadeInSection from "@/app/components/FadeInSection";
const projectData = [
{
title: "Red Lake Stats",
description: "An interactive Javascript and Python application designed to calculate damage statistics for the Roblox game 'The Red Lake'",
image: "/images/portfolio-project1.png",
link: "https://fretux.ch/trlstats",
},
{
title: "SBB-History",
description: "A website about the creation of the SBB (Swiss Train Corporation) with an interactable map!",
image: "/images/portfolio-project2.png",
link: "https://sbb-history.fretux.ch",
},
{
title: "Fretux.ch",
description: "My first portfolio website, gameified for more user interaction.",
image: "/images/portfolio-project3.png",
link: "https://fretux.ch",
},
{
title: "KnockedBack",
description: "A minecraft mod that revamps dying.",
image: "/images/KnockedBack.png",
link: "https://modrinth.com/mod/knockedback",
},
{
title: "Stalinium",
description: "Another minecraft mod I made with some friends, that adds the fictional 'stalinium' ore to the game.",
image: "/images/Stalinium.png",
link: "https://modrinth.com/mod/stalinium-mod",
},
export const metadata = {title: "Projects"};
const projects = [
{title: "Red Lake Stats", description: "An interactive JavaScript and Python tool for calculating damage statistics in the Roblox game The Red Lake.", image: "/images/portfolio-project1.png", link: "https://fretux.ch/trlstats"},
{title: "SBB History", description: "An interactive journey through the creation of Switzerland’s railway company, featuring an explorable map.", image: "/images/portfolio-project2.png", link: "https://sbb-history.fretux.ch"},
{title: "Fretux.ch", description: "My first portfolio—gamified to make exploring personal work feel more playful and memorable.", image: "/images/portfolio-project3.png", link: "https://fretux.ch"},
{title: "KnockedBack", description: "A Minecraft mod that reimagines what happens when a player dies.", image: "/images/KnockedBack.png", link: "https://modrinth.com/mod/knockedback"},
{title: "Stalinium", description: "A collaborative Minecraft mod adding the fictional Stalinium ore and a new layer of progression.", image: "/images/Stalinium.png", link: "https://modrinth.com/mod/stalinium-mod"},
];
export default function ProjectsPage() {
const [currentIndex, setCurrentIndex] = useState(0);
const [fade, setFade] = useState(false);
const handleNavigation = (direction) => {
setFade(true);
setTimeout(() => {
setCurrentIndex((prev) => {
const newIndex =
direction === "next"
? (prev + 1) % projectData.length
: (prev - 1 + projectData.length) % projectData.length;
return newIndex;
});
setFade(false);
}, 300);
};
const project = projectData[currentIndex];
return (
<div className="projectContainer">
<FadeInSection>
<div className={`projectCard ${fade ? "fade-out" : "fade-in"}`}>
<h2 className="projectTitle">{project.title}</h2>
<Image
src={project.image}
alt={project.title}
width={600}
height={400}
className="projectImage"
/>
<p className="projectDescription">{project.description}</p>
<a href={project.link} target="_blank" rel="noopener noreferrer">
<button className="learnMoreButton" style={{ marginTop: "1.5rem" }}>
View Project
</button>
</a>
</div>
</FadeInSection>
<div className="projectNavigation">
<button onClick={() => handleNavigation("prev")} className="arrowButton">
&larr;
</button>
<button onClick={() => handleNavigation("next")} className="arrowButton">
&rarr;
</button>
</div>
</div>
);
}
return (<>
<header className="page-head"><div className="shell page-head-row">
<div><p className="eyebrow">Selected work</p><h1 className="page-title">Things I’ve<br/>made.</h1></div>
<p className="page-intro">A mix of useful tools, interactive websites, and game modifications—each built to explore a new idea.</p>
</div></header>
<div className="shell projects-grid">{projects.map(project => (
<a className="project-card" href={project.link} target="_blank" rel="noopener noreferrer" key={project.title}>
<div className="project-image"><Image src={project.image} alt={`${project.title} project preview`} width={800} height={550}/></div>
<div className="project-meta"><h2>{project.title}</h2><span aria-hidden="true">↗</span></div><p>{project.description}</p>
</a>
))}</div>
</>);
}