Compare commits

..

6 commits

Author SHA1 Message Date
GeorgeWebberley 8369d59310 Finalised lab page 2026-02-03 11:30:13 +01:00
GeorgeWebberley ab481053bf Fleshed out the lab page and projects 2026-02-02 18:55:07 +01:00
GeorgeWebberley 1e7a1c8a5f Fixed Image tags 2026-02-02 13:53:49 +01:00
GeorgeWebberley 0d3b304d9a Refactored footer, and added page load animations 2026-02-02 13:40:02 +01:00
GeorgeWebberley 02c12b6e15 Before refactor 2026-02-02 12:18:35 +01:00
GeorgeWebberley 56afa86704 Added mobile image viewer 2026-02-01 20:08:09 +01:00
41 changed files with 1393 additions and 656 deletions

79
app/forge/page.tsx Normal file
View file

@ -0,0 +1,79 @@
"use client";
import { Hammer, History, Target, Code2 } from "lucide-react";
import PageLayout from "@/components/PageLayout";
export default function ForgePage() {
return (
<PageLayout backLink="/" maxWidth="5xl">
<header className="mb-20">
<div className="flex items-center gap-3 mb-4">
<Hammer className="text-orange-500 animate-pulse" size={24} />
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase">
TheForge
</h1>
</div>
<p className="text-neutral-500 max-w-2xl leading-relaxed text-sm font-mono">
<br />
The Forge is where I document my current engineering focus. This space
reflects active builds, experimental prototypes and gives a taste of
my next releases.
</p>
</header>
{/* Main Project Card */}
<section className="bg-neutral-900/40 border border-neutral-800 rounded-2xl p-8 mb-16">
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div>
<h2 className="text-2xl font-bold text-white mb-2 italic">
Project: PixelPals
</h2>
<div className="flex gap-4">
<span className="text-[10px] text-orange-500 font-bold uppercase tracking-[0.2em]">
Build Phase: Alpha 0.1
</span>
<span className="text-[10px] text-neutral-600 font-bold uppercase tracking-[0.2em]">
Engine: Godot / GDScript
</span>
</div>
</div>
<div className="px-4 py-2 bg-orange-500/10 border border-orange-500/20 rounded-full">
<span className="text-[10px] text-orange-500 font-bold uppercase tracking-widest">
Active Focus
</span>
</div>
</div>
<p className="text-neutral-400 text-sm leading-relaxed mb-8">
Exploring procedural generation and state-machine-driven AI behaviors.
The goal is to bridge my experience in systems architecture with
real-time game loops and interactive storytelling.
</p>
{/* Milestones / Changelog */}
<div className="space-y-6">
<h3 className="flex items-center gap-2 text-xs font-bold text-neutral-500 uppercase tracking-widest border-b border-neutral-800 pb-2">
<History size={14} /> Development_Log
</h3>
<ul className="space-y-4">
<li className="flex gap-4">
<span className="text-xs text-neutral-600 font-mono mt-1 shrink-0">
2026-02-02
</span>
<p className="text-xs text-neutral-400">
Implemented dynamic weather patterns using custom shaders.
</p>
</li>
<li className="flex gap-4">
<span className="text-xs text-neutral-600 font-mono mt-1 shrink-0">
2026-01-28
</span>
<p className="text-xs text-neutral-400">
Optimized asset loading pipeline for faster initial scene entry.
</p>
</li>
</ul>
</div>
</section>
</PageLayout>
);
}

139
app/infrastructure/page.tsx Normal file
View file

@ -0,0 +1,139 @@
"use client";
import { motion } from "framer-motion";
import { ShieldCheck, Zap, Cpu, Terminal } from "lucide-react";
import Link from "next/link";
import PageLayout from "@/components/PageLayout";
const CAPABILITIES = [
{
title: "Cloud Orchestration",
icon: <Cpu size={16} />,
skills: ["Kubernetes", "Docker", "Cloud Run", "Multi-Region VPC"],
description:
"Managing containerized application lifecycles and software-defined networks to ensure high availability and regional data sovereignty.",
},
{
title: "Provisioning & IaC",
icon: <Terminal size={16} />,
skills: ["Terraform", "Makefiles", "Shell Scripting", "Versioned State"],
description:
"Defining environment state through version-controlled configurations to ensure reproducible, predictable, and audit-ready cloud resources.",
},
{
title: "Deployment Pipelines",
icon: <Zap size={16} />,
skills: [
"GitHub Actions",
"Woodpecker CI",
"Fastlane",
"Automated Testing",
],
description:
"Architecting CI/CD workflows that bridge the gap between local development and production environments with zero-downtime strategies.",
},
{
title: "Governance & Security",
icon: <ShieldCheck size={16} />,
skills: ["NHS DSPT", "Cyber Essentials", "DPIA", "Secret Management"],
description:
"Hardening infrastructure and delivery pipelines to maintain alignment with UK health data standards and government security frameworks.",
},
];
export default function InfrastructurePage() {
return (
<PageLayout backLink="/" maxWidth="6xl">
{/* Header Section */}
<header className="mb-16 font-mono">
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase">
Infrastructure and Operations
</h1>
</div>
<p className="text-neutral-500 max-w-xl leading-relaxed text-sm">
Technical manifest of experiences in cloud orchestration, deployment
pipelines, and security frameworks designed for resilient services in
regulated environments.
</p>
</header>
{/* Specification List */}
<div className="space-y-6 mb-32 font-mono">
{CAPABILITIES.map((cap, i) => (
<motion.div
key={cap.title}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1, duration: 0.4 }}
className="grid grid-cols-1 md:grid-cols-12 gap-6 border-t border-neutral-800/60 pt-10"
>
<div className="md:col-span-4">
<div className="flex items-center gap-3 text-blue-500/90">
{cap.icon}
<h3 className="text-[10px] font-bold tracking-[0.25em] uppercase">
{cap.title}
</h3>
</div>
</div>
<div className="md:col-span-8">
<p className="text-neutral-400 text-sm leading-relaxed mb-6">
{cap.description}
</p>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{cap.skills.map((skill) => (
<span
key={skill}
className="text-[9px] font-mono text-neutral-600 uppercase tracking-widest"
>
{`// ${skill}`}
</span>
))}
</div>
</div>
</motion.div>
))}
</div>
{/* Featured Case Study Section */}
<section className="bg-neutral-900/40 border border-neutral-800 p-8 md:p-12 rounded-3xl font-mono">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-10">
<div className="flex-1">
<h2 className="text-[10px] font-mono text-blue-500 uppercase tracking-[0.3em] mb-4 font-bold">
Selected Case Study
</h2>
<h3 className="text-xl font-bold text-white mb-3 tracking-tight">
Medical-Grade Architecture Analysis
</h3>
<p className="text-neutral-500 leading-relaxed text-sm max-w-2xl">
An examination of a multi-cloud environment built to satisfy
<span className="text-neutral-300"> UK Cyber Essentials</span> and
<span className="text-neutral-300"> NHS DSPT</span> standards,
focusing on data residency and infrastructure-level security.
</p>
</div>
<div className="shrink-0">
<Link href="/projects/infrastructure/ayla">
<motion.div
whileHover={{
scale: 1.02,
backgroundColor: "#3b82f6",
color: "#fff",
}}
whileTap={{ scale: 0.98 }}
className="px-8 py-4 rounded-xl bg-white text-black font-bold text-[11px] uppercase tracking-[0.2em] flex items-center gap-3 transition-all duration-300"
>
View case
<Zap size={14} />
</motion.div>
</Link>
</div>
</div>
</section>
</PageLayout>
);
}

136
app/lab/page.tsx Normal file
View file

@ -0,0 +1,136 @@
"use client";
import { LAB_SERVICES } from "@/data/lab";
import { Lock, Box, Globe } from "lucide-react";
import { motion } from "framer-motion";
import Image from "next/image";
import PageLayout from "@/components/PageLayout";
export default function LabPage() {
return (
<PageLayout backLink="/" maxWidth="6xl">
<header className="mb-32">
<div className="flex items-center gap-2 mb-4">
<Box className="text-blue-500" size={20} />
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase">
System Lab
</h1>
</div>
<p className="text-neutral-500 max-w-2xl leading-relaxed text-sm">
A registry of self hosted operational services and experimental R&D.
Services labeled
<span className="text-blue-500"> [VPN]</span> are secured via
Tailscale to maintain a hardened perimeter for sensitive telemetry.
</p>
</header>
<div className="space-y-40 mb-20">
{" "}
{/* Increased spacing for alternating rhythm */}
{LAB_SERVICES.map((service, i) => {
const isEven = i % 2 === 0;
return (
<motion.section
key={service.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-100px" }}
className={`flex flex-col ${isEven ? "md:flex-row" : "md:flex-row-reverse"} gap-12 md:gap-24 items-center group`}
>
{/* Image Side */}
<div className="w-full md:w-1/2">
<div className="relative group aspect-[1.9/1] rounded-2xl overflow-hidden border border-neutral-800 bg-black shadow-2xl transition-colors hover:border-blue-500/50">
<Image
src={service.image}
alt={service.name}
fill
className="object-cover transition-all duration-500 ease-out brightness-100 grayscale-[0.2] group-hover:grayscale-0 scale-100 group-hover:scale-105"
/>
</div>
</div>
{/* Text Side */}
<div className="w-full md:w-1/2 space-y-6">
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-3">
<h3 className="text-2xl font-bold text-white tracking-tight">
{service.name}
</h3>
{/* Live Status Badge */}
{service.uptimeId && (
<div className="flex items-center shrink-0">
<Image
src={`https://status.georgew.dev/api/badge/${service.uptimeId}/status`}
alt="Online"
width={90}
height={20}
className="
h-4 w-auto
transition-all duration-500 ease-in-out
grayscale opacity-60
group-hover:grayscale-0 group-hover:opacity-100
"
unoptimized
/>
</div>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{service.stack.map((tech) => (
<span
key={tech}
className="text-[10px] text-blue-500/70 uppercase tracking-widest font-bold"
>
{tech}
</span>
))}
</div>
</div>
<p className="text-neutral-400 text-sm leading-relaxed max-w-md">
{service.description}
</p>
{/* Conditional Actions */}
<div className="flex items-center gap-6 pt-4 border-t border-neutral-800/50">
{service.visibility === "public" && service.url ? (
<a
href={service.url}
target="_blank"
className="flex items-center gap-2 text-[10px] text-white hover:text-blue-400 transition-colors uppercase font-bold tracking-widest"
>
<Globe size={14} /> Visit Service
</a>
) : (
<div className="flex items-center gap-2 text-[10px] text-neutral-600 uppercase font-bold tracking-widest cursor-default">
<Lock size={12} className="text-blue-500/50" /> VPN
Encrypted
</div>
)}
{service.gitUrl && (
<a
href={service.gitUrl}
target="_blank"
className="group/git flex items-center gap-2 text-[10px] text-neutral-500 hover:text-white transition-colors uppercase font-bold tracking-widest bg-neutral-900/50 px-3 py-1.5 rounded-lg border border-neutral-800 hover:border-neutral-700"
>
<Image
src="/forgejo.svg"
alt="Forgejo"
width={12}
height={12}
className="opacity-50 group-hover/git:opacity-100 transition-opacity"
/>
{`Source`}
</a>
)}
</div>
</div>
</motion.section>
);
})}
</div>
</PageLayout>
);
}

View file

@ -1,19 +1,46 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { motion } from "framer-motion"; import { motion, Variants } from "framer-motion";
import { Globe, Smartphone, Server, Gamepad2 } from "lucide-react"; import { Globe, Smartphone, Server, Gamepad2, Hammer } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import MonitorCard from "@/components/MonitorCard"; import MonitorCard from "@/components/MonitorCard";
import Image from "next/image"; import PageLayout from "@/components/PageLayout";
import { CategoryCardProps } from "@/types/index";
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: "easeOut" },
},
};
export default function Home() { export default function Home() {
const [isHoveringMonitors, setIsHoveringMonitors] = useState(false); const [isHoveringMonitors, setIsHoveringMonitors] = useState(false);
return ( return (
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24"> <PageLayout maxWidth="7xl">
<div className="max-w-7xl mx-auto"> <motion.div
<header className="mb-12"> variants={containerVariants}
initial="hidden"
animate="visible"
className="flex flex-col gap-12"
>
{/* Header Section */}
<motion.header variants={itemVariants}>
<h1 className="text-4xl font-bold tracking-tight">George W.</h1> <h1 className="text-4xl font-bold tracking-tight">George W.</h1>
<p className="text-neutral-400 mt-2"> <p className="text-neutral-400 mt-2">
Senior Full Stack Engineer & Tech Lead Senior Full Stack Engineer & Tech Lead
@ -32,14 +59,15 @@ export default function Home() {
LinkedIn LinkedIn
</a> </a>
</div> </div>
</header> </motion.header>
{/* Main Bento Grid */}
<div className="grid grid-cols-1 md:grid-cols-6 gap-6"> <div className="grid grid-cols-1 md:grid-cols-6 gap-6">
{/* Top Row Left: The Architect */} {/* Top Row Left: The Architect */}
<div className="md:col-span-4 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex flex-col md:flex-row gap-8 min-h-[300px] overflow-hidden relative"> <motion.div
<div className="absolute top-0 right-0 w-1/2 h-full opacity-[0.03] pointer-events-none"></div> variants={itemVariants}
className="md:col-span-4 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex flex-col md:flex-row gap-8 min-h-[300px] overflow-hidden relative"
{/* Description and tags */} >
<div className="flex-[1.5] flex flex-col justify-between relative z-10"> <div className="flex-[1.5] flex flex-col justify-between relative z-10">
<div> <div>
<h2 className="text-3xl font-bold mb-4 tracking-tight"> <h2 className="text-3xl font-bold mb-4 tracking-tight">
@ -76,174 +104,155 @@ export default function Home() {
<div className="hidden md:block w-px bg-neutral-800/50 self-stretch" /> <div className="hidden md:block w-px bg-neutral-800/50 self-stretch" />
{/* Technical details */}
<div className="flex-1 flex flex-col justify-around py-2 relative z-10"> <div className="flex-1 flex flex-col justify-around py-2 relative z-10">
<div className="space-y-6"> <div className="space-y-6">
<section> <TechnicalFocus
<h4 className="text-[12px] font-mono text-blue-500 uppercase tracking-[0.2em] mb-2"> label="Leadership"
Leadership color="text-blue-500"
</h4> text="Tech Lead & Scrum Master. Orchestrating sprint cycles and system design."
<p className="text-xs text-neutral-300 leading-tight"> />
Tech Lead & Scrum Master. Orchestrating sprint cycles, <TechnicalFocus
system design, and cross-functional team growth. label="Integrity"
</p> color="text-purple-500"
</section> text="Medical/Regulatory environments, QMS, and Cyber Essentials."
/>
<section> <TechnicalFocus
<h4 className="text-[12px] font-mono text-purple-500 uppercase tracking-[0.2em] mb-2"> label="Infrastructure"
Integrity color="text-green-500"
</h4> text="Kubernetes, GCP, and automated CI/CD pipelines."
<p className="text-xs text-neutral-300 leading-tight"> />
Experienced in{" "}
<span className="italic">High-Stakes Environments</span>{" "}
(Medical/Regulatory), QMS, and Cyber Essentials.
</p>
</section>
<section>
<h4 className="text-[12px] font-mono text-green-500 uppercase tracking-[0.2em] mb-2">
Infrastructure
</h4>
<p className="text-xs text-neutral-300 leading-tight">
Kubernetes, GCP, and automated CI/CD pipelines.
</p>
</section>
</div>
</div> </div>
</div> </div>
</motion.div>
{/* Top Row Right: The Service Registry */} {/* Top Row Right: The Service Registry */}
<Link href="/lab" className="md:col-span-2 flex flex-col group">
<motion.div <motion.div
variants={itemVariants}
whileHover={{ y: -5 }} whileHover={{ y: -5 }}
onMouseEnter={() => setIsHoveringMonitors(true)} onMouseEnter={() => setIsHoveringMonitors(true)}
onMouseLeave={() => setIsHoveringMonitors(false)} onMouseLeave={() => setIsHoveringMonitors(false)}
className="group md:col-span-2 p-6 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-center relative overflow-hidden transition-all duration-300 min-h-[180px]" className="flex-1 p-6 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-center relative overflow-hidden min-h-[180px] hover:border-blue-500/30"
> >
<MonitorCard isHovered={isHoveringMonitors} /> <MonitorCard isHovered={isHoveringMonitors} />
</motion.div> </motion.div>
{/* Middle Row: Web Systems */}
<Link href="/projects/web" className="group md:col-span-2">
<motion.div
whileHover={{ y: -5 }}
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-blue-500/30"
>
<div>
<Globe className="text-blue-400 w-6 h-6 mb-4" />
<h3 className="font-bold text-xl mb-2">Web Systems</h3>
<p className="text-sm text-neutral-500 leading-relaxed">
Architecting distributed platforms with a focus on
high-availability and containerized deployment.
</p>
</div>
<div className="flex flex-wrap gap-2 mt-6">
{["Next.js", "Python", "Node.js", "Caddy", "PostgreSQL"].map(
(tech) => (
<span
key={tech}
className="text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase group-hover:text-blue-400 group-hover:border-blue-500/20 transition-all"
>
{tech}
</span>
),
)}
</div>
</motion.div>
</Link> </Link>
{/* Middle Row: Mobile Apps */} {/* Project Category Cards */}
<Link href="/projects/mobile" className="group md:col-span-2"> <CategoryCard
<motion.div href="/projects/web"
whileHover={{ y: -5 }} icon={<Globe className="text-blue-400 w-6 h-6 mb-4" />}
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-purple-500/30" title="Web Systems"
> description="Architecting distributed platforms with a focus on high-availability."
<div> tech={["Next.js", "Python", "Node.js", "Caddy", "PostgreSQL"]}
<Smartphone className="text-purple-400 w-6 h-6 mb-4" /> hoverColor="hover:border-blue-500/30"
<h3 className="font-bold text-xl mb-2">Mobile Apps</h3> activeTechColor="group-hover:text-blue-400"
<p className="text-sm text-neutral-500 leading-relaxed"> />
Building fluid, cross-platform experiences using reactive
state and native hardware integration.
</p>
</div>
<div className="flex flex-wrap gap-2 mt-6">
{["Android", "iOS", "Flutter", "Riverpod", "Stores"].map(
(tech) => (
<span
key={tech}
className="text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase group-hover:text-purple-400 group-hover:border-purple-500/20 transition-all"
>
{tech}
</span>
),
)}
</div>
</motion.div>
</Link>
{/* Middle Row: DevOps */} <CategoryCard
<Link href="/projects/infrastructure" className="group md:col-span-2"> href="/projects/mobile"
<motion.div icon={<Smartphone className="text-purple-400 w-6 h-6 mb-4" />}
whileHover={{ y: -5 }} title="Mobile Apps"
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-green-500/30" description="Building fluid, cross-platform experiences using reactive state."
> tech={["Android", "iOS", "Flutter", "Riverpod", "Stores"]}
<div> hoverColor="hover:border-purple-500/30"
<Server className="text-green-400 w-6 h-6 mb-4" /> activeTechColor="group-hover:text-purple-400"
<h3 className="font-bold text-xl mb-2">DevOps</h3> />
<p className="text-sm text-neutral-500 leading-relaxed">
Managing self-hosted cloud nodes with automated CI/CD <CategoryCard
pipelines and proactive monitoring. href="/infrastructure"
</p> icon={<Server className="text-green-400 w-6 h-6 mb-4" />}
</div> title="Infrastructure"
<div className="flex flex-wrap gap-2 mt-6"> description="Resilient cloud environments with automated IaC and multi-region orchestration."
{["Docker", "Woodpecker", "Hetzner", "Linux", "Uptime"].map( tech={["Kubernetes", "Terraform", "GCP", "CI/CD", "Security"]}
(tech) => ( hoverColor="hover:border-green-500/30"
<span activeTechColor="group-hover:text-green-400"
key={tech} />
className="text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase group-hover:text-green-400 group-hover:border-green-500/20 transition-all"
>
{tech}
</span>
),
)}
</div>
</motion.div>
</Link>
{/* Bottom Row: The Forge */} {/* Bottom Row: The Forge */}
<div className="md:col-span-6 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex items-center gap-6 group hover:border-orange-500/30 transition-colors cursor-pointer"> <Link href="/forge" className="md:col-span-6">
<motion.div
variants={itemVariants}
className="p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex items-center gap-6 group hover:border-orange-500/30 transition-colors cursor-pointer"
>
<div className="p-4 bg-orange-500/10 rounded-2xl border border-orange-500/20 group-hover:border-orange-500/40 transition-colors"> <div className="p-4 bg-orange-500/10 rounded-2xl border border-orange-500/20 group-hover:border-orange-500/40 transition-colors">
<Gamepad2 className="text-orange-500 w-8 h-8" /> <Hammer className="text-orange-500 w-8 h-8 group-hover:rotate-12 transition-transform" />
</div> </div>
<div> <div>
<h3 className="font-bold text-xl">The Forge</h3> <div className="flex items-center gap-2 mb-0.5">
<p className="text-sm text-neutral-500"> <h3 className="font-bold text-xl tracking-tight">
Indie Game Dev & Creative Prototypes The Forge
</h3>
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
</div>
<p className="text-sm text-neutral-500 leading-tight">
A space where I demonstrate what I am currently working on and
any future projects.
</p> </p>
</div> </div>
</motion.div>
</Link>
</div> </div>
</div> </motion.div>
<footer className="mt-12 pt-8 border-t border-neutral-900 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] text-neutral-600 font-mono tracking-wider uppercase"> </PageLayout>
<div className="flex items-center gap-6"> );
<div className="flex items-center gap-2"> }
<p>Pipeline Status</p>
<img function TechnicalFocus({
src="https://ci.georgew.dev/api/badges/11/status.svg" label,
alt="Build Status" color,
className="h-3 grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all" text,
/> }: {
</div> label: string;
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" /> color: string;
<p>Engine: Next.js 15 (Standalone)</p> text: string;
</div> }) {
return (
<div className="flex items-center gap-4 bg-neutral-900/50 px-3 py-1 rounded-full border border-neutral-800/50"> <section>
<div className="w-1 h-1 rounded-full bg-blue-500" /> <h4
<p className="text-neutral-400"> className={`text-[12px] font-mono ${color} uppercase tracking-[0.2em] mb-2`}
Deploy: {process.env.NEXT_PUBLIC_APP_VERSION || "v1.0.0-dev"} >
</p> {label}
</div> </h4>
</footer> <p className="text-xs text-neutral-300 leading-tight">{text}</p>
</div> </section>
</main> );
}
function CategoryCard({
href,
icon,
title,
description,
tech,
hoverColor,
activeTechColor,
}: CategoryCardProps) {
return (
<Link href={href} className="group md:col-span-2">
<motion.div
variants={itemVariants}
whileHover={{ y: -5 }}
className={`p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors ${hoverColor}`}
>
<div>
{icon}
<h3 className="font-bold text-xl mb-2">{title}</h3>
<p className="text-sm text-neutral-500 leading-relaxed">
{description}
</p>
</div>
<div className="flex flex-wrap gap-2 mt-6">
{tech.map((t: string) => (
<span
key={t}
className={`text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase ${activeTechColor} group-hover:border-current/20 transition-all`}
>
{t}
</span>
))}
</div>
</motion.div>
</Link>
); );
} }

View file

@ -1,21 +1,36 @@
"use client"; "use client";
import { use } from "react"; import { use } from "react";
import { motion } from "framer-motion"; import { motion, Variants } from "framer-motion";
import Link from "next/link"; import { ExternalLink, Github, ShieldCheck, Cpu, Users } from "lucide-react";
import {
ArrowLeft,
ExternalLink,
Github,
ShieldCheck,
Cpu,
Users,
} from "lucide-react";
import { PROJECT_REGISTRY } from "@/data/projects"; import { PROJECT_REGISTRY } from "@/data/projects";
import Mermaid from "@/components/Mermaid"; import Mermaid from "@/components/Mermaid";
import ProjectShowcase from "@/components/ProjectShowcase"; import ProjectShowcase from "@/components/ProjectShowcase";
import ImageCarousel from "@/components/ImageCarousel"; import ImageCarousel from "@/components/ImageCarousel";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import MobileStack from "@/components/MobileStack";
import PageLayout from "@/components/PageLayout";
// 1. Professional Animation Variants
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15, // Smoothly delay each section
delayChildren: 0.1,
},
},
};
const sectionVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.8, ease: "easeOut" },
},
};
export default function ProjectDetail({ export default function ProjectDetail({
params, params,
@ -25,30 +40,35 @@ export default function ProjectDetail({
const { category, slug } = use(params); const { category, slug } = use(params);
const project = PROJECT_REGISTRY.find((p) => p.slug === slug); const project = PROJECT_REGISTRY.find((p) => p.slug === slug);
if (!project) return <div>Project Not Found</div>; if (!project) {
if (!project)
return ( return (
<div className="p-24 text-white font-mono">Project Log Not Found.</div> <PageLayout backLink={`/projects/${category}`}>
<div className="font-mono text-white">Project Log Not Found.</div>
</PageLayout>
); );
}
return ( return (
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24"> <PageLayout
<div className="max-w-6xl mx-auto"> backLink={
{/* Navigation */} category == "infrastructure"
<Link ? "/infrastructure"
href={`/projects/${category}`} : `/projects/${category}`
className="flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-[10px] uppercase tracking-widest" }
backLabel={`Back to ${category}`}
maxWidth="6xl"
> >
<ArrowLeft size={12} /> Back to {category}
</Link>
{/* Header Section */}
<header className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20">
<motion.div <motion.div
initial={{ opacity: 0, x: -20 }} variants={containerVariants}
animate={{ opacity: 1, x: 0 }} initial="hidden"
animate="visible"
> >
{/* Header Section */}
<motion.header
variants={sectionVariants}
className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20"
>
<div className="flex flex-col justify-center">
<h1 className="text-6xl font-bold tracking-tighter mb-4"> <h1 className="text-6xl font-bold tracking-tighter mb-4">
{project.title} {project.title}
</h1> </h1>
@ -63,84 +83,66 @@ export default function ProjectDetail({
{project.liveUrl && ( {project.liveUrl && (
<a <a
href={project.liveUrl} href={project.liveUrl}
className="flex items-center gap-2 bg-white text-black px-6 py-3 rounded-full font-bold text-sm hover:bg-neutral-200 transition-all" className="flex items-center gap-2 bg-white text-black px-6 py-3 rounded-full font-bold text-sm hover:scale-105 transition-transform"
> >
Launch Site <ExternalLink size={14} /> Launch Site <ExternalLink size={14} />
</a> </a>
)} )}
{project?.repoUrl && ( {project?.repoUrl && (
<a <a
href={project.repoUrl} href={project.repoUrl}
className="flex items-center gap-2 border border-neutral-800 px-6 py-3 rounded-full font-bold text-sm hover:bg-neutral-900 transition-all" className="flex items-center gap-2 border border-neutral-800 px-6 py-3 rounded-full font-bold text-sm hover:bg-neutral-900 transition-colors"
> >
View Source <Github size={14} /> View Source <Github size={14} />
</a> </a>
)} )}
</div> </div>
</motion.div> </div>
{/* Stats Sidebar */} {/* Stats Sidebar */}
<motion.div <div className="bg-neutral-900/30 border border-neutral-800 p-8 rounded-3xl h-fit backdrop-blur-sm">
initial={{ opacity: 0, x: 20 }} <div className="space-y-8 font-mono">
animate={{ opacity: 1, x: 0 }} <StatItem
className="bg-neutral-900/30 border border-neutral-800 p-8 rounded-3xl h-fit" icon={<ShieldCheck className="text-blue-500" />}
> label="My Role"
<div className="space-y-8"> value={project.role}
<div className="flex items-center gap-4"> />
<ShieldCheck className="text-blue-500" /> <StatItem
<div> icon={<Cpu className="text-purple-500" />}
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter"> label="Stack"
My Role value={project.stack.join(", ")}
</p> />
<p className="text-sm font-semibold">{project.role}</p> <StatItem
icon={<Users className="text-green-500" />}
label="Impact"
value={project.metrics.join(" • ")}
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> </motion.header>
<Cpu className="text-purple-500" />
<div>
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
Stack
</p>
<p className="text-sm font-semibold">
{project.stack.join(", ")}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<Users className="text-green-500" />
<div>
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
Impact
</p>
<p className="text-sm font-semibold">
{project.metrics.join(" • ")}
</p>
</div>
</div>
</div>
</motion.div>
</header>
<section className="mb-20"> {/* Media Showcase */}
{/* Desktop Showcase View */} <motion.section variants={sectionVariants} className="mb-20">
{project.category === "mobile" ? (
<MobileStack images={project.images} />
) : (
<>
<div className="hidden lg:block"> <div className="hidden lg:block">
<ProjectShowcase images={project.images} /> <ProjectShowcase images={project.images} />
</div> </div>
{/* Mobile Carousel View */}
<div className="block lg:hidden"> <div className="block lg:hidden">
<ImageCarousel images={project.images} /> <ImageCarousel images={project.images} />
</div> </div>
</>
<p className="mt-4 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]"> )}
<p className="mt-6 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]">
Interactive Gallery Select or swipe to explore Interactive Gallery Select or swipe to explore
</p> </p>
</section> </motion.section>
{/* Mermaid */} {/* System Architecture */}
{project.mermaidChart && ( {project.mermaidChart && (
<section className="mb-16"> <motion.section variants={sectionVariants} className="mb-16">
<div className="flex items-center gap-3 mb-8"> <div className="flex items-center gap-3 mb-8">
<div className="h-px flex-1 bg-neutral-900" /> <div className="h-px flex-1 bg-neutral-900" />
<h3 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.3em]"> <h3 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.3em]">
@ -148,15 +150,17 @@ export default function ProjectDetail({
</h3> </h3>
<div className="h-px flex-1 bg-neutral-900" /> <div className="h-px flex-1 bg-neutral-900" />
</div> </div>
<div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12"> <div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12">
<Mermaid chart={project.mermaidChart} /> <Mermaid chart={project.mermaidChart} />
</div> </div>
</section> </motion.section>
)} )}
{/* Engineering Narrative */} {/* Engineering Narrative */}
<section className="w-full pb-20 mt-12"> <motion.section
variants={sectionVariants}
className="w-full pb-20 mt-12"
>
<div className="flex items-center gap-3 mb-12"> <div className="flex items-center gap-3 mb-12">
<h3 className="text-[10px] font-mono text-blue-500 uppercase tracking-[0.3em] font-bold"> <h3 className="text-[10px] font-mono text-blue-500 uppercase tracking-[0.3em] font-bold">
PROJECT LOG // {project.storyLabel || "NARRATIVE"} PROJECT LOG // {project.storyLabel || "NARRATIVE"}
@ -166,12 +170,34 @@ export default function ProjectDetail({
The Engineering Story The Engineering Story
</h2> </h2>
</div> </div>
<div className="prose prose-invert prose-neutral max-w-none">
<div className="prose prose-invert prose-neutral max-w-none text-left">
<ReactMarkdown>{project.engineeringStory}</ReactMarkdown> <ReactMarkdown>{project.engineeringStory}</ReactMarkdown>
</div> </div>
</section> </motion.section>
</div> </motion.div>
</main> </PageLayout>
);
}
// Small helper component to keep the JSX clean
function StatItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-4">
{icon}
<div>
<p className="text-[10px] text-neutral-500 uppercase tracking-tighter">
{label}
</p>
<p className="text-sm font-semibold">{value}</p>
</div>
</div>
); );
} }

View file

@ -1,9 +1,11 @@
"use client"; "use client";
import { motion } from "framer-motion";
import Link from "next/link";
import { Globe, Smartphone, ArrowLeft, Server } from "lucide-react";
import { use } from "react"; import { use } from "react";
import { motion, Variants } from "framer-motion";
import Link from "next/link";
import { Globe, Smartphone, Server } from "lucide-react";
import { PROJECT_REGISTRY } from "@/data/projects"; import { PROJECT_REGISTRY } from "@/data/projects";
import PageLayout from "@/components/PageLayout";
const CATEGORY_META = { const CATEGORY_META = {
web: { web: {
@ -26,6 +28,27 @@ const CATEGORY_META = {
}, },
}; };
// 1. Cast the container variants
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
};
// 2. Cast the item variants
const itemVariants: Variants = {
hidden: { opacity: 0, x: -20 },
visible: {
opacity: 1,
x: 0,
transition: {
duration: 0.4,
ease: "easeOut", // TypeScript now knows this is a valid Easing string
},
},
};
export default function CategoryPage({ export default function CategoryPage({
params, params,
}: { }: {
@ -33,78 +56,59 @@ export default function CategoryPage({
}) { }) {
const resolvedParams = use(params); const resolvedParams = use(params);
const category = resolvedParams.category; const category = resolvedParams.category;
const meta = CATEGORY_META[category as keyof typeof CATEGORY_META]; const meta = CATEGORY_META[category as keyof typeof CATEGORY_META];
const filteredProjects = PROJECT_REGISTRY.filter( const filteredProjects = PROJECT_REGISTRY.filter(
(p) => p.category === category, (p) => p.category === category,
); );
if (!meta) { if (!meta) return <PageLayout backLink="/">Sector not found.</PageLayout>;
return (
<div className="p-24 text-white font-mono">
<h1 className="text-2xl mb-4">404: Category Not Found</h1>
<Link href="/" className="text-blue-400 underline mt-4 block">
Return Home
</Link>
</div>
);
}
return ( return (
<main className="min-h-screen bg-[#0a0a0a] text-white p-8 md:p-24"> <PageLayout backLink="/" maxWidth="6xl">
<Link <div className="mb-16">
href="/" <h1 className="flex items-center gap-4 text-5xl font-bold tracking-tighter mb-6">
className="flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-xs" {meta.icon} {meta.title}
> </h1>
<ArrowLeft size={14} /> BACK TO DASHBOARD <p className="text-xl text-neutral-400 max-w-2xl leading-relaxed">
</Link>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-5xl mx-auto"
>
<div className="flex items-center gap-4 mb-6">
{meta.icon}
<h1 className="text-5xl font-bold">{meta.title}</h1>
</div>
<p className="text-xl text-neutral-400 max-w-2xl mb-16">
{meta.description} {meta.description}
</p> </p>
</div>
<div className="grid grid-cols-1 gap-8"> {/* 2. The container manages the entrance of all children */}
{filteredProjects.map((project, index) => ( <motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-1 gap-6"
>
{filteredProjects.map((project) => (
<Link <Link
key={project.slug} key={project.slug}
href={`/projects/${category}/${project.slug}`} href={`/projects/${category}/${project.slug}`}
className="block"
> >
<motion.div <motion.div
layout layout
initial={{ opacity: 0, x: -20 }} variants={itemVariants}
whileInView={{ opacity: 1, x: 0 }} whileHover={{ x: 8 }}
viewport={{ once: true }} className="group p-8 rounded-3xl bg-neutral-900/40 border border-neutral-800 hover:border-neutral-700 transition-colors"
transition={{
delay: index * 0.1,
duration: 0.4,
ease: "easeOut",
}}
className="group p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 hover:border-neutral-700 transition-colors"
> >
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div> <div>
<h3 className="text-2xl font-semibold mb-2"> <h3 className="text-2xl font-bold mb-2 group-hover:text-blue-400 transition-colors">
{project.title} {project.title}
</h3> </h3>
<p className="text-neutral-500">{project.description}</p> <p className="text-neutral-500 text-sm max-w-xl">
{project.description}
</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2 md:justify-end">
{project.stack.map((s) => ( {project.stack.map((tech) => (
<span <span
key={s} key={tech}
className="text-[10px] font-mono bg-neutral-800 text-neutral-400 px-3 py-1 rounded-full uppercase" className="text-[9px] font-mono bg-neutral-800 text-neutral-400 px-3 py-1 rounded-full uppercase tracking-widest border border-neutral-800"
> >
{s} {tech}
</span> </span>
))} ))}
</div> </div>
@ -112,8 +116,7 @@ export default function CategoryPage({
</motion.div> </motion.div>
</Link> </Link>
))} ))}
</div>
</motion.div> </motion.div>
</main> </PageLayout>
); );
} }

31
components/Footer.tsx Normal file
View file

@ -0,0 +1,31 @@
"use client";
import Image from "next/image";
export default function Footer() {
return (
<footer className="mt-12 pt-8 border-t border-neutral-900 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] text-neutral-600 font-mono tracking-wider uppercase">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<p>Pipeline Status</p>
<Image
src="https://ci.georgew.dev/api/badges/11/status.svg"
alt="CI Build Status"
width={64}
height={20}
unoptimized
className="h-3 w-auto grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all"
/>
</div>
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" />
<p>Engine: Next.js 15 (Standalone)</p>
</div>
<div className="flex items-center gap-4 bg-neutral-900/50 px-3 py-1 rounded-full border border-neutral-800/50">
<div className="w-1 h-1 rounded-full bg-blue-500" />
<p className="text-neutral-400">
Deploy: {process.env.NEXT_PUBLIC_APP_VERSION || "v1.0.0-dev"}
</p>
</div>
</footer>
);
}

View file

@ -1,110 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import mermaid from "mermaid";
import { motion, AnimatePresence } from "framer-motion";
import { Maximize2, Minimize2 } from "lucide-react";
export default function Mermaid({ chart }: { chart: string }) {
const [isExpanded, setIsExpanded] = useState(false);
const [isRendered, setIsRendered] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
theme: "dark",
securityLevel: "loose",
fontFamily: "monospace",
});
// Render the chart once. Use a timeout to ensure DOM is stable.
const renderChart = async () => {
try {
await mermaid.contentLoaded();
setIsRendered(true);
} catch (err) {
console.error("Mermaid render failed:", err);
}
};
renderChart();
}, [chart]); // Only re-run if the chart string itself changes
return (
<div className="relative max-w-4xl mx-auto group">
<motion.div
layout
initial={false}
animate={{
height: isExpanded ? "auto" : "400px",
}}
transition={{ duration: 0.6, ease: [0.23, 1, 0.32, 1] }}
className={`relative bg-neutral-900/20 rounded-3xl border border-neutral-800/50 overflow-hidden transition-colors duration-500 ${
!isExpanded
? "hover:border-neutral-700 cursor-pointer"
: "cursor-default"
}`}
onClick={() => !isExpanded && setIsExpanded(true)}
>
{/* Legend */}
<div className="absolute top-6 right-8 flex flex-col gap-2 z-20 bg-black/40 backdrop-blur-md p-3 rounded-xl border border-white/5 pointer-events-none">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
Traffic Flow
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
Service Node
</span>
</div>
</div>
{/* Chart Area */}
<div
className={`p-4 md:p-12 transition-opacity duration-500 ${isRendered ? "opacity-100" : "opacity-0"}`}
>
<div className="mermaid flex justify-center">{chart}</div>
</div>
{/* Fade Overlay */}
<AnimatePresence>
{!isExpanded && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/90 to-transparent pointer-events-none"
/>
)}
</AnimatePresence>
</motion.div>
{/* Toggle Button */}
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className={`absolute -bottom-5 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-5 py-2.5 rounded-full text-[10px] font-mono uppercase tracking-[0.2em] transition-all border shadow-xl
${
isExpanded
? "bg-neutral-800 border-neutral-700 text-white"
: "bg-neutral-900 border-neutral-800 text-neutral-500 group-hover:bg-neutral-800 group-hover:border-neutral-600 group-hover:text-white"
}`}
>
{isExpanded ? (
<>
<Minimize2 size={12} /> Collapse Logic
</>
) : (
<>
<Maximize2 size={12} /> Expand Architecture
</>
)}
</button>
</div>
);
}

View file

@ -1,98 +0,0 @@
"use client";
import { useEffect, useState, useRef } from "react";
import mermaid from "mermaid";
import { motion, AnimatePresence } from "framer-motion";
import { Maximize2, Minimize2 } from "lucide-react";
export default function Mermaid({ chart }: { chart: string }) {
const [isExpanded, setIsExpanded] = useState(false);
const [needsExpansion, setNeedsExpansion] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
mermaid.initialize({
startOnLoad: true,
theme: "dark",
securityLevel: "loose",
fontFamily: "monospace",
});
mermaid.contentLoaded();
if (contentRef.current) {
const height = contentRef.current.scrollHeight;
setNeedsExpansion(height > 400);
}
}, [chart]);
return (
<div className="relative max-w-4xl mx-auto group">
<motion.div
initial={false}
onClick={() => needsExpansion && setIsExpanded(!isExpanded)}
animate={{ height: isExpanded || !needsExpansion ? "auto" : "400px" }}
className={`relative bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12 overflow-hidden transition-colors duration-500
${needsExpansion && !isExpanded ? "cursor-pointer hover:border-neutral-700" : "cursor-default"}`}
>
{/* Legend */}
<div className="absolute top-6 right-8 flex flex-col gap-2 z-20 bg-black/40 backdrop-blur-md p-3 rounded-xl border border-white/5">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
Traffic Flow
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
Service Node
</span>
</div>
</div>
<div ref={contentRef} className="mermaid flex justify-center">
{chart}
</div>
{/* The "Fade to Darkness" Overlay (when expansion is needed) */}
<AnimatePresence>
{needsExpansion && !isExpanded && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/80 to-transparent pointer-events-none"
/>
)}
</AnimatePresence>
</motion.div>
{/* Expand/Collapse Button (when expansion is needed) */}
{needsExpansion && (
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className={`absolute -bottom-5 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-5 py-2.5 rounded-full text-[10px] font-mono uppercase tracking-[0.2em] transition-all border shadow-xl
${
isExpanded
? "bg-neutral-800 border-neutral-700 text-white"
: "bg-neutral-900 border-neutral-800 text-neutral-500 group-hover:bg-neutral-800 group-hover:border-neutral-600 group-hover:text-white"
}`}
>
{isExpanded ? (
<>
{" "}
<Minimize2 size={12} /> Collapse Logic{" "}
</>
) : (
<>
{" "}
<Maximize2 size={12} /> Expand Architecture{" "}
</>
)}
</button>
)}
</div>
);
}

View file

@ -0,0 +1,10 @@
export function MobileFrame({ children }: { children: React.ReactNode }) {
return (
<div className="relative mx-auto border-neutral-800 bg-neutral-800 border-[8px] rounded-[2.5rem] h-[600px] w-[300px] shadow-2xl overflow-hidden">
<div className="absolute top-0 inset-x-0 h-6 bg-neutral-800 rounded-b-xl z-20 w-32 mx-auto" />
<div className="relative h-full w-full bg-black overflow-hidden">
{children}
</div>
</div>
);
}

104
components/MobileStack.tsx Normal file
View file

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { MobileFrame } from "./MobileFrame";
export default function MobileStack({ images }: { images: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const DRAG_THRESHOLD = -150;
const getRelativeIndex = (index: number) => {
const len = images.length;
return (index - currentIndex + len) % len;
};
const next = () => setCurrentIndex((prev) => (prev + 1) % images.length);
return (
<div className="relative h-[750px] w-full flex flex-col items-center py-20 overflow-hidden group">
<div className="relative h-[650px] w-full flex justify-center items-center">
<AnimatePresence initial={false}>
{images.map((img, index) => {
const relIndex = getRelativeIndex(index);
const isTop = relIndex === 0;
const xOffset = relIndex * 90;
if (relIndex > 5) return null;
return (
<motion.div
key={img}
style={{ zIndex: images.length - relIndex }}
initial={{ opacity: 0, x: 400 }}
animate={{
opacity: 1,
x: isTop ? 0 : xOffset,
scale: isTop ? 1 : 0.96,
filter: isTop ? "brightness(1)" : "brightness(0.4)",
pointerEvents: isTop ? "auto" : "all",
}}
exit={{
x: -1000,
opacity: 0,
transition: { duration: 0.4, ease: "easeIn" },
}}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={
!isTop ? { scale: 0.98, filter: "brightness(0.6)" } : {}
}
drag={isTop ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.8}
onDrag={(_, info) => {
if (isTop && info.offset.x < DRAG_THRESHOLD) {
next();
}
}}
onDragEnd={(_, info) => {
// Backup check for quick flicks
if (isTop && info.offset.x < -100) {
next();
}
}}
onClick={() => !isTop && setCurrentIndex(index)}
className="absolute"
>
<div
className={`${isTop ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"}`}
>
<MobileFrame>
<img
src={img}
alt="App Screenshot"
draggable="false"
className="w-full h-full object-cover select-none"
/>
</MobileFrame>
</div>
</motion.div>
);
})}
</AnimatePresence>
{/* Navigation Button */}
<div className="absolute -bottom-12 z-[100]">
<button
onClick={next}
className="flex items-center gap-3 px-6 py-3 rounded-full bg-black/40 backdrop-blur-xl border border-white/5 text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 group-hover:translate-y-[-20px]"
>
<span className="text-[10px] font-mono uppercase tracking-[0.2em]">
Next Screen
</span>
<ArrowRight size={16} />
</button>
</div>
</div>
</div>
);
}

View file

@ -21,38 +21,42 @@ const MONITORS = [
]; ];
const ITEMS_PER_PAGE = 6; const ITEMS_PER_PAGE = 6;
const INTERVAL_TIME = 2500;
export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) { export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE);
useEffect(() => { useEffect(() => {
let interval: NodeJS.Timeout | null = null; let interval: NodeJS.Timeout;
if (isHovered && totalPages > 1) { if (isHovered) {
// Start rotating pages only when hovered
interval = setInterval(() => { interval = setInterval(() => {
setPage((prev) => (prev + 1) % totalPages); setPage((p) => (p + 1) % totalPages);
}, 4000); }, INTERVAL_TIME);
} else {
// Defer state reset to avoid "cascading render" error
// and allow the fade-out animation to play smoothly
const timeout = setTimeout(() => {
setPage(0);
}, 300);
return () => clearTimeout(timeout);
} }
return () => { return () => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
if (!isHovered) {
setPage(0);
}
}; };
}, [isHovered, totalPages]); }, [isHovered, totalPages]);
const currentMonitors = MONITORS.slice(
page * ITEMS_PER_PAGE,
(page + 1) * ITEMS_PER_PAGE,
);
return ( return (
<> <div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
{/* Default View */} {/* --- DEFAULT VIEW --- */}
<div className="flex min-h-[250px] flex-col justify-center w-full group-hover:opacity-0 group-hover:pointer-events-none transition-all duration-300"> <div
{/* Header Section */} className={`transition-opacity duration-300 ${
isHovered ? "opacity-0 pointer-events-none" : "opacity-100"
}`}
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@ -61,12 +65,9 @@ export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
Hetzner Node-01 Hetzner Node-01
</span> </span>
</div> </div>
<div className="flex gap-2 items-center"> <p className="text-[10px] font-mono text-neutral-500">
<p className="text-xs text-neutral-500 font-mono">SYS_STATUS:</p> SYS_STATUS: ONLINE
<span className="text-[10px] bg-green-500/10 text-green-500 border border-green-500/20 px-1.5 py-0.5 rounded uppercase font-bold"> </p>
Online
</span>
</div>
</div> </div>
<Activity className="text-neutral-800 w-10 h-10 -mt-1" /> <Activity className="text-neutral-800 w-10 h-10 -mt-1" />
</div> </div>
@ -87,42 +88,99 @@ export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
</div> </div>
</div> </div>
{/* Hover View */} {/* --- REGISTRY VIEW --- */}
<div className="absolute inset-0 p-5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col bg-neutral-900/95 backdrop-blur-sm"> <div
<div className="flex justify-between items-center mb-2 px-1"> className={`absolute inset-0 transition-all duration-300 flex flex-col ${
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em]"> isHovered
Service Registry ? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 pointer-events-none"
}`}
>
{/* HEADER WITH SYNCED TIMER */}
<div className="flex items-center justify-between mb-3 group/header">
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] flex items-center gap-2 group-hover:text-blue-400 transition-colors">
Explore Systems
<motion.span
animate={{ x: [0, 4, 0] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
className="inline-block"
>
</motion.span>
</h4> </h4>
<div className="flex items-center gap-2">
<div className="relative w-12 h-[2.5px] bg-neutral-800 rounded-full overflow-hidden">
<motion.div
key={`${isHovered}-${page}`}
initial={{ width: "0%" }}
animate={isHovered ? { width: "100%" } : { width: "0%" }}
transition={{
duration: isHovered ? INTERVAL_TIME / 1000 : 0,
ease: "linear",
}}
className="h-full bg-blue-500/50" // Changed to blue to match Link intent
/>
</div>
<span className="text-[9px] font-mono text-neutral-600">
{String(page + 1).padStart(2, "0")}
</span>
</div>
</div> </div>
<div className="flex-1 relative min-h-0 overflow-hidden"> <div className="flex-1 overflow-hidden">
<AnimatePresence mode="wait"> <RegistrySlider page={page} />
</div>
</div>
</div>
);
}
function RegistrySlider({ page }: { page: number }) {
const currentItems = MONITORS.slice(
page * ITEMS_PER_PAGE,
(page + 1) * ITEMS_PER_PAGE,
);
return (
<div className="relative h-full">
<AnimatePresence mode="popLayout">
<motion.div <motion.div
key={page} key={page}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4, ease: "easeInOut" }}
className="grid grid-cols-1 gap-2" className="grid grid-cols-1 gap-1.5 w-full"
> >
{currentMonitors.map((m) => ( {currentItems.map((m) => (
<div <div
key={m.id} key={m.id}
className="flex items-center justify-between bg-neutral-800/30 p-1.5 px-3 rounded-lg border border-neutral-700/30" className="flex items-center justify-between bg-neutral-800/40 p-1.5 px-3 rounded-lg border border-neutral-700/30"
> >
<span className="text-[12px] font-medium text-neutral-300 truncate mr-2"> <span className="text-[11px] font-medium text-neutral-300 truncate mr-2">
{m.name} {m.name}
</span> </span>
<div className="flex gap-1 shrink-0 scale-90 origin-right"> <div className="flex gap-1 shrink-0 scale-75 origin-right">
<img <Image
src={`https://status.georgew.dev/api/badge/${m.id}/status`} src={`https://status.georgew.dev/api/badge/${m.id}/status`}
className="h-5" width={60}
alt="up" height={20}
className="h-5 w-auto"
alt="System Status"
unoptimized
/> />
<img <Image
src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`} src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`}
className="h-5 opacity-60" width={80}
alt="ms" height={20}
className="h-5 w-auto opacity-60"
alt="Average Response Time"
unoptimized
/> />
</div> </div>
</div> </div>
@ -130,7 +188,5 @@ export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
</div>
</>
); );
} }

50
components/PageLayout.tsx Normal file
View file

@ -0,0 +1,50 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import Footer from "./Footer"; // Assuming you moved it to a component
import { PageLayoutProps } from "@/types/index";
export default function PageLayout({
children,
backLink,
backLabel = "BACK TO DASHBOARD",
maxWidth = "5xl",
}: PageLayoutProps) {
const widthClass = {
"5xl": "max-w-5xl",
"6xl": "max-w-6xl",
"7xl": "max-w-7xl",
}[maxWidth];
return (
<main className="min-h-screen bg-[#0a0a0a] text-white pt-6 md:pt-12 lg:pt-24 px-6 md:px-12 lg:px-24 pb-8 flex flex-col">
<div className={`${widthClass} mx-auto w-full flex-grow flex flex-col`}>
<div className="flex-grow">
{backLink && (
<Link
href={backLink}
className="group flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-[10px] uppercase tracking-widest w-fit"
>
<ArrowLeft
size={12}
className="transition-transform group-hover:-translate-x-1"
/>
{backLabel}
</Link>
)}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{children}
</motion.div>
</div>
<Footer />
</div>
</main>
);
}

View file

@ -43,11 +43,16 @@ export default function ProjectShowcase({ images }: { images: string[] }) {
: "border-neutral-800 opacity-40 hover:opacity-100" : "border-neutral-800 opacity-40 hover:opacity-100"
}`} }`}
> >
<img <div className="relative h-full w-full overflow-hidden">
<Image
src={img} src={img}
className="h-full w-full object-cover" alt={`Project showcase thumbnail ${i}`}
alt={`Thumb ${i}`} fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/> />
</div>
{i === index && ( {i === index && (
<motion.div <motion.div
layoutId="active-thumb" layoutId="active-thumb"

26
data/forge.ts Normal file
View file

@ -0,0 +1,26 @@
import { ForgeProject } from "@/types";
export const ACTIVE_BUILD: ForgeProject = {
id: "pixelpals",
name: "PixelPals",
status: "Alpha",
engine: "Godot 4.x",
description:
"A mobile-first creature collection game focusing on procedural behaviors and lightweight state-management.",
highlights: [
"State-machine AI",
"Custom Shader Pipelines",
"Mobile-optimized Loops",
],
externalLink: "/projects/pixelpals", // Link to your internal portfolio or a dedicated site
changelog: [
{
date: "2026-02-03",
update: "Refined touch-input latency for mobile devices.",
},
{
date: "2026-01-25",
update: "Integrated local SQLite database for creature persistence.",
},
],
};

101
data/lab.ts Normal file
View file

@ -0,0 +1,101 @@
import { LabService } from "@/types/index";
export const LAB_SERVICES: LabService[] = [
{
id: "observatory",
name: "The Observatory",
description:
"Astronomical API orchestration and orbital visualization. Built to track ISS transits and lunar phases over Copenhagen.",
stack: ["Next.js", "Node", "SQLite"],
visibility: "public",
url: "https://observatory.georgew.dev",
gitUrl: "https://git.georgew.dev/georgew/mission-control",
image: "/lab/observatory.jpg",
uptimeId: 12,
},
{
id: "surf-hub",
name: "Surf Sentinel",
description:
"Custom telemetry dashboard for Llangennith Bay, Wales. Pulling real-time buoy data and wave height predictions to find the perfect window for a session.",
stack: ["Grafana", "Influx DB", "Node"],
visibility: "public",
url: "https://surf.georgew.dev/d/adrx6b4/llangennith-beach-surf-data?orgId=1&from=now-24h&to=now&timezone=browser&refresh=1h&theme=dark&kiosk=true",
image: "/lab/surf-hub.jpg",
gitUrl: "https://git.georgew.dev/georgew/surf-hub",
uptimeId: 13,
},
{
id: "audiobookshelf",
name: "The Archive",
description:
"Dedicated audiobook server for the household. Primarily used for our Brandon Sanderson, Patrick Rothfuss, and Dungeon Crawler Carl marathons.",
stack: ["Docker", "Compose", "Tailscale", "rclone"],
visibility: "tailscale",
image: "/lab/audiobookshelf.jpg",
gitUrl: "https://git.georgew.dev/georgew/audiobookshelf",
uptimeId: 6,
},
{
id: "yamtrack",
name: "Yamtrack",
description:
"A specialized tracker for our anime watch-lists! Features custom metadata hooks to keep our seasonal progress in sync.",
stack: ["Docker", "Redis", "Tailscale"],
visibility: "tailscale",
image: "/lab/yamtrack.jpg",
gitUrl: "https://git.georgew.dev/georgew/yamtrack",
uptimeId: 11,
},
{
id: "paperless",
name: "Paperless-ngx",
description:
"Personal document management system with OCR and automated tagging. Digitizing our physical mail and records into a searchable, versioned archive.",
stack: ["Docker", "Redis", "PostgreSQL"],
visibility: "tailscale",
image: "/lab/paperless.jpg",
gitUrl: "https://git.georgew.dev/georgew/paperless-ngx",
uptimeId: 14,
},
{
id: "change-detection",
name: "Signal Watcher",
description:
"Automated monitoring for the essentials: NASA news updates, hobby stock alerts, and Telegram pings the second a new episode of 'The Traitors' drops.",
stack: ["Telegram API", "Webhooks"],
visibility: "tailscale",
image: "/lab/change-detection.jpg",
gitUrl: "https://git.georgew.dev/georgew/change-detection",
uptimeId: 15,
},
{
id: "ops-suite",
name: "System Operations",
description:
"The 'Engine Room.' Utilizing Portainer for orchestration, Dozzle for log streaming, and Watchtower for automated container lifecycle management across the Hetzner node.",
stack: ["Portainer", "Dozzle", "Watchtower"],
visibility: "tailscale",
image: "/lab/portainer.jpg",
},
{
id: "wikijs",
name: "System Wiki",
description:
"The 'Source of Truth' for the home infrastructure. Contains deployment guides, network maps, and disaster recovery procedures for the entire node.",
stack: ["Wiki.js", "Markdown"],
visibility: "tailscale",
image: "/lab/wikijs.jpg",
uptimeId: 5,
},
{
id: "dashboard",
name: "System Dashboard",
description:
"The central entry point for the GeorgeW ecosystem, used as my personal home page. A high-level overview providing unified access to all public and VPN-secured services.",
stack: ["Homepage", "Docker", "Reverse Proxy"],
visibility: "tailscale",
image: "/lab/dashboard.jpg",
},
];

View file

@ -1,4 +1,4 @@
import { Project } from "@/types/project"; import { Project } from "@/types/index";
export const PROJECT_REGISTRY: Project[] = [ export const PROJECT_REGISTRY: Project[] = [
{ {
@ -245,41 +245,160 @@ graph TB
`, `,
}, },
{ {
slug: "flutter-1", slug: "choosa",
category: "mobile", category: "mobile",
title: "Flutter-1", title: "Choosa",
subtitle: "Personal R&D Pipeline", subtitle: "Social Content Discovery Engine",
role: "Architect & Creator", role: "Lead Developer & Architect",
duration: "2025 — Present", duration: "2023 — Present",
stack: ["Python", "FastAPI", "Next.js", "Redis"], stack: [
metrics: ["Sub-50ms Latency", "Automated ETL", "Self-Hosted"], "Flutter",
"Firebase",
"Firestore",
"Cloud Functions",
"Push Notifications",
],
metrics: [
"Real-time Match Engine",
"Cross-Platform (iOS/Android)",
"Multi-API Orchestration",
],
description: description:
"A data science pipeline tool built to explore high-speed processing and real-time visualization of large datasets.", "A social decision-making app that solves 'choice paralysis' by allowing groups to swipe on movies and TV shows, using a real-time matching algorithm to find common interests.",
images: ["/datasaur-1.jpg"], storyLabel: "UX // MOBILE SYNCHRONIZATION",
repoUrl: "https://git.georgew.dev/georgew/datasaur", images: [
liveUrl: "https://ratoong.com", "/projects/choosa/choosa-1.jpg",
engineeringStory: "/projects/choosa/choosa-2.jpg",
"In this high-stakes medical environment, I implemented a custom audit logging system that ensured every state change was immutable...", "/projects/choosa/choosa-3.jpg",
storyLabel: "DATA EFFICIENCY", "/projects/choosa/choosa-4.jpg",
isPrivate: true, "/projects/choosa/choosa-5.jpg",
],
isPrivate: false,
engineeringStory: `
Choosa was built to solve the universal problem of "choice paralysis" in social settings. The challenge was creating a low-latency, real-time environment where group preferences could be aggregated and matched instantaneously.
#### Real-time State & Match Logic
The core engine utilizes **Firestore's** real-time listeners to sync swipe states across multiple devices simultaneously. I architected a custom matching algorithm within **Firebase Cloud Functions** that monitors group sessions; the moment a consensus is reached, the system triggers **Firebase Cloud Messaging (FCM)** to send push notifications to all participants, ensuring a seamless transition from "deciding" to "watching."
#### Data Orchestration & External APIs
To provide a rich library of content, I integrated the **TMDB** and **Movie of the Night** APIs. By utilizing a middleware layer in Cloud Functions, I was able to normalize data from different sources, filter results based on user-specific streaming subscriptions, and cache results to minimize API overhead and latency.
#### Mobile Deployment & Native Experience
Developing Choosa in **Flutter** allowed for a unified codebase while maintaining native performance on both iOS and Android. I managed the full deployment lifecycle, from configuring **Fastlane** for automated App Store and Play Store releases to handling platform-specific requirements like adaptive icons and deep-linking.
`,
mermaidChart: `
graph LR
subgraph Client_Mobile [Mobile Frontend]
A[Flutter App]:::traffic
end
subgraph Firebase_Core [Backend Services]
Hub((Firebase SDK)):::hub
B[Auth]:::node
C[Firestore DB]:::node
D[Cloud Functions]:::node
E[Cloud Messaging]:::node
end
subgraph External_Data [Content Providers]
F[TMDB API]:::traffic
G[Movie of Night API]:::traffic
end
A <--> Hub
Hub --> B
Hub <-->|Sync State| C
Hub -->|Trigger Match| D
D -->|Push Notification| E
E -->|Alert Group| A
D --> F
D --> G
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff
`,
}, },
{ {
slug: "flutter-2", slug: "nutriveat",
category: "mobile", category: "mobile",
title: "Flutter-1", title: "Nutriveat",
subtitle: "Personal R&D Pipeline", subtitle: "AI-Powered Personalized Nutrition",
role: "Architect & Creator", role: "Lead Developer & Architect",
duration: "2025 — Present", duration: "2024 — Present",
stack: ["Python", "FastAPI", "Next.js", "Redis"], stack: [
metrics: ["Sub-50ms Latency", "Automated ETL", "Self-Hosted"], "Flutter",
"Firebase",
"OpenAI (GPT-4o)",
"Novita AI",
"StoreKit / Play Billing",
],
metrics: [
"Fine-tuned LLM Assistants",
"Direct Store Integrations",
"Multi-Tier Subscriptions",
],
description: description:
"A data science pipeline tool built to explore high-speed processing and real-time visualization of large datasets.", "A comprehensive health and nutrition platform that leverages fine-tuned generative AI to architect personalized meal plans, automate shopping lists, and provide real-time culinary assistance.",
images: ["/datasaur-1.jpg"], storyLabel: "AI ORCHESTRATION // MONETIZATION",
repoUrl: "https://git.georgew.dev/georgew/datasaur", images: [
liveUrl: "https://ratoong.com", "/projects/nutriveat/nutriveat-6.jpg",
engineeringStory: "/projects/nutriveat/nutriveat-1.jpg",
"In this high-stakes medical environment, I implemented a custom audit logging system that ensured every state change was immutable...", "/projects/nutriveat/nutriveat-2.jpg",
storyLabel: "DATA EFFICIENCY", "/projects/nutriveat/nutriveat-3.jpg",
isPrivate: true, "/projects/nutriveat/nutriveat-4.jpg",
"/projects/nutriveat/nutriveat-5.jpg",
],
isPrivate: false,
engineeringStory: `
Nutriveat represents a deep dive into the practical application of Large Language Models (LLMs) in a consumer-facing mobile environment. The goal was to move beyond a standard "chat wrapper" and create a deeply integrated tool that understands the nuance of dietary constraints, kitchen logistics, and user budgets.
#### Fine-Tuned AI & Structured Output
A major engineering hurdle was ensuring the AI generated valid, consistent, and safe meal plans. I implemented a system of fine-tuned system prompts and strict schema validation within **Cloud Functions** to force GPT-4o to return structured data. This allowed the app to take raw AI output and instantly transform it into actionable Firestore documents, shopping list items, and high-fidelity image prompts for **Novita AI**.
#### Native Subscription Architecture
To support the ongoing API costs of generative AI, I architected a robust multi-tier subscription model (Monthly/Annual). I implemented the monetization layer by integrating directly with the **Apple App Store (StoreKit)** and **Google Play Console (Billing Library)**. This involved architecting a custom server-side validation system in Cloud Functions to handle real-time subscription status, grace periods, and cross-platform entitlement logic without the use of third-party middleware.
#### Context-Aware Culinary Assistance
I developed a specialized AI Chatbot designed to function as a "Kitchen Assistant." Unlike general-purpose bots, this assistant is provided with the specific context of the user's current meal plan, dietary allergies, and available utensils. By using **RAG-lite (Retrieval-Augmented Generation)** principles, the bot can provide accurate unit conversions and tailored cooking instructions that respect the user's specific kitchen setup.
`,
mermaidChart: `
graph LR
subgraph Client_Mobile [Flutter Frontend]
A[Mobile App]:::traffic
end
subgraph Firebase_Backend [Control Plane]
Hub((Firebase SDK)):::hub
C[Firestore DB]:::node
D[Cloud Functions]:::node
end
subgraph AI_Orchestration [Intelligence Layer]
F[OpenAI / GPT-4o]:::node
G[Novita AI / Stable Diffusion]:::node
end
subgraph Store_Integrations [Native Billing]
H[App Store / Play Store]:::traffic
end
A <--> Hub
Hub <--> C
Hub --> D
D ==>|Fine-tuned Prompts| F
D ==>|Image Generation| G
F -.->|JSON Parsing| D
A <-->|Native IAP Flow| H
H -.->|Server-to-Server Hooks| D
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff
`,
}, },
]; ];

View file

@ -1,7 +1,15 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone' output: "standalone",
// assetPrefix:
// process.env.NODE_ENV === "production"
// ? "https://cdn.georgew.dev"
// : undefined,
// images: {
// loader: "imgix",
// path: "https://cdn.georgew.dev/",
// },
}; };
export default nextConfig; export default nextConfig;

1
public/forgejo.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="38.5 0.5 135 211"><style>.st3{fill:none;stroke:#d40000;stroke-width:15}</style><g transform="translate(6 6)"><path d="M58 168V70c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#f60;stroke-width:25"/><path d="M58 168v-30c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#d40000;stroke-width:25"/><circle cx="142" cy="20" r="18" style="fill:none;stroke:#f60;stroke-width:15"/><circle cx="142" cy="88" r="18" class="st3"/><circle cx="58" cy="180" r="18" class="st3"/></g></svg>

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

BIN
public/lab/dashboard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
public/lab/observatory.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
public/lab/paperless.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
public/lab/portainer.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
public/lab/surf-hub.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
public/lab/wikijs.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
public/lab/yamtrack.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

60
types/index.ts Normal file
View file

@ -0,0 +1,60 @@
import { ReactNode } from "react";
export interface CategoryCardProps {
href: string;
icon: ReactNode;
title: string;
description: string;
tech: string[];
hoverColor: string;
activeTechColor: string;
}
export interface PageLayoutProps {
children: ReactNode;
backLink?: string;
backLabel?: string;
maxWidth?: "5xl" | "6xl" | "7xl";
}
export interface Project {
slug: string;
category: "web" | "mobile" | "infrastructure";
title: string;
subtitle: string;
role: string;
duration: string;
stack: string[];
metrics: string[];
description: string;
engineeringStory: string;
storyLabel?: string;
images: string[];
liveUrl?: string;
repoUrl?: string;
mermaidChart?: string;
isPrivate: boolean;
}
export interface LabService {
id: string;
name: string;
description: string;
stack: string[];
visibility: "public" | "tailscale";
url?: string;
gitUrl?: string;
image: string;
uptimeId?: number;
}
export interface ForgeProject {
id: string;
name: string;
status: "Alpha" | "Beta" | "R&D";
engine: string;
description: string;
highlights: string[];
externalLink?: string;
changelog: { date: string; update: string }[];
}

View file

@ -1,18 +0,0 @@
export interface Project {
slug: string;
category: 'web' | 'mobile' | 'infrastructure';
title: string;
subtitle: string;
role: string;
duration: string;
stack: string[];
metrics: string[];
description: string;
engineeringStory: string;
storyLabel?: string;
images: string[];
liveUrl?: string;
repoUrl?: string;
mermaidChart?: string;
isPrivate: boolean;
}