Port portfolio from Next.js/React to SolidJS with Vite

Replace Next.js App Router with Vite, solid-js, and @solidjs/router.
Convert all components, sections, hooks, and pages to SolidJS patterns
(signals, onMount, For, A). Swap lucide-react for lucide-solid and add
client-side routing with Vercel SPA rewrites.

Co-authored-by: Mike Karl <karl.mike2002@gmail.com>
This commit is contained in:
Cursor Agent 2026-06-15 23:52:28 +00:00
parent d72113bef9
commit 2e76528ac0
No known key found for this signature in database
47 changed files with 2615 additions and 2640 deletions

5
.gitignore vendored
View file

@ -13,11 +13,10 @@
# testing
/coverage
# next.js
/.next/
/out/
# vite
/dist/
# production
/build

30
index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="de" data-scroll-behavior="smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mike Karl — Portfolio</title>
<meta
name="description"
content="Portfolio von Mike Karl — Systementwickler & Creative Technologist. Projekte aus Systems Programming, Computer Graphics und Low-Level-Entwicklung."
/>
<link rel="icon" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--font-space-grotesk: "Space Grotesk", sans-serif;
--font-inter: "Inter", sans-serif;
--font-ibm-plex-mono: "IBM Plex Mono", monospace;
}
</style>
</head>
<body class="min-h-screen bg-deep-space text-text-primary antialiased">
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View file

@ -1,16 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
distDir: "dist",
compress: true,
images: {
unoptimized: true,
},
experimental: {
inlineCss: true,
optimizePackageImports: ["lucide-react", "gsap"],
},
};
export default nextConfig;

2960
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,21 +4,18 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "node scripts/cleanup-legacy.js",
"start": "next start",
"lint": "next lint"
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^0.15.3",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
"lucide-react": "^1.8.0",
"next": "^16.2.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"lucide-solid": "^0.511.0",
"solid-js": "^1.9.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4"
},
@ -31,10 +28,9 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.2.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^10.2.1",
"postcss": "^8.5.0",
"typescript": "~6.0.2"
"typescript": "~6.0.2",
"vite": "^6.3.5",
"vite-plugin-solid": "^2.11.6"
}
}

View file

@ -1,57 +0,0 @@
import { readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from "fs";
import { join } from "path";
const distDir = "./dist";
function walk(dir, callback) {
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
walk(fullPath, callback);
} else {
callback(fullPath, stats);
}
}
}
// Remove noModule script tags from all HTML files
let htmlCount = 0;
walk(distDir, (filePath) => {
if (filePath.endsWith(".html")) {
let content = readFileSync(filePath, "utf-8");
const before = content.length;
// Remove both <script ... noModule=""> and self-closing variants
content = content.replace(/<script[^>]*\snoModule[^>]*>(?:[^<]*(?:<(?!\/script>)[^<]*)*)?<\/script>/gi, "");
content = content.replace(/<script[^>]*\snoModule[^>]*\/>/gi, "");
if (content.length !== before) {
writeFileSync(filePath, content);
htmlCount++;
}
}
});
if (htmlCount > 0) {
console.log(`Cleaned noModule scripts from ${htmlCount} HTML files`);
}
// Remove core-js polyfill chunks
let removedCount = 0;
walk(distDir, (filePath) => {
if (filePath.endsWith(".js")) {
try {
const content = readFileSync(filePath, "utf-8");
if (content.includes('"zloirock"') || content.includes("core-js")) {
unlinkSync(filePath);
console.log("Removed legacy polyfill:", filePath);
removedCount++;
}
} catch {
// ignore
}
}
});
if (removedCount === 0) {
console.log("No legacy polyfills found to remove");
}

91
src/App.tsx Normal file
View file

@ -0,0 +1,91 @@
import { Route } from "@solidjs/router";
import Layout from "./components/Layout";
import PageTransition from "./components/PageTransition";
import HomePage from "./pages/HomePage";
import AboutPage from "./pages/AboutPage";
import HobbiesPage from "./pages/HobbiesPage";
import ProjectsPage from "./pages/ProjectsPage";
import TimelinePage from "./pages/TimelinePage";
import ContactPage from "./pages/ContactPage";
import RaycasterPage from "./pages/project/RaycasterPage";
import RaytracerPage from "./pages/project/RaytracerPage";
import EmulatorPage from "./pages/project/EmulatorPage";
export default function App() {
return (
<Route path="/" component={Layout}>
<Route
path="/"
component={() => (
<PageTransition>
<HomePage />
</PageTransition>
)}
/>
<Route
path="/about"
component={() => (
<PageTransition>
<AboutPage />
</PageTransition>
)}
/>
<Route
path="/hobbies"
component={() => (
<PageTransition>
<HobbiesPage />
</PageTransition>
)}
/>
<Route
path="/projects"
component={() => (
<PageTransition>
<ProjectsPage />
</PageTransition>
)}
/>
<Route
path="/timeline"
component={() => (
<PageTransition>
<TimelinePage />
</PageTransition>
)}
/>
<Route
path="/contact"
component={() => (
<PageTransition>
<ContactPage />
</PageTransition>
)}
/>
<Route
path="/project/raycaster"
component={() => (
<PageTransition>
<RaycasterPage />
</PageTransition>
)}
/>
<Route
path="/project/raytracer"
component={() => (
<PageTransition>
<RaytracerPage />
</PageTransition>
)}
/>
<Route
path="/project/emulator"
component={() => (
<PageTransition>
<EmulatorPage />
</PageTransition>
)}
/>
</Route>
);
}

View file

@ -1,136 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { values, aboutPage } from "@/data/about";
import { site } from "@/data/site";
import { Icon } from "@/lib/icons";
export const metadata: Metadata = aboutPage.metadata;
export default function AboutPage() {
return (
<main className="min-h-screen bg-deep-space">
{/* Header */}
<section className="pt-32 pb-20 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto space-y-6">
<p className="section-label">{aboutPage.label}</p>
<h1
className="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ fontSize: "clamp(2.5rem, 8vw, 5rem)" }}
>
{aboutPage.pageTitle}
</h1>
<p className="text-text-secondary max-w-2xl text-lg leading-relaxed">
{aboutPage.pageSubtitle}
</p>
</div>
</section>
{/* Content */}
<section className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-16">
<div className="space-y-8">
<h2 className="font-display font-semibold text-text-primary text-2xl tracking-tight">
{aboutPage.philosophyTitle}
</h2>
<p className="text-text-secondary leading-relaxed">
{aboutPage.philosophyText[0]}
</p>
<p className="text-text-secondary leading-relaxed">
{aboutPage.philosophyText[1]}
</p>
<h2 className="font-display font-semibold text-text-primary text-2xl tracking-tight pt-8">
{aboutPage.backgroundTitle}
</h2>
<p className="text-text-secondary leading-relaxed">
{aboutPage.backgroundText[0]}
</p>
<p className="text-text-secondary leading-relaxed">
{aboutPage.backgroundText[1]}
</p>
<div className="pt-4">
<Link
href="/hobbies"
className="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group"
>
<span className="text-link">
{aboutPage.linkText}
</span>
<ArrowRight
size={18}
className="group-hover:translate-x-1 transition-transform"
/>
</Link>
</div>
</div>
<div className="space-y-8">
<div className="border border-warm-grey p-6 space-y-4">
<h3 className="font-display font-medium text-text-primary">
{aboutPage.sidebarFocusTitle}
</h3>
<ul className="space-y-2 text-text-secondary text-sm">
{aboutPage.sidebarFocusItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="border border-warm-grey p-6 space-y-4">
<h3 className="font-display font-medium text-text-primary">
{aboutPage.sidebarLocationTitle}
</h3>
<p className="text-text-secondary text-sm">
{site.location}
</p>
</div>
<div className="border border-warm-grey p-6 space-y-4">
<h3 className="font-display font-medium text-text-primary">
{aboutPage.sidebarHobbiesTitle}
</h3>
<ul className="space-y-2 text-text-secondary text-sm">
{aboutPage.sidebarHobbiesItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<Link
href="/hobbies"
className="text-link text-amber text-sm"
>
{aboutPage.sidebarHobbiesLink}
</Link>
</div>
</div>
</div>
</section>
{/* Values */}
<section className="py-20 md:py-32 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto">
<p className="section-label mb-12">{aboutPage.valuesTitle}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
{values.map((value) => (
<div key={value.title} className="space-y-6">
<div className="w-10 h-10 flex items-center justify-center border border-warm-grey">
<Icon name={value.icon} size={20} className="text-amber" />
</div>
<h3 className="font-display font-semibold text-text-primary text-2xl tracking-tight">
{value.title}
</h3>
<p className="text-text-secondary leading-relaxed">
{value.text}
</p>
<p className="font-display text-text-primary italic border-l-2 border-amber pl-4">
&ldquo;{value.quote}&rdquo;
</p>
</div>
))}
</div>
</div>
</section>
</main>
);
}

View file

@ -1,96 +0,0 @@
import type { Metadata } from "next";
import { site } from "@/data/site";
import { contactContent, contactPage } from "@/data/contact";
import { Mail } from "lucide-react";
export const metadata: Metadata = contactPage.metadata;
export default function ContactPage() {
return (
<main className="min-h-screen bg-deep-space">
{/* Header */}
<section className="pt-32 pb-20 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto space-y-6">
<p className="section-label">{contactPage.label}</p>
<h1
className="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ fontSize: "clamp(2.5rem, 8vw, 5rem)" }}
>
{contactContent.heading}
</h1>
<p className="text-text-secondary max-w-2xl text-lg leading-relaxed">
{contactPage.pageSubtitle}
</p>
</div>
</section>
{/* Content */}
<section className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-16">
<div className="space-y-8">
<h2 className="font-display font-semibold text-text-primary text-2xl tracking-tight">
{contactPage.contentTitle}
</h2>
<p className="text-text-secondary leading-relaxed">
{contactContent.text}
</p>
<p className="text-text-secondary leading-relaxed">
{contactPage.contentPlaceholder1}
</p>
<p className="text-text-secondary leading-relaxed">
{contactPage.contentPlaceholder2}
</p>
<div className="pt-4">
<a
href={`mailto:${site.email}`}
className="inline-flex items-center gap-3 px-6 py-3 bg-amber text-deep-space font-display font-medium hover:bg-amber-hover transition-colors"
>
<Mail size={18} />
{contactContent.cta}
</a>
</div>
</div>
<div className="space-y-8">
<div className="border border-warm-grey p-6 space-y-4">
<h3 className="font-display font-medium text-text-primary">
{contactPage.sidebarEmailTitle}
</h3>
<a
href={`mailto:${site.email}`}
className="text-link text-amber font-mono text-sm"
>
{site.email}
</a>
</div>
<div className="border border-warm-grey p-6 space-y-4">
<h3 className="font-display font-medium text-text-primary">
{contactPage.sidebarSocialTitle}
</h3>
<div className="space-y-2 text-sm">
<a
href={site.github}
target="_blank"
rel="noopener noreferrer"
className="block text-text-secondary hover:text-amber transition-colors"
>
{contactPage.sidebarSocialGithub}
</a>
<a
href={site.linkedin}
target="_blank"
rel="noopener noreferrer"
className="block text-text-secondary hover:text-amber transition-colors"
>
{contactPage.sidebarSocialLinkedin}
</a>
</div>
</div>
</div>
</div>
</section>
</main>
);
}

View file

@ -1,72 +0,0 @@
import type { Metadata } from "next";
import { hobbies, hobbiesPage } from "@/data/hobbies";
import { Icon } from "@/lib/icons";
export const metadata: Metadata = hobbiesPage.metadata;
export default function HobbiesPage() {
return (
<main className="min-h-screen bg-deep-space">
{/* Header */}
<section className="pt-32 pb-20 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto space-y-6">
<p className="section-label">{hobbiesPage.label}</p>
<h1
className="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ fontSize: "clamp(2.5rem, 8vw, 5rem)" }}
>
{hobbiesPage.pageTitle}
</h1>
<p className="text-text-secondary max-w-2xl text-lg leading-relaxed">
{hobbiesPage.pageSubtitle}
</p>
</div>
</section>
{/* Hobbies Content */}
<section className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto space-y-24">
{hobbies.map((hobby, index) => {
const isEven = index % 2 === 0;
return (
<div
key={hobby.id}
className={`grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-12 items-start ${
isEven ? "" : "lg:grid-cols-[2fr_1fr]"
}`}
>
<div className={`space-y-6 ${isEven ? "" : "lg:order-2"}`}>
<div className="w-12 h-12 flex items-center justify-center border border-warm-grey">
<Icon name={hobby.icon} size={24} className="text-amber" />
</div>
<h2 className="font-display font-semibold text-text-primary text-3xl tracking-tight">
{hobby.title}
</h2>
</div>
<div className={`space-y-6 ${isEven ? "" : "lg:order-1"}`}>
{hobby.description.map((paragraph, i) => (
<p key={i} className="text-text-secondary leading-relaxed">
{paragraph}
</p>
))}
</div>
</div>
);
})}
</div>
</section>
{/* Quote */}
<section className="py-20 px-[5vw] bg-charcoal">
<div className="max-w-[800px] mx-auto text-center space-y-6">
<p className="font-display text-text-primary text-2xl md:text-3xl italic leading-relaxed">
&ldquo;{hobbiesPage.quote}&rdquo;
</p>
<p className="text-text-secondary text-sm font-mono">
{hobbiesPage.quoteAuthor}
</p>
</div>
</section>
</main>
);
}

View file

@ -1,66 +0,0 @@
import type { Metadata } from "next";
import { Space_Grotesk, Inter, IBM_Plex_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import "./globals.css";
import Navigation from "@/components/Navigation";
import Footer from "@/components/Footer";
import PreviewBanner from "@/components/PreviewBanner";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-space-grotesk",
display: "swap",
});
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-inter",
display: "swap",
preload: false,
});
const ibmPlexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-ibm-plex-mono",
display: "swap",
preload: false,
});
export const metadata: Metadata = {
title: {
default: "Mike Karl — Portfolio",
template: "%s — Mike Karl",
},
description:
"Portfolio von Mike Karl — Systementwickler & Creative Technologist. Projekte aus Systems Programming, Computer Graphics und Low-Level-Entwicklung.",
icons: {
icon: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="de"
data-scroll-behavior="smooth"
className={`${spaceGrotesk.variable} ${inter.variable} ${ibmPlexMono.variable}`}
>
<body className="min-h-screen bg-deep-space text-text-primary antialiased">
<PreviewBanner />
<Navigation />
{children}
<Footer />
<Analytics />
<SpeedInsights />
</body>
</html>
);
}

View file

@ -1,77 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { projects, projectsPage } from "@/data/projects";
import { ArrowRight } from "lucide-react";
export const metadata: Metadata = projectsPage.metadata;
export default function ProjectsPage() {
return (
<main className="min-h-screen bg-deep-space">
{/* Header */}
<section className="pt-32 pb-20 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto space-y-6">
<p className="section-label">{projectsPage.pageLabel}</p>
<h1
className="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ fontSize: "clamp(2.5rem, 8vw, 5rem)" }}
>
{projectsPage.pageTitle}
</h1>
<p className="text-text-secondary max-w-2xl text-lg leading-relaxed">
{projectsPage.subtitle}
</p>
</div>
</section>
{/* Project Grid */}
<section className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto space-y-16">
{projects.map((project) => (
<div
key={project.id}
className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-12 items-start border-t border-warm-grey pt-12"
>
<div className="border border-warm-grey overflow-hidden group/image">
<img
src={project.image}
alt={project.title}
className="w-full h-auto group-hover/image:scale-105 transition-transform duration-700 ease-out"
loading="lazy"
/>
</div>
<div className="space-y-6">
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
</div>
<h2 className="font-display font-semibold text-text-primary text-3xl tracking-tight">
{project.title}
</h2>
<p className="text-text-secondary leading-relaxed">
{project.description}
</p>
<p className="text-text-secondary leading-relaxed">
{projectsPage.detailText}
</p>
<Link
href={project.link}
className="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group"
>
<span className="text-link">{projectsPage.linkText}</span>
<ArrowRight
size={18}
className="group-hover:translate-x-1 transition-transform"
/>
</Link>
</div>
</div>
))}
</div>
</section>
</main>
);
}

View file

@ -1,5 +0,0 @@
import PageTransition from "@/components/PageTransition";
export default function Template({ children }: { children: React.ReactNode }) {
return <PageTransition>{children}</PageTransition>;
}

View file

@ -1,58 +0,0 @@
import type { Metadata } from "next";
import { timeline, timelinePage } from "@/data/timeline";
export const metadata: Metadata = timelinePage.metadata;
export default function TimelinePage() {
return (
<main className="min-h-screen bg-deep-space">
{/* Header */}
<section className="pt-32 pb-20 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto space-y-6">
<p className="section-label">{timelinePage.label}</p>
<h1
className="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ fontSize: "clamp(2.5rem, 8vw, 5rem)" }}
>
{timelinePage.pageTitle}
</h1>
<p className="text-text-secondary max-w-2xl text-lg leading-relaxed">
{timelinePage.subtitle}
</p>
</div>
</section>
{/* Timeline Content */}
<section className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto space-y-16">
{timeline.map((item) => (
<div
key={item.year}
className="grid grid-cols-1 md:grid-cols-[120px_1fr] gap-8 border-t border-warm-grey pt-12"
>
<span className="font-mono text-amber text-xl">{item.year}</span>
<div className="space-y-4">
<h2 className="font-display font-semibold text-text-primary text-2xl tracking-tight">
{item.title}
</h2>
<p className="text-text-secondary leading-relaxed">
{item.description}
</p>
<p className="text-text-secondary leading-relaxed">
{timelinePage.detailText}
</p>
<div className="flex flex-wrap gap-2 pt-2">
{item.tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
</div>
</div>
</div>
))}
</div>
</section>
</main>
);
}

View file

@ -2,12 +2,12 @@ import { site, footerContent } from "../data/site";
export default function Footer() {
return (
<footer className="border-t border-warm-grey py-8 px-[5vw]">
<div className="max-w-[1200px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-sm text-text-secondary">
<footer class="border-t border-warm-grey py-8 px-[5vw]">
<div class="max-w-[1200px] mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<p class="text-sm text-text-secondary">
{footerContent.copyright(new Date().getFullYear(), site.name)}
</p>
<p className="text-sm text-text-secondary font-mono">
<p class="text-sm text-text-secondary font-mono">
{footerContent.builtWith}
</p>
</div>

23
src/components/Layout.tsx Normal file
View file

@ -0,0 +1,23 @@
import { onMount } from "solid-js";
import { inject } from "@vercel/analytics";
import { injectSpeedInsights } from "@vercel/speed-insights";
import type { RouteSectionProps } from "@solidjs/router";
import Navigation from "./Navigation";
import Footer from "./Footer";
import PreviewBanner from "./PreviewBanner";
export default function Layout(props: RouteSectionProps) {
onMount(() => {
inject();
injectSpeedInsights();
});
return (
<>
<PreviewBanner />
<Navigation />
{props.children}
<Footer />
</>
);
}

View file

@ -1,71 +1,70 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { createSignal, createEffect, onMount, onCleanup, For } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import gsap from "gsap";
import { Menu, X } from "lucide-react";
import { Menu, X } from "lucide-solid";
import { site, navLinks, socialLinks } from "../data/site";
import { Icon } from "@/lib/icons";
export default function Navigation() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const menuRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const linksRef = useRef<HTMLUListElement>(null);
const socialsRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const prefersReducedMotion = useRef(false);
const [scrolled, setScrolled] = createSignal(false);
const [mobileOpen, setMobileOpen] = createSignal(false);
const location = useLocation();
useEffect(() => {
prefersReducedMotion.current = window.matchMedia(
let menuRef: HTMLDivElement | undefined;
let backdropRef: HTMLDivElement | undefined;
let linksRef: HTMLUListElement | undefined;
let socialsRef: HTMLDivElement | undefined;
let timelineRef: gsap.core.Timeline | null = null;
let prefersReducedMotion = false;
onMount(() => {
prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
}, []);
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 50);
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
onCleanup(() => window.removeEventListener("scroll", handleScroll));
});
createEffect(() => {
location.pathname;
setMobileOpen(false);
window.scrollTo(0, 0);
}, [pathname]);
});
useEffect(() => {
if (mobileOpen) {
createEffect(() => {
if (mobileOpen()) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
});
useEffect(() => {
onCleanup(() => {
document.body.style.overflow = "";
});
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && mobileOpen) {
if (e.key === "Escape" && mobileOpen()) {
setMobileOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mobileOpen]);
onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
});
const animateOpen = useCallback(() => {
const menu = menuRef.current;
const backdrop = backdropRef.current;
const links = linksRef.current;
const socials = socialsRef.current;
const animateOpen = () => {
const menu = menuRef;
const backdrop = backdropRef;
const links = linksRef;
const socials = socialsRef;
if (!menu || !backdrop || !links || !socials) return;
if (prefersReducedMotion.current) {
if (prefersReducedMotion) {
gsap.set(menu, { opacity: 1, y: 0 });
gsap.set(backdrop, { opacity: 1 });
gsap.set([...links.children, ...socials.children], { opacity: 1, y: 0 });
@ -73,7 +72,7 @@ export default function Navigation() {
}
const tl = gsap.timeline();
timelineRef.current = tl;
timelineRef = tl;
tl.set(menu, { display: "block" })
.fromTo(
@ -111,20 +110,20 @@ export default function Navigation() {
},
"-=0.15"
);
}, []);
};
const animateClose = useCallback(() => {
const menu = menuRef.current;
const backdrop = backdropRef.current;
const links = linksRef.current;
const socials = socialsRef.current;
const animateClose = () => {
const menu = menuRef;
const backdrop = backdropRef;
const links = linksRef;
const socials = socialsRef;
if (!menu || !backdrop || !links || !socials) return;
if (timelineRef.current) {
timelineRef.current.kill();
if (timelineRef) {
timelineRef.kill();
}
if (prefersReducedMotion.current) {
if (prefersReducedMotion) {
gsap.set(menu, { display: "none", opacity: 0 });
gsap.set(backdrop, { opacity: 0, display: "none" });
return;
@ -151,132 +150,134 @@ export default function Navigation() {
)
.to(menu, { opacity: 0, y: -8, duration: 0.2, ease: "power2.in" }, "-=0.08")
.to(backdrop, { opacity: 0, duration: 0.2, ease: "power2.in" }, "<");
}, []);
};
useEffect(() => {
if (mobileOpen) {
createEffect(() => {
if (mobileOpen()) {
animateOpen();
} else {
animateClose();
}
}, [mobileOpen, animateOpen, animateClose]);
});
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-colors duration-300 [will-change:transform] ${
scrolled
class={`fixed top-0 left-0 right-0 z-50 transition-colors duration-300 [will-change:transform] ${
scrolled()
? "bg-deep-space/95 border-b border-warm-grey/50"
: "bg-transparent border-b border-transparent"
}`}
>
<nav className="max-w-[1400px] mx-auto px-[5vw] py-5 flex items-center justify-between">
<Link
<nav class="max-w-[1400px] mx-auto px-[5vw] py-5 flex items-center justify-between">
<A
href="/"
className="font-display font-bold text-text-primary tracking-tight text-lg"
class="font-display font-bold text-text-primary tracking-tight text-lg"
>
{site.name.toUpperCase()}
</Link>
</A>
{/* Desktop Nav */}
<div className="hidden md:flex items-center gap-8">
<ul className="flex items-center gap-6">
{navLinks.map((link) => {
const isActive = pathname === link.href;
<div class="hidden md:flex items-center gap-8">
<ul class="flex items-center gap-6">
<For each={navLinks}>
{(link) => {
const isActive = () => location.pathname === link.href;
return (
<li key={link.href}>
<Link
<li>
<A
href={link.href}
className={`text-sm transition-colors ${
isActive
class={`text-sm transition-colors ${
isActive()
? "text-text-primary"
: "text-text-secondary hover:text-text-primary"
}`}
>
{link.label}
</Link>
</A>
</li>
);
})}
}}
</For>
</ul>
<div className="flex items-center gap-4 border-l border-warm-grey pl-6">
{socialLinks.map((link) => (
<div class="flex items-center gap-4 border-l border-warm-grey pl-6">
<For each={socialLinks}>
{(link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
aria-label={link.label}
className="text-text-secondary hover:text-amber transition-colors"
class="text-text-secondary hover:text-amber transition-colors"
>
<Icon name={link.icon} size={18} />
</a>
))}
)}
</For>
</div>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden relative w-6 h-6"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={mobileOpen ? "Close menu" : "Open menu"}
aria-expanded={mobileOpen}
class="md:hidden relative w-6 h-6"
onClick={() => setMobileOpen(!mobileOpen())}
aria-label={mobileOpen() ? "Close menu" : "Open menu"}
aria-expanded={mobileOpen()}
>
<Menu
size={24}
className={`absolute inset-0 text-text-primary transition-opacity duration-200 ease-out ${
mobileOpen ? "opacity-0" : "opacity-100"
class={`absolute inset-0 text-text-primary transition-opacity duration-200 ease-out ${
mobileOpen() ? "opacity-0" : "opacity-100"
}`}
/>
<X
size={24}
className={`absolute inset-0 text-text-primary transition-opacity duration-200 ease-out ${
mobileOpen ? "opacity-100" : "opacity-0"
class={`absolute inset-0 text-text-primary transition-opacity duration-200 ease-out ${
mobileOpen() ? "opacity-100" : "opacity-0"
}`}
/>
</button>
</nav>
{/* Backdrop */}
<div
ref={backdropRef}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 md:hidden"
class="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 md:hidden"
style={{ display: "none", opacity: 0 }}
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
{/* Mobile Menu */}
<div
ref={menuRef}
className="md:hidden bg-deep-space/98 border-t border-warm-grey/50 px-[5vw] py-8 z-40"
class="md:hidden bg-deep-space/98 border-t border-warm-grey/50 px-[5vw] py-8 z-40"
style={{ display: "none", opacity: 0 }}
>
<ul ref={linksRef} className="space-y-4 mb-8">
{navLinks.map((link) => (
<li key={link.href}>
<Link
<ul ref={linksRef} class="space-y-4 mb-8">
<For each={navLinks}>
{(link) => (
<li>
<A
href={link.href}
className="block text-lg text-text-primary"
class="block text-lg text-text-primary"
onClick={() => setMobileOpen(false)}
>
{link.label}
</Link>
</A>
</li>
))}
)}
</For>
</ul>
<div ref={socialsRef} className="flex items-center gap-6">
{socialLinks.map((link) => (
<div ref={socialsRef} class="flex items-center gap-6">
<For each={socialLinks}>
{(link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
aria-label={link.label}
className="text-text-secondary hover:text-amber transition-colors"
class="text-text-secondary hover:text-amber transition-colors"
>
<Icon name={link.icon} size={20} />
</a>
))}
)}
</For>
</div>
</div>
</header>

View file

@ -1,17 +1,11 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { onMount } from "solid-js";
import gsap from "gsap";
export default function PageTransition({
children,
}: {
children: React.ReactNode;
}) {
const containerRef = useRef<HTMLDivElement>(null);
export default function PageTransition(props: { children: import("solid-js").JSX.Element }) {
let containerRef: HTMLDivElement | undefined;
useLayoutEffect(() => {
const el = containerRef.current;
onMount(() => {
const el = containerRef;
if (!el) return;
const prefersReducedMotion = window.matchMedia(
@ -32,7 +26,7 @@ export default function PageTransition({
});
return () => ctx.revert();
}, []);
});
return <div ref={containerRef}>{children}</div>;
return <div ref={containerRef}>{props.children}</div>;
}

View file

@ -1,6 +1,4 @@
"use client";
import { useEffect, useRef } from "react";
import { onMount, onCleanup } from "solid-js";
interface Particle {
x: number;
@ -11,12 +9,12 @@ interface Particle {
}
export default function ParticleGrid() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animRef = useRef<number>(0);
const mouseRef = useRef({ x: -1000, y: -1000 });
let canvasRef: HTMLCanvasElement | undefined;
let animRef = 0;
const mouseRef = { x: -1000, y: -1000 };
useEffect(() => {
const canvas = canvasRef.current;
onMount(() => {
const canvas = canvasRef;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
@ -30,6 +28,7 @@ export default function ParticleGrid() {
const h = window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
const spacing = Math.min(w, h) / 35;
@ -56,7 +55,7 @@ export default function ParticleGrid() {
const h = window.innerHeight;
ctx.clearRect(0, 0, w, h);
const mouse = mouseRef.current;
const mouse = mouseRef;
const radius = 180;
for (const p of particles) {
@ -86,16 +85,18 @@ export default function ParticleGrid() {
ctx.fill();
}
animRef.current = requestAnimationFrame(animate);
animRef = requestAnimationFrame(animate);
};
const handleMouseMove = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
mouseRef.x = e.clientX - rect.left;
mouseRef.y = e.clientY - rect.top;
};
const handleMouseLeave = () => {
mouseRef.current = { x: -1000, y: -1000 };
mouseRef.x = -1000;
mouseRef.y = -1000;
};
const isTouchDevice = window.matchMedia("(hover: none)").matches;
@ -108,21 +109,21 @@ export default function ParticleGrid() {
canvas.addEventListener("mouseleave", handleMouseLeave);
}
return () => {
cancelAnimationFrame(animRef.current);
onCleanup(() => {
cancelAnimationFrame(animRef);
window.removeEventListener("resize", init);
if (!isTouchDevice) {
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("mouseleave", handleMouseLeave);
}
};
}, []);
});
});
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className="absolute inset-0 w-full h-full"
class="absolute inset-0 w-full h-full"
style={{ display: "block" }}
/>
);

View file

@ -1,7 +1,7 @@
export default function PreviewBanner() {
return (
<div className="fixed top-0 left-0 z-[60] pointer-events-none">
<div className="absolute w-[320px] bg-amber text-deep-space text-center text-sm font-bold font-mono uppercase tracking-widest py-2.5 rotate-[-45deg] top-10 -left-20 shadow-lg">
<div class="fixed top-0 left-0 z-[60] pointer-events-none">
<div class="absolute w-[320px] bg-amber text-deep-space text-center text-sm font-bold font-mono uppercase tracking-widest py-2.5 rotate-[-45deg] top-10 -left-20 shadow-lg">
Preview Build
</div>
</div>

View file

@ -1,8 +1,6 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { A, useNavigate } from "@solidjs/router";
import { ArrowLeft, ArrowRight } from "lucide-solid";
import { For } from "solid-js";
import { useScrollReveal } from "../hooks/useScrollReveal";
import { projectLayout } from "../data/projects";
@ -43,185 +41,160 @@ interface ProjectPageLayoutProps {
nextProject: AdjacentProject;
}
export default function ProjectPageLayout({
title,
subtitle,
tags,
year,
duration,
status,
description,
image,
imageAlt,
features,
stats,
gallery,
prevProject,
nextProject,
}: ProjectPageLayoutProps) {
const router = useRouter();
export default function ProjectPageLayout(props: ProjectPageLayoutProps) {
const navigate = useNavigate();
const heroRef = useScrollReveal<HTMLDivElement>({ stagger: 0.1, start: "top 90%" });
const descRef = useScrollReveal<HTMLDivElement>({ stagger: 0.15 });
const techRef = useScrollReveal<HTMLDivElement>({ stagger: 0.15 });
const galleryRef = useScrollReveal<HTMLDivElement>({ stagger: 0.1 });
return (
<main className="min-h-screen bg-deep-space">
{/* Hero */}
<section className="pt-32 pb-20 px-[5vw] bg-charcoal">
<div ref={heroRef} className="max-w-[1200px] mx-auto space-y-8">
<main class="min-h-screen bg-deep-space">
<section class="pt-32 pb-20 px-[5vw] bg-charcoal">
<div ref={heroRef} class="max-w-[1200px] mx-auto space-y-8">
<button
onClick={() => router.back()}
className="inline-flex items-center gap-2 text-link text-amber hover:opacity-80 transition-opacity cursor-pointer"
onClick={() => navigate(-1)}
class="inline-flex items-center gap-2 text-link text-amber hover:opacity-80 transition-opacity cursor-pointer"
>
<ArrowLeft size={16} />
<span>{projectLayout.backLabel}</span>
</button>
<div className="space-y-4">
<div class="space-y-4">
<h1
className="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ fontSize: "clamp(2.5rem, 8vw, 6rem)" }}
class="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ "font-size": "clamp(2.5rem, 8vw, 6rem)" }}
>
{title}
{props.title}
</h1>
<p className="font-display font-medium text-amber text-xl md:text-2xl">
{subtitle}
<p class="font-display font-medium text-amber text-xl md:text-2xl">
{props.subtitle}
</p>
</div>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
<div class="flex flex-wrap gap-2">
<For each={props.tags}>
{(tag) => <span class="chip">{tag}</span>}
</For>
</div>
<div className="font-mono text-text-secondary text-sm flex flex-wrap gap-x-6 gap-y-2">
<span>{projectLayout.yearLabel}: {year}</span>
<div class="font-mono text-text-secondary text-sm flex flex-wrap gap-x-6 gap-y-2">
<span>{projectLayout.yearLabel}: {props.year}</span>
<span>|</span>
<span>{projectLayout.durationLabel}: {duration}</span>
<span>{projectLayout.durationLabel}: {props.duration}</span>
<span>|</span>
<span>{projectLayout.statusLabel}: {status}</span>
<span>{projectLayout.statusLabel}: {props.status}</span>
</div>
</div>
</section>
{/* Project Description */}
<section className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-16 items-start">
<div ref={descRef} className="space-y-8">
<h2 className="font-display font-semibold text-text-primary text-3xl md:text-4xl tracking-tight">
<section class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-16 items-start">
<div ref={descRef} class="space-y-8">
<h2 class="font-display font-semibold text-text-primary text-3xl md:text-4xl tracking-tight">
{projectLayout.descriptionTitle}
</h2>
<div className="space-y-6 text-text-secondary leading-relaxed">
{description.map((p, i) => (
<p key={i}>{p}</p>
))}
<div class="space-y-6 text-text-secondary leading-relaxed">
<For each={props.description}>
{(p) => <p>{p}</p>}
</For>
</div>
</div>
<div className="border border-warm-grey overflow-hidden">
<div class="border border-warm-grey overflow-hidden">
<img
src={image}
alt={imageAlt}
className="w-full h-auto"
src={props.image}
alt={props.imageAlt}
class="w-full h-auto"
loading="lazy"
/>
</div>
</div>
</section>
{/* Technical Implementation */}
<section className="py-20 md:py-32 px-[5vw] bg-charcoal">
<div ref={techRef} className="max-w-[800px] mx-auto space-y-12">
<h2 className="font-display font-semibold text-text-primary text-3xl md:text-4xl tracking-tight">
<section class="py-20 md:py-32 px-[5vw] bg-charcoal">
<div ref={techRef} class="max-w-[800px] mx-auto space-y-12">
<h2 class="font-display font-semibold text-text-primary text-3xl md:text-4xl tracking-tight">
{projectLayout.techTitle}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.map((feature) => (
<div
key={feature.title}
className="border border-warm-grey p-6 space-y-4"
>
<h3 className="font-display font-medium text-text-primary text-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<For each={props.features}>
{(feature) => (
<div class="border border-warm-grey p-6 space-y-4">
<h3 class="font-display font-medium text-text-primary text-lg">
{feature.title}
</h3>
<p className="text-text-secondary text-sm leading-relaxed">
<p class="text-text-secondary text-sm leading-relaxed">
{feature.description}
</p>
</div>
))}
)}
</For>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 pt-8 border-t border-warm-grey">
{stats.map((stat) => (
<div key={stat.label} className="text-center space-y-2">
<p className="font-display font-bold text-text-primary text-2xl md:text-3xl text-amber">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 pt-8 border-t border-warm-grey">
<For each={props.stats}>
{(stat) => (
<div class="text-center space-y-2">
<p class="font-display font-bold text-text-primary text-2xl md:text-3xl text-amber">
{stat.value}
</p>
<p className="text-text-secondary text-xs font-mono uppercase tracking-wider">
<p class="text-text-secondary text-xs font-mono uppercase tracking-wider">
{stat.label}
</p>
</div>
))}
)}
</For>
</div>
</div>
</section>
{/* Gallery */}
<section className="py-20 md:py-32 px-[5vw]">
<div ref={galleryRef} className="max-w-[1200px] mx-auto space-y-12">
<h2 className="font-display font-semibold text-text-primary text-3xl md:text-4xl tracking-tight">
<section class="py-20 md:py-32 px-[5vw]">
<div ref={galleryRef} class="max-w-[1200px] mx-auto space-y-12">
<h2 class="font-display font-semibold text-text-primary text-3xl md:text-4xl tracking-tight">
{projectLayout.galleryTitle}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{gallery.map((item) => (
<div
key={item.src}
className="border border-warm-grey overflow-hidden"
>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<For each={props.gallery}>
{(item) => (
<div class="border border-warm-grey overflow-hidden">
<img
src={item.src}
alt={item.alt}
className="w-full h-48 object-cover hover:scale-105 transition-transform duration-500"
class="w-full h-48 object-cover hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
</div>
))}
)}
</For>
</div>
</div>
</section>
{/* Prev / Next Navigation */}
<section className="py-20 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<Link
href={prevProject.link}
replace
className="inline-flex items-center gap-3 px-6 py-3 border border-warm-grey text-text-primary hover:border-amber hover:text-amber transition-colors"
<section class="py-20 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<A
href={props.prevProject.link}
class="inline-flex items-center gap-3 px-6 py-3 border border-warm-grey text-text-primary hover:border-amber hover:text-amber transition-colors"
>
<ArrowLeft size={16} />
<div className="text-left">
<p className="text-text-secondary text-xs mb-0.5">{projectLayout.prevProjectLabel}</p>
<p className="font-display font-medium text-sm">{prevProject.title}</p>
<div class="text-left">
<p class="text-text-secondary text-xs mb-0.5">{projectLayout.prevProjectLabel}</p>
<p class="font-display font-medium text-sm">{props.prevProject.title}</p>
</div>
</Link>
</A>
<Link
href={nextProject.link}
replace
className="inline-flex items-center gap-3 px-6 py-3 border border-warm-grey text-text-primary hover:border-amber hover:text-amber transition-colors"
<A
href={props.nextProject.link}
class="inline-flex items-center gap-3 px-6 py-3 border border-warm-grey text-text-primary hover:border-amber hover:text-amber transition-colors"
>
<div className="text-right">
<p className="text-text-secondary text-xs mb-0.5">{projectLayout.nextProjectLabel}</p>
<p className="font-display font-medium text-sm">{nextProject.title}</p>
<div class="text-right">
<p class="text-text-secondary text-xs mb-0.5">{projectLayout.nextProjectLabel}</p>
<p class="font-display font-medium text-sm">{props.nextProject.title}</p>
</div>
<ArrowRight size={16} />
</Link>
</A>
</div>
</section>
</main>

View file

@ -23,7 +23,7 @@ export const socialLinks = [
export const footerContent = {
copyright: (year: number, name: string) =>
`\u00A9 ${year} ${name}. All rights reserved.`,
builtWith: "Built with Next.js, React & Tailwind CSS",
builtWith: "Built with SolidJS & Tailwind CSS",
};
export const homeMetadata = {

4
src/global.d.ts vendored
View file

@ -1,4 +0,0 @@
declare module '*.css' {
const content: { [className: string]: string };
export default content;
}

View file

@ -1,4 +1,4 @@
import { useLayoutEffect, useRef } from "react";
import { onMount } from "solid-js";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@ -17,10 +17,10 @@ interface ScrollRevealOptions {
export function useScrollReveal<T extends HTMLElement>(
options: ScrollRevealOptions = {}
) {
const ref = useRef<T>(null);
let ref: T | undefined;
useLayoutEffect(() => {
const el = ref.current;
onMount(() => {
const el = ref;
if (!el) return;
const {
@ -58,7 +58,9 @@ export function useScrollReveal<T extends HTMLElement>(
if (st.trigger === el) st.kill();
});
};
}, []);
});
return ref;
return (el: T) => {
ref = el;
};
}

20
src/index.tsx Normal file
View file

@ -0,0 +1,20 @@
/* @refresh reload */
import { render } from "solid-js/web";
import { Router } from "@solidjs/router";
import App from "./App";
import "./globals.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element not found");
}
render(
() => (
<Router>
<App />
</Router>
),
root
);

View file

@ -5,27 +5,22 @@ import {
Mail,
Bike,
Camera,
} from "lucide-react";
import type { ComponentType } from "react";
} from "lucide-solid";
import type { Component } from "solid-js";
function GithubIcon({
size = 18,
className = "",
}: {
size?: number;
className?: string;
}) {
function GithubIcon(props: { size?: number; class?: string }) {
const size = () => props.size ?? 18;
return (
<svg
width={size}
height={size}
width={size()}
height={size()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={props.class}
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
@ -33,24 +28,19 @@ function GithubIcon({
);
}
function LinkedInIcon({
size = 18,
className = "",
}: {
size?: number;
className?: string;
}) {
function LinkedInIcon(props: { size?: number; class?: string }) {
const size = () => props.size ?? 18;
return (
<svg
width={size}
height={size}
width={size()}
height={size()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={props.class}
>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect width="4" height="12" x="2" y="9" />
@ -61,7 +51,7 @@ function LinkedInIcon({
const iconMap: Record<
string,
ComponentType<{ size?: number; className?: string }>
Component<{ size?: number; class?: string }>
> = {
Crosshair,
Layers,
@ -73,19 +63,15 @@ const iconMap: Record<
LinkedInIcon,
};
export function Icon({
name,
size = 18,
className = "",
}: {
export function Icon(props: {
name: string;
size?: number;
className?: string;
class?: string;
}) {
const Component = iconMap[name];
const Component = iconMap[props.name];
if (!Component) {
console.warn(`Icon "${name}" not found`);
console.warn(`Icon "${props.name}" not found`);
return null;
}
return <Component size={size} className={className} />;
return <Component size={props.size} class={props.class} />;
}

13
src/lib/meta.ts Normal file
View file

@ -0,0 +1,13 @@
import { onMount } from "solid-js";
export function usePageMeta(title: string, description?: string) {
onMount(() => {
document.title = title.includes("—") ? title : `${title} — Mike Karl`;
if (description) {
const meta = document.querySelector('meta[name="description"]');
if (meta) {
meta.setAttribute("content", description);
}
}
});
}

127
src/pages/AboutPage.tsx Normal file
View file

@ -0,0 +1,127 @@
import { A } from "@solidjs/router";
import { For } from "solid-js";
import { ArrowRight } from "lucide-solid";
import { usePageMeta } from "@/lib/meta";
import { values, aboutPage } from "@/data/about";
import { site } from "@/data/site";
import { Icon } from "@/lib/icons";
export default function AboutPage() {
usePageMeta(aboutPage.metadata.title as string, aboutPage.metadata.description as string);
return (
<main class="min-h-screen bg-deep-space">
<section class="pt-32 pb-20 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto space-y-6">
<p class="section-label">{aboutPage.label}</p>
<h1
class="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ "font-size": "clamp(2.5rem, 8vw, 5rem)" }}
>
{aboutPage.pageTitle}
</h1>
<p class="text-text-secondary max-w-2xl text-lg leading-relaxed">
{aboutPage.pageSubtitle}
</p>
</div>
</section>
<section class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-16">
<div class="space-y-8">
<h2 class="font-display font-semibold text-text-primary text-2xl tracking-tight">
{aboutPage.philosophyTitle}
</h2>
<p class="text-text-secondary leading-relaxed">
{aboutPage.philosophyText[0]}
</p>
<p class="text-text-secondary leading-relaxed">
{aboutPage.philosophyText[1]}
</p>
<h2 class="font-display font-semibold text-text-primary text-2xl tracking-tight pt-8">
{aboutPage.backgroundTitle}
</h2>
<p class="text-text-secondary leading-relaxed">
{aboutPage.backgroundText[0]}
</p>
<p class="text-text-secondary leading-relaxed">
{aboutPage.backgroundText[1]}
</p>
<div class="pt-4">
<A
href="/hobbies"
class="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group"
>
<span class="text-link">{aboutPage.linkText}</span>
<ArrowRight
size={18}
class="group-hover:translate-x-1 transition-transform"
/>
</A>
</div>
</div>
<div class="space-y-8">
<div class="border border-warm-grey p-6 space-y-4">
<h3 class="font-display font-medium text-text-primary">
{aboutPage.sidebarFocusTitle}
</h3>
<ul class="space-y-2 text-text-secondary text-sm">
<For each={aboutPage.sidebarFocusItems}>
{(item) => <li>{item}</li>}
</For>
</ul>
</div>
<div class="border border-warm-grey p-6 space-y-4">
<h3 class="font-display font-medium text-text-primary">
{aboutPage.sidebarLocationTitle}
</h3>
<p class="text-text-secondary text-sm">{site.location}</p>
</div>
<div class="border border-warm-grey p-6 space-y-4">
<h3 class="font-display font-medium text-text-primary">
{aboutPage.sidebarHobbiesTitle}
</h3>
<ul class="space-y-2 text-text-secondary text-sm">
<For each={aboutPage.sidebarHobbiesItems}>
{(item) => <li>{item}</li>}
</For>
</ul>
<A href="/hobbies" class="text-link text-amber text-sm">
{aboutPage.sidebarHobbiesLink}
</A>
</div>
</div>
</div>
</section>
<section class="py-20 md:py-32 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto">
<p class="section-label mb-12">{aboutPage.valuesTitle}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
<For each={values}>
{(value) => (
<div class="space-y-6">
<div class="w-10 h-10 flex items-center justify-center border border-warm-grey">
<Icon name={value.icon} size={20} class="text-amber" />
</div>
<h3 class="font-display font-semibold text-text-primary text-2xl tracking-tight">
{value.title}
</h3>
<p class="text-text-secondary leading-relaxed">{value.text}</p>
<p class="font-display text-text-primary italic border-l-2 border-amber pl-4">
&ldquo;{value.quote}&rdquo;
</p>
</div>
)}
</For>
</div>
</div>
</section>
</main>
);
}

92
src/pages/ContactPage.tsx Normal file
View file

@ -0,0 +1,92 @@
import { usePageMeta } from "@/lib/meta";
import { site } from "@/data/site";
import { contactContent, contactPage } from "@/data/contact";
import { Mail } from "lucide-solid";
export default function ContactPage() {
usePageMeta(contactPage.metadata.title as string, contactPage.metadata.description as string);
return (
<main class="min-h-screen bg-deep-space">
<section class="pt-32 pb-20 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto space-y-6">
<p class="section-label">{contactPage.label}</p>
<h1
class="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ "font-size": "clamp(2.5rem, 8vw, 5rem)" }}
>
{contactContent.heading}
</h1>
<p class="text-text-secondary max-w-2xl text-lg leading-relaxed">
{contactPage.pageSubtitle}
</p>
</div>
</section>
<section class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-16">
<div class="space-y-8">
<h2 class="font-display font-semibold text-text-primary text-2xl tracking-tight">
{contactPage.contentTitle}
</h2>
<p class="text-text-secondary leading-relaxed">{contactContent.text}</p>
<p class="text-text-secondary leading-relaxed">
{contactPage.contentPlaceholder1}
</p>
<p class="text-text-secondary leading-relaxed">
{contactPage.contentPlaceholder2}
</p>
<div class="pt-4">
<a
href={`mailto:${site.email}`}
class="inline-flex items-center gap-3 px-6 py-3 bg-amber text-deep-space font-display font-medium hover:bg-amber-hover transition-colors"
>
<Mail size={18} />
{contactContent.cta}
</a>
</div>
</div>
<div class="space-y-8">
<div class="border border-warm-grey p-6 space-y-4">
<h3 class="font-display font-medium text-text-primary">
{contactPage.sidebarEmailTitle}
</h3>
<a
href={`mailto:${site.email}`}
class="text-link text-amber font-mono text-sm"
>
{site.email}
</a>
</div>
<div class="border border-warm-grey p-6 space-y-4">
<h3 class="font-display font-medium text-text-primary">
{contactPage.sidebarSocialTitle}
</h3>
<div class="space-y-2 text-sm">
<a
href={site.github}
target="_blank"
rel="noopener noreferrer"
class="block text-text-secondary hover:text-amber transition-colors"
>
{contactPage.sidebarSocialGithub}
</a>
<a
href={site.linkedin}
target="_blank"
rel="noopener noreferrer"
class="block text-text-secondary hover:text-amber transition-colors"
>
{contactPage.sidebarSocialLinkedin}
</a>
</div>
</div>
</div>
</div>
</section>
</main>
);
}

71
src/pages/HobbiesPage.tsx Normal file
View file

@ -0,0 +1,71 @@
import { For } from "solid-js";
import { usePageMeta } from "@/lib/meta";
import { hobbies, hobbiesPage } from "@/data/hobbies";
import { Icon } from "@/lib/icons";
export default function HobbiesPage() {
usePageMeta(hobbiesPage.metadata.title as string, hobbiesPage.metadata.description as string);
return (
<main class="min-h-screen bg-deep-space">
<section class="pt-32 pb-20 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto space-y-6">
<p class="section-label">{hobbiesPage.label}</p>
<h1
class="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ "font-size": "clamp(2.5rem, 8vw, 5rem)" }}
>
{hobbiesPage.pageTitle}
</h1>
<p class="text-text-secondary max-w-2xl text-lg leading-relaxed">
{hobbiesPage.pageSubtitle}
</p>
</div>
</section>
<section class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto space-y-24">
<For each={hobbies}>
{(hobby, index) => {
const isEven = () => index() % 2 === 0;
return (
<div
class={`grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-12 items-start ${
isEven() ? "" : "lg:grid-cols-[2fr_1fr]"
}`}
>
<div class={`space-y-6 ${isEven() ? "" : "lg:order-2"}`}>
<div class="w-12 h-12 flex items-center justify-center border border-warm-grey">
<Icon name={hobby.icon} size={24} class="text-amber" />
</div>
<h2 class="font-display font-semibold text-text-primary text-3xl tracking-tight">
{hobby.title}
</h2>
</div>
<div class={`space-y-6 ${isEven() ? "" : "lg:order-1"}`}>
<For each={hobby.description}>
{(paragraph) => (
<p class="text-text-secondary leading-relaxed">{paragraph}</p>
)}
</For>
</div>
</div>
);
}}
</For>
</div>
</section>
<section class="py-20 px-[5vw] bg-charcoal">
<div class="max-w-[800px] mx-auto text-center space-y-6">
<p class="font-display text-text-primary text-2xl md:text-3xl italic leading-relaxed">
&ldquo;{hobbiesPage.quote}&rdquo;
</p>
<p class="text-text-secondary text-sm font-mono">
{hobbiesPage.quoteAuthor}
</p>
</div>
</section>
</main>
);
}

View file

@ -1,4 +1,4 @@
import type { Metadata } from "next";
import { usePageMeta } from "@/lib/meta";
import { homeMetadata } from "@/data/site";
import HeroSection from "@/sections/HeroSection";
import AboutSection from "@/sections/AboutSection";
@ -7,9 +7,9 @@ import ProjectsSection from "@/sections/ProjectsSection";
import TimelineSection from "@/sections/TimelineSection";
import ContactSection from "@/sections/ContactSection";
export const metadata: Metadata = homeMetadata;
export default function HomePage() {
usePageMeta(homeMetadata.title, homeMetadata.description);
return (
<main>
<HeroSection />

View file

@ -0,0 +1,73 @@
import { A } from "@solidjs/router";
import { For } from "solid-js";
import { ArrowRight } from "lucide-solid";
import { usePageMeta } from "@/lib/meta";
import { projects, projectsPage } from "@/data/projects";
export default function ProjectsPage() {
usePageMeta(projectsPage.metadata.title as string, projectsPage.metadata.description as string);
return (
<main class="min-h-screen bg-deep-space">
<section class="pt-32 pb-20 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto space-y-6">
<p class="section-label">{projectsPage.pageLabel}</p>
<h1
class="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ "font-size": "clamp(2.5rem, 8vw, 5rem)" }}
>
{projectsPage.pageTitle}
</h1>
<p class="text-text-secondary max-w-2xl text-lg leading-relaxed">
{projectsPage.subtitle}
</p>
</div>
</section>
<section class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto space-y-16">
<For each={projects}>
{(project) => (
<div class="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-12 items-start border-t border-warm-grey pt-12">
<div class="border border-warm-grey overflow-hidden group/image">
<img
src={project.image}
alt={project.title}
class="w-full h-auto group-hover/image:scale-105 transition-transform duration-700 ease-out"
loading="lazy"
/>
</div>
<div class="space-y-6">
<div class="flex flex-wrap gap-2">
<For each={project.tags}>
{(tag) => <span class="chip">{tag}</span>}
</For>
</div>
<h2 class="font-display font-semibold text-text-primary text-3xl tracking-tight">
{project.title}
</h2>
<p class="text-text-secondary leading-relaxed">
{project.description}
</p>
<p class="text-text-secondary leading-relaxed">
{projectsPage.detailText}
</p>
<A
href={project.link}
class="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group"
>
<span class="text-link">{projectsPage.linkText}</span>
<ArrowRight
size={18}
class="group-hover:translate-x-1 transition-transform"
/>
</A>
</div>
</div>
)}
</For>
</div>
</section>
</main>
);
}

View file

@ -0,0 +1,54 @@
import { For } from "solid-js";
import { usePageMeta } from "@/lib/meta";
import { timeline, timelinePage } from "@/data/timeline";
export default function TimelinePage() {
usePageMeta(timelinePage.metadata.title as string, timelinePage.metadata.description as string);
return (
<main class="min-h-screen bg-deep-space">
<section class="pt-32 pb-20 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto space-y-6">
<p class="section-label">{timelinePage.label}</p>
<h1
class="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ "font-size": "clamp(2.5rem, 8vw, 5rem)" }}
>
{timelinePage.pageTitle}
</h1>
<p class="text-text-secondary max-w-2xl text-lg leading-relaxed">
{timelinePage.subtitle}
</p>
</div>
</section>
<section class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto space-y-16">
<For each={timeline}>
{(item) => (
<div class="grid grid-cols-1 md:grid-cols-[120px_1fr] gap-8 border-t border-warm-grey pt-12">
<span class="font-mono text-amber text-xl">{item.year}</span>
<div class="space-y-4">
<h2 class="font-display font-semibold text-text-primary text-2xl tracking-tight">
{item.title}
</h2>
<p class="text-text-secondary leading-relaxed">
{item.description}
</p>
<p class="text-text-secondary leading-relaxed">
{timelinePage.detailText}
</p>
<div class="flex flex-wrap gap-2 pt-2">
<For each={item.tags}>
{(tag) => <span class="chip">{tag}</span>}
</For>
</div>
</div>
</div>
)}
</For>
</div>
</section>
</main>
);
}

View file

@ -1,10 +1,10 @@
import type { Metadata } from "next";
import { usePageMeta } from "@/lib/meta";
import ProjectPageLayout from "@/components/ProjectPageLayout";
import { emulatorProject } from "@/data/projects";
export const metadata: Metadata = emulatorProject.metadata;
export default function EmulatorPage() {
usePageMeta(emulatorProject.metadata.title as string, emulatorProject.metadata.description as string);
export default function ProjectEmulatorPage() {
return (
<ProjectPageLayout
title={emulatorProject.title}

View file

@ -1,10 +1,10 @@
import type { Metadata } from "next";
import { usePageMeta } from "@/lib/meta";
import ProjectPageLayout from "@/components/ProjectPageLayout";
import { raycasterProject } from "@/data/projects";
export const metadata: Metadata = raycasterProject.metadata;
export default function RaycasterPage() {
usePageMeta(raycasterProject.metadata.title as string, raycasterProject.metadata.description as string);
export default function ProjectRaycasterPage() {
return (
<ProjectPageLayout
title={raycasterProject.title}

View file

@ -1,10 +1,10 @@
import type { Metadata } from "next";
import { usePageMeta } from "@/lib/meta";
import ProjectPageLayout from "@/components/ProjectPageLayout";
import { raytracerProject } from "@/data/projects";
export const metadata: Metadata = raytracerProject.metadata;
export default function RaytracerPage() {
usePageMeta(raytracerProject.metadata.title as string, raytracerProject.metadata.description as string);
export default function ProjectRaytracerPage() {
return (
<ProjectPageLayout
title={raytracerProject.title}

View file

@ -1,36 +1,33 @@
"use client";
import Link from "next/link";
import { A } from "@solidjs/router";
import { For } from "solid-js";
import { ArrowRight } from "lucide-solid";
import { useScrollReveal } from "../hooks/useScrollReveal";
import { aboutContent, aboutPage } from "../data/about";
import { ArrowRight } from "lucide-react";
export default function AboutSection() {
const revealRef = useScrollReveal<HTMLDivElement>({ stagger: 0.15 });
return (
<section id="about" className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto">
<div ref={revealRef} className="space-y-8">
<h2 className="section-label">{aboutPage.label}</h2>
<div className="max-w-3xl space-y-6 text-text-secondary leading-relaxed">
{aboutContent.paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
<section id="about" class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto">
<div ref={revealRef} class="space-y-8">
<h2 class="section-label">{aboutPage.label}</h2>
<div class="max-w-3xl space-y-6 text-text-secondary leading-relaxed">
<For each={aboutContent.paragraphs}>
{(p) => <p>{p}</p>}
</For>
</div>
<div>
<Link
<A
href="/hobbies"
className="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group"
class="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group"
>
<span className="text-link">
{aboutPage.linkText}
</span>
<span class="text-link">{aboutPage.linkText}</span>
<ArrowRight
size={18}
className="group-hover:translate-x-1 transition-transform"
class="group-hover:translate-x-1 transition-transform"
/>
</Link>
</A>
</div>
</div>
</div>

View file

@ -1,32 +1,30 @@
"use client";
import { useScrollReveal } from "../hooks/useScrollReveal";
import { site } from "../data/site";
import { contactContent, contactPage } from "../data/contact";
import { Mail } from "lucide-react";
import { Mail } from "lucide-solid";
export default function ContactSection() {
const revealRef = useScrollReveal<HTMLDivElement>({ stagger: 0.15 });
return (
<section id="contact" className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto">
<div ref={revealRef} className="max-w-2xl space-y-8">
<h2 className="section-label">{contactPage.label}</h2>
<p className="text-text-secondary leading-relaxed text-lg">
<section id="contact" class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto">
<div ref={revealRef} class="max-w-2xl space-y-8">
<h2 class="section-label">{contactPage.label}</h2>
<p class="text-text-secondary leading-relaxed text-lg">
{contactContent.text}
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-6">
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-6">
<a
href={`mailto:${site.email}`}
className="inline-flex items-center gap-3 px-6 py-3 bg-amber text-deep-space font-display font-medium hover:bg-amber-hover transition-colors"
class="inline-flex items-center gap-3 px-6 py-3 bg-amber text-deep-space font-display font-medium hover:bg-amber-hover transition-colors"
>
<Mail size={18} />
{contactContent.cta}
</a>
<a
href={`mailto:${site.email}`}
className="text-link text-amber font-mono text-sm"
class="text-link text-amber font-mono text-sm"
>
{site.email}
</a>

View file

@ -1,6 +1,4 @@
"use client";
import { useEffect, useRef } from "react";
import { onMount, For } from "solid-js";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ParticleGrid from "../components/ParticleGrid";
@ -11,14 +9,14 @@ const subtitles = heroContent.subtitle;
gsap.registerPlugin(ScrollTrigger);
export default function HeroSection() {
const sectionRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const bgRef = useRef<HTMLDivElement>(null);
let sectionRef: HTMLElement | undefined;
let contentRef: HTMLDivElement | undefined;
let bgRef: HTMLDivElement | undefined;
useEffect(() => {
const section = sectionRef.current;
const content = contentRef.current;
const bg = bgRef.current;
onMount(() => {
const section = sectionRef;
const content = contentRef;
const bg = bgRef;
if (!section || !content || !bg) return;
const ctx = gsap.context(() => {
@ -46,56 +44,49 @@ export default function HeroSection() {
}, section);
return () => ctx.revert();
}, []);
});
return (
<section
ref={sectionRef}
id="hero"
className="relative min-h-[100dvh] flex items-center justify-center overflow-hidden"
class="relative min-h-[100dvh] flex items-center justify-center overflow-hidden"
>
{/* Particle grid background */}
<div ref={bgRef} className="absolute inset-0 z-0">
<div ref={bgRef} class="absolute inset-0 z-0">
<ParticleGrid />
</div>
{/* Amber radial gradient from top */}
<div
className="absolute inset-0 z-[1] pointer-events-none"
class="absolute inset-0 z-[1] pointer-events-none"
style={{
background:
"radial-gradient(ellipse at 50% 0%, rgba(232,97,1,0.08) 0%, transparent 60%)",
}}
/>
{/* Bottom fade to deep space */}
<div
className="absolute inset-0 z-[1] pointer-events-none"
class="absolute inset-0 z-[1] pointer-events-none"
style={{
background:
"linear-gradient(to bottom, transparent 50%, #0F0E0C 100%)",
}}
/>
{/* Content */}
<div
ref={contentRef}
className="relative z-10 text-center px-6"
>
<div className="font-mono text-amber text-sm md:text-base tracking-[0.1em] uppercase mb-6 overflow-hidden">
<div ref={contentRef} class="relative z-10 text-center px-6">
<div class="font-mono text-amber text-sm md:text-base tracking-[0.1em] uppercase mb-6 overflow-hidden">
<SubtitleFlipper />
</div>
<h1
className="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ fontSize: "clamp(4rem, 12vw, 10rem)" }}
class="font-display font-bold text-text-primary leading-[0.9] tracking-tight"
style={{ "font-size": "clamp(4rem, 12vw, 10rem)" }}
>
{heroContent.nameFirst}
<br />
{heroContent.nameLast}
</h1>
<div className="flex items-center justify-center gap-4 mt-6">
<div className="w-[60px] h-px bg-amber" />
<p className="font-mono text-text-secondary text-xs md:text-sm tracking-wider">
<div class="flex items-center justify-center gap-4 mt-6">
<div class="w-[60px] h-px bg-amber" />
<p class="font-mono text-text-secondary text-xs md:text-sm tracking-wider">
{site.location}
</p>
</div>
@ -105,10 +96,10 @@ export default function HeroSection() {
}
function SubtitleFlipper() {
const containerRef = useRef<HTMLDivElement>(null);
let containerRef: HTMLDivElement | undefined;
useEffect(() => {
const container = containerRef.current;
onMount(() => {
const container = containerRef;
if (!container) return;
const items = container.querySelectorAll<HTMLSpanElement>(".subtitle-item");
@ -148,19 +139,20 @@ function SubtitleFlipper() {
clearTimeout(timeoutId);
gsap.killTweensOf(items);
};
}, []);
});
return (
<div ref={containerRef} className="grid place-items-center">
{subtitles.map((text, i) => (
<div ref={containerRef} class="grid place-items-center">
<For each={subtitles}>
{(text, i) => (
<span
key={text}
className="subtitle-item col-start-1 row-start-1 whitespace-nowrap"
aria-hidden={i !== 0}
class="subtitle-item col-start-1 row-start-1 whitespace-nowrap"
aria-hidden={i() !== 0}
>
{text}
</span>
))}
)}
</For>
</div>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { A } from "@solidjs/router";
import { For } from "solid-js";
import { ArrowRight } from "lucide-solid";
import { useScrollReveal } from "../hooks/useScrollReveal";
import { hobbies, hobbiesPage } from "../data/hobbies";
import { Icon } from "@/lib/icons";
@ -10,41 +9,40 @@ export default function HobbiesSection() {
const revealRef = useScrollReveal<HTMLDivElement>({ stagger: 0.2 });
return (
<section id="hobbies" className="py-20 md:py-32 px-[5vw]">
<div className="max-w-[1200px] mx-auto">
<div className="space-y-4 mb-12">
<h2 className="section-label">{hobbiesPage.label}</h2>
<p className="text-text-secondary max-w-xl">
<section id="hobbies" class="py-20 md:py-32 px-[5vw]">
<div class="max-w-[1200px] mx-auto">
<div class="space-y-4 mb-12">
<h2 class="section-label">{hobbiesPage.label}</h2>
<p class="text-text-secondary max-w-xl">
{hobbiesPage.sectionSubtitle}
</p>
</div>
<div ref={revealRef} className="grid grid-cols-1 md:grid-cols-2 gap-8">
{hobbies.map((hobby) => (
<div
key={hobby.id}
className="border border-warm-grey p-8 space-y-6 group hover:border-amber/30 transition-colors"
>
<div className="w-10 h-10 flex items-center justify-center border border-warm-grey">
<Icon name={hobby.icon} size={20} className="text-amber" />
<div ref={revealRef} class="grid grid-cols-1 md:grid-cols-2 gap-8">
<For each={hobbies}>
{(hobby) => (
<div class="border border-warm-grey p-8 space-y-6 group hover:border-amber/30 transition-colors">
<div class="w-10 h-10 flex items-center justify-center border border-warm-grey">
<Icon name={hobby.icon} size={20} class="text-amber" />
</div>
<h3 className="font-display font-semibold text-text-primary text-2xl tracking-tight">
<h3 class="font-display font-semibold text-text-primary text-2xl tracking-tight">
{hobby.title}
</h3>
<p className="text-text-secondary leading-relaxed">
<p class="text-text-secondary leading-relaxed">
{hobby.shortDescription}
</p>
<Link
<A
href="/hobbies"
className="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group/link"
class="inline-flex items-center gap-2 text-amber hover:text-amber-hover transition-colors group/link"
>
<span className="text-link">{hobbiesPage.linkText}</span>
<span class="text-link">{hobbiesPage.linkText}</span>
<ArrowRight
size={18}
className="group-hover/link:translate-x-1 transition-transform"
class="group-hover/link:translate-x-1 transition-transform"
/>
</Link>
</A>
</div>
))}
)}
</For>
</div>
</div>
</section>

View file

@ -1,62 +1,60 @@
"use client";
import Link from "next/link";
import { A } from "@solidjs/router";
import { For } from "solid-js";
import { ArrowRight } from "lucide-solid";
import { useScrollReveal } from "../hooks/useScrollReveal";
import { projects, projectsPage } from "../data/projects";
import { ArrowRight } from "lucide-react";
export default function ProjectsSection() {
const revealRef = useScrollReveal<HTMLDivElement>({ stagger: 0.15 });
return (
<section id="projects" className="py-20 md:py-32 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto">
<div ref={revealRef} className="space-y-12">
<div className="space-y-4">
<h2 className="section-label">{projectsPage.sectionLabel}</h2>
<p className="text-text-secondary max-w-xl">
<section id="projects" class="py-20 md:py-32 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto">
<div ref={revealRef} class="space-y-12">
<div class="space-y-4">
<h2 class="section-label">{projectsPage.sectionLabel}</h2>
<p class="text-text-secondary max-w-xl">
{projectsPage.subtitle}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<Link
key={project.id}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={projects}>
{(project) => (
<A
href={project.link}
className="group block border border-warm-grey bg-deep-space overflow-hidden hover:border-amber/50 transition-colors duration-300"
class="group block border border-warm-grey bg-deep-space overflow-hidden hover:border-amber/50 transition-colors duration-300"
>
<div className="relative overflow-hidden aspect-[4/3]">
<div class="relative overflow-hidden aspect-[4/3]">
<img
src={project.image}
alt={project.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 ease-out"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 ease-out"
loading="lazy"
/>
<div className="absolute inset-0 bg-deep-space/60 translate-y-full group-hover:translate-y-0 transition-transform duration-500 ease-[cubic-bezier(0.33,1,0.68,1)] flex items-center justify-center">
<span className="inline-flex items-center gap-2 text-amber translate-y-3 group-hover:translate-y-0 transition-transform duration-500 ease-[cubic-bezier(0.33,1,0.68,1)] delay-100">
<div class="absolute inset-0 bg-deep-space/60 translate-y-full group-hover:translate-y-0 transition-transform duration-500 ease-[cubic-bezier(0.33,1,0.68,1)] flex items-center justify-center">
<span class="inline-flex items-center gap-2 text-amber translate-y-3 group-hover:translate-y-0 transition-transform duration-500 ease-[cubic-bezier(0.33,1,0.68,1)] delay-100">
{projectsPage.caseStudyLabel}
<ArrowRight size={16} className="group-hover:translate-x-1 transition-transform duration-300" />
<ArrowRight size={16} class="group-hover:translate-x-1 transition-transform duration-300" />
</span>
</div>
</div>
<div className="p-6 space-y-4">
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
<div class="p-6 space-y-4">
<div class="flex flex-wrap gap-2">
<For each={project.tags}>
{(tag) => <span class="chip">{tag}</span>}
</For>
</div>
<h3 className="font-display font-semibold text-text-primary text-xl tracking-tight">
<h3 class="font-display font-semibold text-text-primary text-xl tracking-tight">
{project.title}
</h3>
<p className="text-text-secondary text-sm leading-relaxed">
<p class="text-text-secondary text-sm leading-relaxed">
{project.description}
</p>
</div>
</Link>
))}
</A>
)}
</For>
</div>
</div>
</div>

View file

@ -1,5 +1,4 @@
"use client";
import { For } from "solid-js";
import { useScrollReveal } from "../hooks/useScrollReveal";
import { timeline, timelinePage } from "../data/timeline";
@ -7,64 +6,58 @@ export default function TimelineSection() {
const revealRef = useScrollReveal<HTMLDivElement>({ stagger: 0.12 });
return (
<section id="timeline" className="py-20 md:py-32 px-[5vw] bg-charcoal">
<div className="max-w-[1200px] mx-auto">
<div className="space-y-4 mb-16">
<h2 className="section-label">{timelinePage.label}</h2>
<p className="text-text-secondary max-w-xl">
<section id="timeline" class="py-20 md:py-32 px-[5vw] bg-charcoal">
<div class="max-w-[1200px] mx-auto">
<div class="space-y-4 mb-16">
<h2 class="section-label">{timelinePage.label}</h2>
<p class="text-text-secondary max-w-xl">
{timelinePage.subtitle}
</p>
</div>
<div ref={revealRef} className="relative">
{/* Vertical line */}
<div className="absolute left-4 md:left-1/2 top-0 bottom-0 w-px bg-warm-grey md:-translate-x-px" />
<div ref={revealRef} class="relative">
<div class="absolute left-4 md:left-1/2 top-0 bottom-0 w-px bg-warm-grey md:-translate-x-px" />
<div className="space-y-12">
{timeline.map((item) => {
const isLeft = item.side === "left";
<div class="space-y-12">
<For each={timeline}>
{(item) => {
const isLeft = () => item.side === "left";
return (
<div
key={item.year}
className="relative flex items-start md:items-center"
>
{/* Dot */}
<div className="absolute left-4 md:left-1/2 w-3 h-3 bg-amber border-2 border-deep-space -translate-x-1/2 mt-2 md:mt-0 z-10" />
<div class="relative flex items-start md:items-center">
<div class="absolute left-4 md:left-1/2 w-3 h-3 bg-amber border-2 border-deep-space -translate-x-1/2 mt-2 md:mt-0 z-10" />
{/* Content */}
<div
className={`pl-12 md:pl-0 md:w-1/2 ${
isLeft
class={`pl-12 md:pl-0 md:w-1/2 ${
isLeft()
? "md:pr-12 md:text-right"
: "md:pl-12 md:ml-auto"
}`}
>
<div className="space-y-3">
<span className="font-mono text-amber text-sm">
<div class="space-y-3">
<span class="font-mono text-amber text-sm">
{item.year}
</span>
<h3 className="font-display font-semibold text-text-primary text-xl tracking-tight">
<h3 class="font-display font-semibold text-text-primary text-xl tracking-tight">
{item.title}
</h3>
<p className="text-text-secondary text-sm leading-relaxed">
<p class="text-text-secondary text-sm leading-relaxed">
{item.description}
</p>
<div
className={`flex flex-wrap gap-2 ${
isLeft ? "md:justify-end" : ""
class={`flex flex-wrap gap-2 ${
isLeft() ? "md:justify-end" : ""
}`}
>
{item.tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
<For each={item.tags}>
{(tag) => <span class="chip">{tag}</span>}
</For>
</div>
</div>
</div>
</div>
);
})}
}}
</For>
</div>
</div>
</div>

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -1,10 +1,6 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -14,30 +10,15 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
},
"target": "ES2017"
"target": "ES2017",
"types": ["vite/client"]
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"dist/types/**/*.ts",
".next/dev/types/**/*.ts",
"dist/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}

3
vercel.json Normal file
View file

@ -0,0 +1,3 @@
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

15
vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import { resolve } from "path";
export default defineConfig({
plugins: [solid()],
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
build: {
outDir: "dist",
},
});