Compare commits

..

No commits in common. "main" and "v0.0.6" have entirely different histories.
main ... v0.0.6

76 changed files with 361 additions and 6071 deletions

View file

View file

@ -1,8 +0,0 @@
{
"css.lint.unknownAtRules": "ignore",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}

View file

@ -10,7 +10,7 @@ steps:
privileged: true
settings:
build_args:
APP_VERSION: ${CI_COMMIT_TAG}
- APP_VERSION=${CI_COMMIT_TAG}
platforms: linux/amd64
registry: git.georgew.dev
repo: git.georgew.dev/georgew/${CI_REPO_NAME}
@ -40,4 +40,4 @@ steps:
- mkdir -p /home/george/$APP_NAME
- cp docker-compose.yaml /home/george/$APP_NAME/
- docker compose -p $APP_NAME -f /home/george/$APP_NAME/docker-compose.yaml pull
- docker compose -p $APP_NAME -f /home/george/$APP_NAME/docker-compose.yaml up -d --force-recreate --remove-orphans
- docker compose -p $APP_NAME -f /home/george/$APP_NAME/docker-compose.yaml up -d --force-recreate --remove-orphans

View file

@ -1,47 +1,36 @@
# Portfolio
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
A high-performance, containerized professional portfolio and R&D lab built with **Next.js 15**, **Tailwind CSS**, and **TypeScript**. This project serves as both a public showcase and a dynamic bridge to a private **Forgejo** instance for real-time development tracking.
## Getting Started
## 🏗️ Architecture Summary
- **Infrastructure**: Hosted on a **Hetzner** cloud node.
- **Containerization**: Fully Dockerized for **linux/amd64** architectures.
- **CI/CD**: Automated build and deployment via **Woodpecker CI**.
- **CDN**: Assets and media served via **Bunny CDN** for global performance.
- **Data Layer**: Dynamic changelog fetching from private repositories via **Forgejo API**.
## 🛠️ Technical Stack
- **Framework**: Next.js 15 (App Router).
- **Styling**: Tailwind CSS with a monospace "Technical Lab" aesthetic.
- **Icons**: Lucide React.
- **Deployment**: Docker Compose with `force-dynamic` runtime environment injection.
## 🚀 Deployment & Build
The project utilizes a specialized build process to ensure compatibility with the production Hetzner node environment:
### Manual Build
First, run the development server:
```bash
docker buildx build --platform linux/amd64 -t portfolio:latest .
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
### Automated Pipeline
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Deployments are triggered via Git Releases. The Woodpecker pipeline executes the following steps:
-Build: Compiles the Next.js application for linux/amd64.
-Push: Uploads the image to a private container registry.
-Deploy: Re-creates the container on the Hetzner node, injecting the FORGEJO_TOKEN from a secure .env file at runtime.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## 🧪 The_Forge Integration
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
"The Forge" is a dynamic project log system that provides a glimpse into active R&D cycles.
-Metadata: Configured via data/forge.ts for project-specific details.
-Real-time Logs: Fetched server-side from private changelog.md files.
-Status: Includes a conditional ACTIVE_STREAM pulse based on commit recency.
-Performance: Utilizes Next.js data caching with a 1-hour revalidation window.
## Learn More
---
To learn more about Next.js, take a look at the following resources:
Built for performance and technical transparency.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View file

@ -1,381 +0,0 @@
"use client";
import PageLayout from "@/components/PageLayout";
import { Mail, Globe, MapPin, ExternalLink, Server, Zap } from "lucide-react";
export default function CVPage() {
return (
<PageLayout backLink="/" maxWidth="5xl">
{/* Hide the print button itself during printing */}
<div className="flex justify-end mb-4 no-print">
<button
onClick={() => window.print()}
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-orange-600 text-white text-xs font-mono tracking-widest uppercase transition-colors rounded-sm"
>
<Zap size={14} /> Print PDF
</button>
</div>
{/* Main Container*/}
<div className="bg-white text-slate-900 p-8 md:p-16 rounded-sm shadow-2xl print:shadow-none print:p-12 print:m-0 font-sans print:text-[12pt] leading-normal">
<header className="border-b-4 border-slate-900 pb-8 print:pb-4 mb-8 print:mb-6 flex flex-col md:flex-row print:grid print:grid-cols-[1.5fr_1fr] justify-between items-start gap-6">
<div className="flex flex-wrap items-center gap-4 sm:gap-6 mb-4 print:mb-2 print:flex-nowrap">
<div className="shrink-0">
<img
src="/profile.jpg"
alt="George A. Webberley"
className="w-20 h-20 md:w-24 md:h-24 print:w-16 print:h-16 rounded-full object-cover border-2 border-slate-100 shadow-sm"
/>
</div>
<div className="min-w-0 flex-1">
<h1 className="text-3xl md:text-5xl print:text-3xl font-extrabold tracking-tighter mb-1 text-slate-900 lg:whitespace-nowrap print:whitespace-nowrap">
George A. Webberley
</h1>
<p className="text-lg md:text-2xl print:text-[14pt] text-orange-600 font-bold uppercase tracking-tight lg:whitespace-nowrap print:whitespace-nowrap">
Full Stack Engineer // Systems Architect
</p>
</div>
</div>
{/* Contact Info */}
<div className="space-y-1 text-sm print:text-[9px] text-slate-500 font-mono text-right print:w-full print:flex print:flex-col print:items-end">
<div className="flex items-center md:justify-end gap-2">
<MapPin size={14} /> Copenhagen, Denmark
</div>
<div className="flex items-center md:justify-end gap-2">
<Mail size={14} /> george@georgew.dev
</div>
<div className="flex items-center md:justify-end gap-2 text-orange-600 font-bold">
<Globe size={14} /> georgew.dev
</div>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-12 print:grid-cols-12 gap-8 print:gap-10">
{/* Main Column */}
<div className="lg:col-span-8 print:col-span-8 space-y-10 print:space-y-6">
<section>
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-4 print:mb-2 border-b border-slate-100 pb-2">
Professional Summary
</h2>
<p className="text-md print:text-sm leading-relaxed text-slate-700">
After 5 years as a dental surgeon, a serendipitous broken leg
led me to discover that software engineering perfectly suits my
analytical mind. Now an MSc (Distinction) graduate and Senior
Engineer, my core expertise lies in the entire product
lifecycle. Im less about a specific niche and more about the
whole stack. I enjoy the challenge of creating a clean frontend,
connecting a stable backend API, and building the infrastructure
that keeps it all running. I&apos;m always looking for the most
efficient way to get a project from &apos;concept&apos; to
&apos;shipped&apos;.
</p>
</section>
<section>
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-6 print:mb-3 border-b border-slate-100 pb-2">
Engineering Experience
</h2>
<div className="space-y-10 print:space-y-6">
{/* Brain+ Section */}
<div className="break-inside-avoid">
<div className="flex justify-between items-baseline mb-1">
<h3 className="text-xl print:text-base font-bold italic text-slate-900">
Brain+, Copenhagen
</h3>
<span className="text-sm print:text-xs font-mono font-bold text-orange-600 uppercase">
2022 Present
</span>
</div>
<p className="text-slate-500 font-bold mb-3 print:mb-1 italic text-sm print:text-xs">
Technical Lead & Senior Full Stack Developer
</p>
<ul className="list-disc ml-5 space-y-2 text-slate-600 text-sm print:text-[12px] print:leading-snug">
<li>
<strong>Product Ownership:</strong> I act as the bridge
between technical and product teams; I handle the full
product lifecycle from initial design and sprint planning
to final demos.
</li>
<li>
<strong>Infrastructure:</strong> Architected a resilient
multi-cloud setup focusing on high availability,
scalability, and security. I leveraged container
orchestration (K8s), modern load balancers, and VPC
security policies all managed through IaC (Terraform) to
ensure 24/7 reliability.
</li>
<li>
<strong>DevOps & Automation:</strong> Built automated
CI/CD pipelines using GitHub Actions and Fastlane for
mobile releases. I utilize Kubectl and custom Makefiles to
streamline cluster management and standardize local
development environments.
</li>
<li>
<strong>Full Stack Delivery:</strong> Scaled core features
using Ruby on Rails REST API services and Flask, while
managing the rigorous documentation and release processes
required for high-stakes medical compliance.
</li>
</ul>
</div>
{/* Other Roles */}
<div className="space-y-8 print:space-y-4">
{/* StageUp */}
<div className="break-inside-avoid">
<div className="flex justify-between items-baseline mb-1">
<h4 className="text-lg print:text-sm font-bold italic text-slate-800">
StageUp, Cardiff
</h4>
<span className="text-xs font-mono text-slate-400 uppercase">
2021 2022
</span>
</div>
<p className="text-sm print:text-xs text-slate-600 mb-3 print:mb-1.5 leading-relaxed">
Delivered new features and backend logic for a startup
platform, while maintaining the GCP infrastructure and
deployment pipelines.
</p>
<div className="flex flex-wrap gap-1.5">
{[
"Angular",
"Node.js",
"PostgreSQL",
"Terraform",
"Docker",
"GCP",
].map((t) => (
<span
key={t}
className="text-[9px] print:text-[8px] font-bold px-1.5 py-0.5 bg-slate-50 border border-slate-200 text-slate-500 rounded-sm uppercase"
>
{t}
</span>
))}
</div>
</div>
{/* Startemup */}
<div className="break-inside-avoid">
<div className="flex justify-between items-baseline mb-1">
<h4 className="text-lg print:text-sm font-bold italic text-slate-800">
Startemup, Ontario
</h4>
<span className="text-xs font-mono text-slate-400 uppercase">
2021 2023
</span>
</div>
<p className="text-sm print:text-xs text-slate-600 mb-3 print:mb-1.5 leading-relaxed">
Technical troubleshooter for complex WordPress
customizations requiring bespoke PHP logic and deep
performance optimization.
</p>
<div className="flex flex-wrap gap-1.5">
{[
"PHP",
"WordPress",
"Performance Optimization",
"Bespoke Logic",
].map((t) => (
<span
key={t}
className="text-[9px] print:text-[8px] font-bold px-1.5 py-0.5 bg-slate-50 border border-slate-200 text-slate-500 rounded-sm uppercase"
>
{t}
</span>
))}
</div>
</div>
{/* Ratoong */}
<div className="break-inside-avoid">
<div className="flex justify-between items-baseline mb-1">
<h4 className="text-lg print:text-sm font-bold italic text-slate-800">
Ratoong, Copenhagen
</h4>
<span className="text-xs font-mono text-slate-400 uppercase">
2020 2022
</span>
</div>
<p className="text-sm print:text-xs text-slate-600 mb-3 print:mb-1.5 leading-relaxed">
Leading the end-to-end development of a functional SPA,
moving from initial UI designs to a live production
environment.
</p>
<div className="flex flex-wrap gap-1.5">
{["Angular", "Firebase", "GCP", "SPA Architecture"].map(
(t) => (
<span
key={t}
className="text-[9px] print:text-[8px] font-bold px-1.5 py-0.5 bg-slate-50 border border-slate-200 text-slate-500 rounded-sm uppercase"
>
{t}
</span>
),
)}
</div>
</div>
</div>
</div>
</section>
</div>
{/* Sidebar */}
<div className="lg:col-span-4 print:col-span-4 space-y-10 print:space-y-6">
<section>
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-6 print:mb-3 border-b border-slate-100 pb-2 text-slate-900">
Technical Mastery
</h2>
<div className="space-y-6 print:space-y-4">
<div>
<p className="text-[10px] font-black uppercase mb-2 text-slate-500">
Infrastructure & Ops
</p>
<div className="flex flex-wrap gap-1.5">
{[
"Kubernetes",
"Docker",
"Terraform",
"GCP",
"OTC",
"CI/CD",
"VPC",
].map((s) => (
<span
key={s}
className="px-2 py-0.5 bg-slate-900 text-white text-[10px] print:text-[8px] font-bold rounded-sm uppercase"
>
{s}
</span>
))}
</div>
</div>
<div>
<p className="text-[10px] font-black uppercase mb-2 text-slate-500">
Full Stack Engineering
</p>
<div className="flex flex-wrap gap-1.5">
{[
"TypeScript",
"Flutter",
"Python",
"React",
"Angular",
"Node.js",
"Rails",
"PostgreSQL",
"NoSQL",
].map((s) => (
<span
key={s}
className="px-2 py-0.5 bg-slate-100 text-slate-700 text-[10px] print:text-[8px] font-bold rounded-sm uppercase"
>
{s}
</span>
))}
</div>
</div>
</div>
</section>
<section>
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-6 print:mb-3 border-b border-slate-100 pb-2">
Education
</h2>
<div className="space-y-4 print:space-y-2">
<div>
<p className="font-bold text-slate-900 text-sm print:text-xs">
MSc Computer Science
</p>
<p className="text-[10px] text-orange-600 font-bold uppercase tracking-tighter">
Distinction
</p>
<p className="text-[10px] text-slate-400 italic">
Univ. of Bristol
</p>
</div>
<div>
<p className="font-bold text-slate-900 text-sm print:text-xs">
Bachelor of Dental Surgery
</p>
<p className="text-[10px] text-slate-400 italic">
Univ. of Bristol (Merit)
</p>
</div>
</div>
</section>
<section>
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-6 print:mb-3 border-b border-slate-100 pb-2 text-slate-900">
Systems Tinkering
</h2>
<div className="space-y-6 print:space-y-3">
<div>
<div className="flex items-center gap-2 mb-1">
<Server size={14} className="text-orange-600" />
<p className="font-bold text-slate-900 text-xs">
Cloud-Hybrid Laboratory
</p>
</div>
<p className="text-[11px] print:text-[10px] text-slate-600 leading-snug">
I manage a suite of self-hosted services. A playground for
breaking things in private. I use{" "}
<strong>Tailscale and Woodpecker CI</strong> to orchestrate
everything from <strong>Grafana surf dashboards</strong> to
personal wikis.
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<Zap size={14} className="text-orange-600" />
<p className="font-bold text-slate-900 text-xs">
Product Prototyping
</p>
</div>
<p className="text-[12px] print:text-[10px] text-slate-600 leading-snug">
Building quirky apps like a &quot;not-pokemon&quot; pet
collecting game and a space-rocket countdown dashboard.
Check out my <strong>portfolio website</strong> for full
details!
</p>
</div>
</div>
</section>
<section>
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-6 print:mb-3 border-b border-slate-100 pb-2 text-slate-900">
Human
</h2>
<p className="text-[12px] print:text-[10px] text-slate-600 leading-relaxed italic">
Surfing, photography, and following space news. Usually found
watching anime while tinkering with my server stack. Currently
learning Danish (undskyld forhånd).
</p>
</section>
<div className="pt-4 print:pt-2">
<div className="p-4 print:p-3 bg-orange-50 rounded-sm border-l-4 border-orange-600">
<p className="text-[10px] font-bold text-orange-600 uppercase mb-2 print:mb-1">
Portfolio Deep Dive
</p>
<p className="text-[12px] print:text-[10px] text-slate-700 mb-3 print:mb-1.5 leading-tight">
Detailed architecture diagrams and documentation:
</p>
<a
href="https://georgew.dev"
className="text-sm print:hidden font-black flex items-center gap-1 hover:underline text-slate-900"
>
GEORGEW.DEV <ExternalLink size={14} />
</a>
<div className="hidden print:block text-sm font-black text-slate-900">
GEORGEW.DEV
</div>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
);
}

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,33 +0,0 @@
export const dynamic = "force-dynamic";
import ForgeUI from "@/components/ForgeUI";
import PageLayout from "@/components/PageLayout";
import { getAllForgeProjects } from "@/lib/git";
import { Hammer } from "lucide-react";
export default async function ForgePage() {
const projects = await getAllForgeProjects();
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 font-mono">
The Forge
</h1>
</div>
<p className="text-neutral-500 max-w-2xl text-sm font-mono leading-relaxed">
The Forge is my active development logs, providing a glimpse into my
current projects and future potential releases.
</p>
</header>
<div className="space-y-24">
{projects.map((project) => (
<ForgeUI key={project.id} project={project} />
))}
</div>
</PageLayout>
);
}

View file

@ -1,5 +1,4 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
@ -25,68 +24,3 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
.prose {
width: 100%;
max-width: none;
word-break: break-word;
}
@media print {
@page {
size: A4;
margin: 0 !important; /* Use 0 here, and handle padding in the React component */
-webkit-margin-before: 0;
-webkit-margin-after: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
/* Force the body to be the exact width of A4 to prevent scaling */
body {
width: 210mm;
height: 297mm;
margin: 0;
padding: 0;
background: white !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Hide everything outside the CV content */
#safari-extension-is-installed,
main a[href="/"],
main a[href*="back"],
footer,
.no-print {
display: none !important;
}
/* Ensure the PageLayout wrapper doesn't add width or centering */
main {
display: block !important;
min-height: auto !important;
padding: 0 !important;
margin: 0 !important;
background: white !important;
color: black !important;
}
/* 3. Ensure the inner container doesn't push content down */
main > div {
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
}
/* 4. Fix the "page break" issue after the title */
h1,
header {
break-after: avoid;
break-inside: avoid;
}
}

View file

@ -1,139 +0,0 @@
"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>
);
}

View file

@ -1,136 +0,0 @@
"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

@ -13,11 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "George W.",
description: "Portfolio site",
icons: {
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
},
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({

View file

@ -1,264 +1,151 @@
"use client";
import Link from "next/link";
import { motion, Variants } from "framer-motion";
import { Globe, Smartphone, Server, Hammer } from "lucide-react";
import { useState } from "react";
import MonitorCard from "@/components/MonitorCard";
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" },
},
};
import { motion } from "framer-motion";
import { Server, Globe, Smartphone, Gamepad2, Activity } from "lucide-react";
export default function Home() {
const [isHoveringMonitors, setIsHoveringMonitors] = useState(false);
return (
<PageLayout maxWidth="7xl">
<motion.div
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>
<p className="text-neutral-400 mt-2">
Senior Full Stack Engineer & Tech Lead
</p>
<div className="flex gap-6 text-[10px] font-mono tracking-[0.2em] uppercase mt-4">
<a
href="https://git.georgew.dev/georgew"
className="text-neutral-500 hover:text-white transition-colors"
>
Git
</a>
<a
href="https://www.linkedin.com/in/george-webberley/"
className="text-neutral-500 hover:text-white transition-colors"
>
LinkedIn
</a>
<Link
href="/cv"
className="text-neutral-500 hover:text-white transition-colors"
>
CV
</Link>
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24">
{/* Header section */}
<header className="mb-12">
<h1 className="text-4xl font-bold tracking-tight">George W.</h1>
<p className="text-neutral-400 mt-2">Senior Full Stack Engineer & Tech Lead</p>
</header>
{/* Bento Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 auto-rows-[180px]">
{/* About Me - Large Card */}
<motion.div
whileHover={{ y: -5 }}
className="md:col-span-2 md:row-span-2 p-8 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-between"
>
<div>
<h2 className="text-2xl font-semibold mb-4">The Architect</h2>
<p className="text-neutral-400 leading-relaxed">
Engineering high-scale web systems and mobile experiences.
Passionate about self-hosting, clean architecture, and performance.
</p>
</div>
</motion.header>
<div className="flex gap-2 text-xs font-mono text-neutral-500">
<span>#NextJS</span> <span>#Flutter</span> <span>#Typescript</span> <span>#Python</span> <span>#Node</span> <span>#Docker</span> <span>#Kubernetes</span> <span>#Serverless</span> <span>#CI/CD</span>
</div>
</motion.div>
{/* Main Bento Grid */}
<div className="grid grid-cols-1 md:grid-cols-6 gap-6">
{/* Top Row Left: Technical Focus */}
<motion.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"
>
<div className="flex-[1.5] flex flex-col justify-between relative z-10">
<div>
<h2 className="text-3xl font-bold mb-4 tracking-tight">
Technical Focus
</h2>
<p className="text-base text-neutral-400 leading-relaxed max-w-lg">
Bridging the gap between rigid regulatory requirements and
fluid user experiences. I specialize in designing{" "}
<span className="text-white">distributed systems</span> and
<span className="text-white">
{" "}
cross-platform mobile apps
</span>{" "}
with a focus on automated delivery and high-integrity code.
</p>
</div>
{/* Live Pulse Card */}
<motion.div
whileHover={{ y: -5 }}
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]"
>
{/* The Monitor Map (Easily editable) */}
{(() => {
const monitors = [
{ id: 2, name: "Datasaur" },
{ id: 6, name: "Audiobookshelf" },
{ id: 7, name: "Woodpecker CI" },
{ id: 8, name: "Forgejo Git" },
{ id: 9, name: "Server dashboard" },
{ id: 10, name: "Ratoong" },
];
<div className="flex flex-wrap gap-2 mt-8">
{[
"#Architecture",
"#Regulatory Compliance",
"#Agile Leadership",
"#DevOps",
].map((tag) => (
<span
key={tag}
className="text-[10px] font-mono text-neutral-500 border border-neutral-800 px-2 py-1 rounded"
>
{tag}
</span>
))}
</div>
</div>
<div className="hidden md:block w-px bg-neutral-800/50 self-stretch" />
<div className="flex-1 flex flex-col justify-around py-2 relative z-10">
<div className="space-y-6">
<TechnicalFocus
label="Leadership"
color="text-blue-500"
text="Tech Lead & Scrum Master. Orchestrating sprint cycles and system design."
/>
<TechnicalFocus
label="Integrity"
color="text-purple-500"
text="Medical/Regulatory environments, QMS, and Cyber Essentials."
/>
<TechnicalFocus
label="Infrastructure"
color="text-green-500"
text="Kubernetes, GCP, and automated CI/CD pipelines."
/>
</div>
</div>
</motion.div>
{/* Top Row Right: The Service Registry */}
<Link href="/lab" className="md:col-span-2 flex flex-col group">
<motion.div
variants={itemVariants}
whileHover={{ y: -5 }}
onMouseEnter={() => setIsHoveringMonitors(true)}
onMouseLeave={() => setIsHoveringMonitors(false)}
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} />
</motion.div>
</Link>
{/* Project Category Cards */}
<CategoryCard
href="/projects/web"
icon={<Globe className="text-blue-400 w-6 h-6 mb-4" />}
title="Web Systems"
description="Architecting distributed platforms with a focus on high-availability."
tech={["Next.js", "Python", "Node.js", "Caddy", "PostgreSQL"]}
hoverColor="hover:border-blue-500/30"
activeTechColor="group-hover:text-blue-400"
/>
<CategoryCard
href="/projects/mobile"
icon={<Smartphone className="text-purple-400 w-6 h-6 mb-4" />}
title="Mobile Apps"
description="Building fluid, cross-platform experiences using reactive state."
tech={["Android", "iOS", "Flutter", "Riverpod", "Stores"]}
hoverColor="hover:border-purple-500/30"
activeTechColor="group-hover:text-purple-400"
/>
<CategoryCard
href="/infrastructure"
icon={<Server className="text-green-400 w-6 h-6 mb-4" />}
title="Infrastructure"
description="Resilient cloud environments with automated IaC and multi-region orchestration."
tech={["Kubernetes", "Terraform", "GCP", "CI/CD", "Security"]}
hoverColor="hover:border-green-500/30"
activeTechColor="group-hover:text-green-400"
/>
{/* Bottom Row: The Forge */}
<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">
<Hammer className="text-orange-500 w-8 h-8 group-hover:rotate-12 transition-transform" />
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<h3 className="font-bold text-xl tracking-tight">
The Forge
</h3>
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
return (
<>
{/* Default View */}
<div className="flex items-center justify-between w-full group-hover:opacity-0 group-hover:pointer-events-none transition-opacity duration-300">
<div>
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="font-medium text-white">Hetzner Node-01</span>
</div>
<div className="flex gap-2 items-center">
<p className="text-sm text-neutral-500">System Status:</p>
<img
src="https://status.georgew.dev/api/status-page/dashboard/badge"
alt="Overall Status"
className="h-5"
/>
</div>
</div>
<Activity className="text-neutral-700 w-8 h-8" />
</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>
</div>
</motion.div>
</Link>
{/* Hover View: Friendly Names */}
<div className="absolute inset-0 p-6 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-center bg-neutral-900/95 backdrop-blur-sm">
<h4 className="text-[10px] font-mono text-neutral-500 mb-3 uppercase tracking-[0.2em]">Service Registry</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{monitors.map((m) => (
<div key={m.id} className="flex items-center justify-between bg-neutral-800/40 p-2 rounded-lg border border-neutral-700/30">
<span className="text-[11px] font-medium text-neutral-300 truncate mr-2">{m.name}</span>
<div className="flex gap-1 shrink-0">
<img src={`https://status.georgew.dev/api/badge/${m.id}/status`} className="h-3" alt="up" />
<img src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`} className="h-3 opacity-60" alt="ms" />
</div>
</div>
))}
</div>
</div>
</>
);
})()}
</motion.div>
{/* Project One */}
<motion.div
whileHover={{ scale: 1.02 }}
className="md:col-span-1 p-6 rounded-3xl bg-[#1a1a1a] border border-neutral-800 flex flex-col justify-end group cursor-pointer"
>
<Globe className="mb-auto text-blue-400" />
<h3 className="font-semibold mt-4">Prod Website</h3>
<p className="text-xs text-neutral-500">Case Study 01</p>
</motion.div>
{/* Project Two */}
<motion.div
whileHover={{ scale: 1.02 }}
className="md:col-span-1 p-6 rounded-3xl bg-[#1a1a1a] border border-neutral-800 flex flex-col justify-end cursor-pointer"
>
<Smartphone className="mb-auto text-purple-400" />
<h3 className="font-semibold mt-4">Mobile App</h3>
<p className="text-xs text-neutral-500">Active Dev</p>
</motion.div>
{/* Game Teaser / The Lab */}
<motion.div
whileHover={{ scale: 1.02 }}
className="md:col-span-2 p-6 rounded-3xl bg-gradient-to-br from-[#111] to-[#1a1a1a] border border-neutral-800 flex items-center gap-6"
>
<div className="p-4 rounded-2xl bg-neutral-800">
<Gamepad2 className="w-8 h-8 text-orange-400" />
</div>
<div>
<h3 className="font-semibold italic">The Forge</h3>
<p className="text-sm text-neutral-500">Indie Game Dev & Prototypes</p>
</div>
</motion.div>
</div>
{/* Deployment Footer */}
<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>
<img
src="https://ci.georgew.dev/api/badges/11/status.svg"
alt="Build Status"
className="h-3 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>
</motion.div>
</PageLayout>
);
}
function TechnicalFocus({
label,
color,
text,
}: {
label: string;
color: string;
text: string;
}) {
return (
<section>
<h4
className={`text-[12px] font-mono ${color} uppercase tracking-[0.2em] mb-2`}
>
{label}
</h4>
<p className="text-xs text-neutral-300 leading-tight">{text}</p>
</section>
);
}
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}
<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>
<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>
</footer>
</main>
);
}
}

View file

@ -1,203 +0,0 @@
"use client";
import { use } from "react";
import { motion, Variants } from "framer-motion";
import { ExternalLink, Github, ShieldCheck, Cpu, Users } from "lucide-react";
import { PROJECT_REGISTRY } from "@/data/projects";
import Mermaid from "@/components/Mermaid";
import ProjectShowcase from "@/components/ProjectShowcase";
import ImageCarousel from "@/components/ImageCarousel";
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({
params,
}: {
params: Promise<{ category: string; slug: string }>;
}) {
const { category, slug } = use(params);
const project = PROJECT_REGISTRY.find((p) => p.slug === slug);
if (!project) {
return (
<PageLayout backLink={`/projects/${category}`}>
<div className="font-mono text-white">Project Log Not Found.</div>
</PageLayout>
);
}
return (
<PageLayout
backLink={
category == "infrastructure"
? "/infrastructure"
: `/projects/${category}`
}
backLabel={`Back to ${category}`}
maxWidth="6xl"
>
<motion.div
variants={containerVariants}
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">
{project.title}
</h1>
<p className="text-blue-500 font-mono text-sm uppercase tracking-widest mb-6">
{project.subtitle}
</p>
<p className="text-neutral-400 text-lg leading-relaxed">
{project.description}
</p>
<div className="flex gap-4 mt-8">
{project.liveUrl && (
<a
href={project.liveUrl}
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} />
</a>
)}
{project?.repoUrl && (
<a
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-colors"
>
View Source <Github size={14} />
</a>
)}
</div>
</div>
{/* Stats Sidebar */}
<div className="bg-neutral-900/30 border border-neutral-800 p-8 rounded-3xl h-fit backdrop-blur-sm">
<div className="space-y-8 font-mono">
<StatItem
icon={<ShieldCheck className="text-blue-500" />}
label="My Role"
value={project.role}
/>
<StatItem
icon={<Cpu className="text-purple-500" />}
label="Stack"
value={project.stack.join(", ")}
/>
<StatItem
icon={<Users className="text-green-500" />}
label="Impact"
value={project.metrics.join(" • ")}
/>
</div>
</div>
</motion.header>
{/* Media Showcase */}
<motion.section variants={sectionVariants} className="mb-20">
{project.category === "mobile" ? (
<MobileStack images={project.images} />
) : (
<>
<div className="hidden lg:block">
<ProjectShowcase images={project.images} />
</div>
<div className="block lg:hidden">
<ImageCarousel images={project.images} />
</div>
</>
)}
<p className="mt-6 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]">
Interactive Gallery Select or swipe to explore
</p>
</motion.section>
{/* System Architecture */}
{project.mermaidChart && (
<motion.section variants={sectionVariants} className="mb-16">
<div className="flex items-center gap-3 mb-8">
<div className="h-px flex-1 bg-neutral-900" />
<h3 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.3em]">
System Architecture Log
</h3>
<div className="h-px flex-1 bg-neutral-900" />
</div>
<div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12">
<Mermaid chart={project.mermaidChart} />
</div>
</motion.section>
)}
{/* Engineering Narrative */}
<motion.section
variants={sectionVariants}
className="w-full pb-20 mt-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">
PROJECT LOG // {project.storyLabel || "NARRATIVE"}
</h3>
<div className="h-px flex-1 bg-neutral-900" />
<h2 className="text-xl font-bold tracking-tighter">
The Engineering Story
</h2>
</div>
<div className="prose prose-invert prose-neutral max-w-none">
<ReactMarkdown>{project.engineeringStory}</ReactMarkdown>
</div>
</motion.section>
</motion.div>
</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,122 +0,0 @@
"use client";
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 PageLayout from "@/components/PageLayout";
const CATEGORY_META = {
web: {
title: "Web Systems",
icon: <Globe className="w-8 h-8 text-blue-400" />,
description:
"Architecting scalable web applications and distributed systems.",
},
mobile: {
title: "Mobile Apps",
icon: <Smartphone className="w-8 h-8 text-purple-400" />,
description:
"Building cross-platform experiences with Flutter and native integrations.",
},
infrastructure: {
title: "DevOps & Infrastructure",
icon: <Server className="w-8 h-8 text-green-400" />,
description:
"Self-hosted systems architecture and automated deployment pipelines.",
},
};
// 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({
params,
}: {
params: Promise<{ category: string }>;
}) {
const resolvedParams = use(params);
const category = resolvedParams.category;
const meta = CATEGORY_META[category as keyof typeof CATEGORY_META];
const filteredProjects = PROJECT_REGISTRY.filter(
(p) => p.category === category,
);
if (!meta) return <PageLayout backLink="/">Sector not found.</PageLayout>;
return (
<PageLayout backLink="/" maxWidth="6xl">
<div className="mb-16">
<h1 className="flex items-center gap-4 text-5xl font-bold tracking-tighter mb-6">
{meta.icon} {meta.title}
</h1>
<p className="text-xl text-neutral-400 max-w-2xl leading-relaxed">
{meta.description}
</p>
</div>
{/* 2. The container manages the entrance of all children */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-1 gap-6"
>
{filteredProjects.map((project) => (
<Link
key={project.slug}
href={`/projects/${category}/${project.slug}`}
className="block"
>
<motion.div
layout
variants={itemVariants}
whileHover={{ x: 8 }}
className="group p-8 rounded-3xl bg-neutral-900/40 border border-neutral-800 hover:border-neutral-700 transition-colors"
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<h3 className="text-2xl font-bold mb-2 group-hover:text-blue-400 transition-colors">
{project.title}
</h3>
<p className="text-neutral-500 text-sm max-w-xl">
{project.description}
</p>
</div>
<div className="flex flex-wrap gap-2 md:justify-end">
{project.stack.map((tech) => (
<span
key={tech}
className="text-[9px] font-mono bg-neutral-800 text-neutral-400 px-3 py-1 rounded-full uppercase tracking-widest border border-neutral-800"
>
{tech}
</span>
))}
</div>
</div>
</motion.div>
</Link>
))}
</motion.div>
</PageLayout>
);
}

View file

@ -1,31 +0,0 @@
"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,131 +0,0 @@
"use client";
import Image from "next/image";
import { History, ExternalLink, Cpu } from "lucide-react";
import { ForgeProject } from "@/types";
export default function ForgeUI({ project }: { project: ForgeProject }) {
return (
<div className="bg-neutral-900/40 border border-neutral-800 rounded-3xl p-8 pt-0 mb-12 overflow-hidden">
{/* Upper Section: Hero Area */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8 flex flex-col justify-center">
<div className="flex flex-wrap justify-between items-start gap-6 mb-6">
<div>
<h2 className="text-4xl font-bold text-white mb-4 tracking-tighter">
{project.projectName}
</h2>
<div className="flex flex-wrap items-center gap-3">
{/* Version Badge */}
<span className="px-3 py-1 bg-orange-500/10 border border-orange-500/20 text-orange-500 text-[10px] font-bold uppercase tracking-widest rounded-full">
{project.currentVersion}
</span>
{/* Engine Tag */}
<span className="px-3 py-1 bg-neutral-800 text-neutral-400 text-[10px] font-bold uppercase tracking-widest rounded-full flex items-center gap-2">
<Cpu size={12} /> {project.engine}
</span>
{/* Status Indicator (Now part of the tag row) */}
<div className="flex items-center gap-2 text-[9px] font-mono text-green-500/60 ml-1">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"></span>
</span>
ACTIVE STREAM
</div>
</div>
</div>
{project.externalLink && (
<a
href={project.externalLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs font-bold text-white bg-neutral-800 hover:bg-neutral-700 transition-colors px-4 py-2 rounded-xl shrink-0"
>
Project Details <ExternalLink size={14} />
</a>
)}
</div>
<p className="text-neutral-400 text-base leading-relaxed max-w-2xl">
{project.description}
</p>
</div>
{project.imagePath && (
<div className="lg:col-span-4 flex justify-center lg:justify-end items-center">
<div className="relative w-full aspect-square max-w-[280px] group">
<Image
src={project.imagePath}
alt={`${project.projectName} Feature`}
fill
className="object-contain drop-shadow-[0_0_40px_rgba(249,115,22,0.15)] transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-orange-500/10 blur-[80px] rounded-full -z-10 opacity-40 group-hover:opacity-60 transition-opacity" />
</div>
</div>
)}
</div>
{/* Technical Highlights */}
<div className="flex flex-wrap gap-2 mb-12">
{project.highlights.map((tag) => (
<span
key={tag}
className="text-[10px] text-neutral-600 font-mono uppercase tracking-tighter border border-neutral-800 px-3 py-1 rounded"
>
{`// ${tag}`}
</span>
))}
</div>
{/* Lower Section: Logs with Gradient Fade */}
<div className="space-y-8">
<div 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} /> Dev_Log
</div>
{/* This container handles the fade effect */}
<div
className="space-y-8 relative"
style={{
maskImage:
"linear-gradient(to bottom, black 60%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 60%, transparent 100%)",
}}
>
{project.changelog.map((entry) => (
<div
key={entry.version}
className="relative pl-8 border-l border-neutral-800"
>
<div className="absolute -left-1.5 top-1.5 w-3 h-3 bg-neutral-950 border border-neutral-700 rounded-full" />
<div className="flex items-baseline gap-4 mb-3">
<span className="text-orange-500 font-mono text-sm font-bold leading-none">
{entry.version}
</span>
<span className="text-neutral-600 font-mono text-xs leading-none">
{entry.date}
</span>
</div>
<ul className="space-y-2.5">
{entry.changes.map((change, idx) => (
<li
key={idx}
className="text-neutral-400 text-sm leading-relaxed max-w-4xl"
>
{change}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,138 +0,0 @@
"use client";
import Image from "next/image";
import { History, ExternalLink, Cpu } from "lucide-react";
import { ForgeProject } from "@/types";
export default function ForgeUI({ project }: { project: ForgeProject }) {
return (
<div className="bg-neutral-900/40 border border-neutral-800 rounded-3xl p-8 pt-0 mb-12 overflow-hidden">
{/* Upper Section: Hero Area */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pt-4">
{" "}
{/* Added pt-8 here to replace the p-8 pt-0 conflict */}
<div className="lg:col-span-8 flex flex-col justify-center">
<div className="flex flex-wrap justify-between items-start gap-6 mb-6">
<div>
<h2 className="text-4xl font-bold text-white mb-4 tracking-tighter">
{project.projectName}
</h2>
<div className="flex flex-wrap items-center gap-3">
<span className="px-3 py-1 bg-orange-500/10 border border-orange-500/20 text-orange-500 text-[10px] font-bold uppercase tracking-widest rounded-full">
{project.currentVersion}
</span>
<span className="px-3 py-1 bg-neutral-800 text-neutral-400 text-[10px] font-bold uppercase tracking-widest rounded-full flex items-center gap-2">
<Cpu size={12} /> {project.engine}
</span>
{project.isRecent && (
<div className="flex items-center gap-2 text-[9px] font-mono text-green-500/60 ml-1">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"></span>
</span>
ACTIVE STREAM
</div>
)}
</div>
</div>
{project.externalLink && (
<a
href={project.externalLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs font-bold text-white bg-neutral-800 hover:bg-neutral-700 transition-colors px-4 py-2 rounded-xl shrink-0"
>
Project Details <ExternalLink size={14} />
</a>
)}
</div>
<p className="text-neutral-400 text-base leading-relaxed max-w-2xl">
{project.description}
</p>
</div>
{project.imagePath && (
<div className="lg:col-span-4 flex justify-center lg:justify-end items-center">
<div className="relative w-full aspect-square max-w-[280px] group">
<Image
src={project.imagePath}
alt={`${project.projectName} Feature`}
fill
className="object-contain drop-shadow-[0_0_40px_rgba(249,115,22,0.15)] transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute inset-0 bg-orange-500/10 blur-[80px] rounded-full -z-10 opacity-40 group-hover:opacity-60 transition-opacity" />
</div>
</div>
)}
</div>
{/* Technical Highlights */}
<div className="flex flex-wrap gap-2 mb-12 mt-6">
{project.highlights.map((tag) => (
<span
key={tag}
className="text-[10px] text-neutral-600 font-mono uppercase tracking-tighter border border-neutral-800 px-3 py-1 rounded"
>
{`// ${tag}`}
</span>
))}
</div>
{/* Lower Section: Logs */}
<div className="space-y-8">
<div 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} /> Dev_Log
</div>
<div
className="space-y-10 relative pb-4" // Increased space between version blocks
style={{
maskImage:
"linear-gradient(to bottom, black 60%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 60%, transparent 100%)",
}}
>
{project.changelog.map((entry) => (
<div
key={entry.version}
className="ml-1.5 relative pl-10 border-l border-neutral-800" // Increased pl-8 to pl-10 to fix clipping
>
{/* The Version Indicator Circle */}
<div className="absolute -left-1.5 top-1 w-3 h-3 bg-neutral-950 border border-neutral-700 rounded-full" />
<div className="flex items-baseline gap-4 mb-4">
<span className="text-orange-500 font-mono text-sm font-bold leading-none">
{entry.version}
</span>
<span className="text-neutral-600 font-mono text-[10px] uppercase tracking-wider leading-none">
{entry.date}
</span>
</div>
{/* Individual Changes with Bullet Points */}
<ul className="space-y-3">
{entry.changes.map((change, idx) => (
<li
key={idx}
className="text-neutral-400 text-sm leading-relaxed max-w-4xl flex items-start gap-3"
>
{/* The Bullet Character */}
<span className="text-orange-500/40 mt-1.5 shrink-0 text-[10px]">
</span>
<span>{change}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,112 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface GalleryProps {
images: string[];
}
export default function ImageCarousel({ images }: GalleryProps) {
const [[page, direction], setPage] = useState([0, 0]);
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
const imageIndex = Math.abs(page % images.length);
const paginate = useCallback(
(newDirection: number) => {
setPage([page + newDirection, newDirection]);
},
[page],
);
useEffect(() => {
if (!isAutoPlaying) return;
const interval = setInterval(() => {
paginate(1);
}, 5000);
return () => clearInterval(interval);
}, [paginate, isAutoPlaying]);
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
opacity: 0,
}),
center: { zIndex: 1, x: 0, opacity: 1 },
exit: (direction: number) => ({
zIndex: 0,
x: direction < 0 ? 1000 : -1000,
opacity: 0,
}),
};
return (
<div
className="relative aspect-video w-full overflow-hidden rounded-3xl border border-neutral-800 bg-neutral-900"
onMouseEnter={() => setIsAutoPlaying(false)}
onMouseLeave={() => setIsAutoPlaying(true)}
>
<AnimatePresence initial={false} custom={direction}>
<motion.img
key={page}
src={images[imageIndex]}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
onDragStart={() => setIsAutoPlaying(false)}
onDragEnd={(e, { offset }) => {
const swipe = Math.abs(offset.x) > 50;
if (swipe) paginate(offset.x > 0 ? -1 : 1);
}}
className="absolute h-full w-full object-cover cursor-grab active:cursor-grabbing"
/>
</AnimatePresence>
{/* Navigation Arrows */}
<div className="absolute inset-0 z-10 flex items-center justify-between p-4 pointer-events-none">
<button
className="p-2 rounded-full bg-black/50 backdrop-blur-md border border-white/10 text-white pointer-events-auto hover:bg-black/80 transition-all"
onClick={() => {
setIsAutoPlaying(false);
paginate(-1);
}}
>
<ChevronLeft size={24} />
</button>
<button
className="p-2 rounded-full bg-black/50 backdrop-blur-md border border-white/10 text-white pointer-events-auto hover:bg-black/80 transition-all"
onClick={() => {
setIsAutoPlaying(false);
paginate(1);
}}
>
<ChevronRight size={24} />
</button>
</div>
{/* Progress Bar */}
{isAutoPlaying && (
<motion.div
key={imageIndex}
initial={{ width: "0%" }}
animate={{ width: "100%" }}
transition={{ duration: 5, ease: "linear" }}
className="absolute bottom-0 left-0 h-1 bg-blue-500/50 z-20"
/>
)}
</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

@ -1,10 +0,0 @@
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>
);
}

View file

@ -1,104 +0,0 @@
"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

@ -1,192 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Activity } from "lucide-react";
import Image from "next/image";
const MONITORS = [
{ id: 2, name: "Datasaur" },
{ id: 12, name: "Observatory" },
{ id: 16, name: "Fossil tracker" },
{ id: 6, name: "Audiobookshelf" },
{ id: 7, name: "Woodpecker CI" },
{ id: 8, name: "Forgejo Git" },
{ id: 9, name: "Server dashboard" },
{ id: 3, name: "Dozzle" },
{ id: 13, name: "Surf hub" },
{ id: 11, name: "Anime list" },
{ id: 5, name: "Wiki" },
{ id: 14, name: "Paperless" },
];
const ITEMS_PER_PAGE = 6;
const INTERVAL_TIME = 2500;
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
const [page, setPage] = useState(0);
const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE);
useEffect(() => {
let interval: NodeJS.Timeout;
if (isHovered) {
// Start rotating pages only when hovered
interval = setInterval(() => {
setPage((p) => (p + 1) % totalPages);
}, 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 () => {
if (interval) clearInterval(interval);
};
}, [isHovered, totalPages]);
return (
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
{/* --- DEFAULT VIEW --- */}
<div
className={`transition-opacity duration-300 ${
isHovered ? "opacity-0 pointer-events-none" : "opacity-100"
}`}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.6)]" />
<span className="font-medium text-white tracking-tight">
Hetzner Node-01
</span>
</div>
<p className="text-[10px] font-mono text-neutral-500">
SYS_STATUS: ONLINE
</p>
</div>
<Activity className="text-neutral-800 w-10 h-10 -mt-1" />
</div>
<div className="mt-8 grid grid-cols-2 gap-4 border-t border-neutral-800/50 pt-6">
<div>
<p className="text-[10px] font-mono text-neutral-600 uppercase">
Architecture
</p>
<p className="text-xs text-neutral-400">linux/amd64</p>
</div>
<div>
<p className="text-[10px] font-mono text-neutral-600 uppercase">
Provider
</p>
<p className="text-xs text-neutral-400">Hetzner Cloud</p>
</div>
</div>
</div>
{/* --- REGISTRY VIEW --- */}
<div
className={`absolute inset-0 transition-all duration-300 flex flex-col ${
isHovered
? "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>
<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 className="flex-1 overflow-hidden">
<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
key={page}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
className="grid grid-cols-1 gap-1.5 w-full"
>
{currentItems.map((m) => (
<div
key={m.id}
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-[11px] font-medium text-neutral-300 truncate mr-2">
{m.name}
</span>
<div className="flex gap-1 shrink-0 scale-75 origin-right">
<Image
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
width={60}
height={20}
className="h-5 w-auto"
alt="System Status"
unoptimized
/>
<Image
src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`}
width={80}
height={20}
className="h-5 w-auto opacity-60"
alt="Average Response Time"
unoptimized
/>
</div>
</div>
))}
</motion.div>
</AnimatePresence>
</div>
);
}

View file

@ -1,50 +0,0 @@
"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

@ -1,67 +0,0 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
export default function ProjectShowcase({ images }: { images: string[] }) {
const [index, setIndex] = useState(0);
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 h-[500px]">
{/* Main Image */}
<div className="lg:col-span-9 relative overflow-hidden rounded-3xl border border-neutral-800 bg-neutral-900 group">
<AnimatePresence mode="wait">
<motion.img
key={index}
src={images[index]}
initial={{ opacity: 0, scale: 1.05 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
className="absolute inset-0 h-full w-full object-cover"
/>
</AnimatePresence>
{/* Subtle Overlay Label */}
<div className="absolute bottom-4 left-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] font-mono uppercase tracking-widest text-white/70">
View {index + 1} of {images.length}
</p>
</div>
</div>
{/* Thumbnail Column */}
<div className="lg:col-span-3 flex lg:flex-col gap-3 overflow-x-auto lg:overflow-y-auto pr-2 custom-scrollbar">
{images.map((img, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`relative flex-shrink-0 w-24 lg:w-full aspect-video rounded-xl border-2 transition-all overflow-hidden ${
i === index
? "border-blue-500 ring-4 ring-blue-500/10"
: "border-neutral-800 opacity-40 hover:opacity-100"
}`}
>
<div className="relative h-full w-full overflow-hidden">
<Image
src={img}
alt={`Project showcase thumbnail ${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 && (
<motion.div
layoutId="active-thumb"
className="absolute inset-0 bg-blue-500/10 z-10"
/>
)}
</button>
))}
</div>
</div>
);
}

View file

@ -1,21 +0,0 @@
import { ForgeProjectMetadata } from "@/types";
export const FORGE_PROJECTS: ForgeProjectMetadata[] = [
{
id: "pixelpals",
projectName: "PixelPals",
repoName: "pixel-pals",
status: "Alpha",
engine: "Flutter / Firebase",
imagePath: "/forge/pixel-pals.png",
description:
"A GPS-driven, cooperative creature collection game built to encourage exercise. Features turn-based tactical combat, class-based progression, and AI-generated companion art based on player descriptions.",
highlights: [
"GPS / Mapbox Integration",
"Generative AI (Pet Synthesis)",
"Real-time Firebase Sync",
"Turn-based Battle Engine",
],
// externalLink: "https://pixelpals.app",
},
];

View file

@ -1,113 +0,0 @@
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: "dino-tracker",
name: "GW Paleo",
description:
"A digital field journal and taxonomic registry for Dinosauria. Designed with a 'Museum Archive' aesthetic, this specimen tracker syncs with the Paleobiology Database (PBDB) to catalog thousands of validated species and genera.",
stack: ["Next.js", "Prisma", "SQLite", "Tailwind CSS"],
visibility: "public",
url: "https://paleo.georgew.dev",
gitUrl: "https://git.georgew.dev/georgew/dino-tracker",
image: "/lab/dino-tracker.jpg",
uptimeId: 16,
},
{
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,404 +0,0 @@
import { Project } from "@/types/index";
export const PROJECT_REGISTRY: Project[] = [
{
slug: "ratoong",
category: "web",
title: "Ratoong",
subtitle: "High-Performance Ski & Travel Engine",
role: "Full-Stack Engineer",
duration: "2020 — 2022",
stack: ["Angular", "Firebase", "GCP Cloud Functions", "TypeScript"],
metrics: [
"< 200ms Search Latency",
"10,000+ Active Data Points",
"Fully Responsive Design",
],
description:
"A comprehensive ski resort planning and rating platform featuring real-time weather integration and complex multi-parameter search filters.",
engineeringStory: `
Building Ratoong was an exercise in managing **High-Density Data** within a reactive frontend ecosystem. The core challenge was transforming thousands of resort data pointsranging from piste lengths to real-time weatherinto a lightning-fast, searchable interface.
#### Data Orchestration & Efficiency
Leveraging a **Document-Based Architecture (Firestore)**, I designed a schema that balanced read efficiency with real-time updates. To handle complex filtering (altitude, lift types, pricing) without taxing the client-side, I utilized **GCP Cloud Functions** as a middleware layer to process and normalize data from various 3rd-party APIs, including Google Maps and Weather services.
#### Modern Angular & Responsive UI
The frontend was built using modern **Angular**, focusing on a component-based architecture that ensured high performance across both desktop and mobile. I implemented a custom state management flow to handle resort ratings and trip planning, ensuring that user interactions were instantly reflected in the UI while syncing seamlessly with **Firebase Authentication**.
#### Lessons in Scalability
Working with a **Backend-as-a-Service (BaaS)** model taught me the importance of cost-effective query design and the power of event-driven triggers. I was responsible for maintaining the development, staging, and production environments, ensuring a clean CI/CD flow from localhost to the Firebase cloud.
#### Security & Data Governance
A key architectural pillar was the implementation of a robust **Security Rules** layer within Firebase. By moving the logic from the client to the database level, we ensured that resort metadata was globally searchable while sensitive user planning data remained strictly isolated. This event-driven security model allowed us to scale the user base without increasing the risk surface area of the platform.
`,
images: [
"/projects/ratoong/ratoong-1.jpg",
"/projects/ratoong/ratoong-2.jpg",
"/projects/ratoong/ratoong-3.jpg",
"/projects/ratoong/ratoong-4.jpg",
"/projects/ratoong/ratoong-5.jpg",
],
liveUrl: "https://www.ratoong.com/",
isPrivate: false,
mermaidChart: `
graph LR
subgraph Client_Side [Frontend]
A[Angular Web App]:::traffic
end
subgraph Firebase_GCP [Cloud Infrastructure]
direction TB
Hub((Firebase SDK)):::hub
B[Firebase Auth]:::node
C[Firestore DB]:::node
D[Cloud Functions]:::node
E[Partner API Proxy]:::node
end
subgraph External [Third Party]
direction TB
F[Weather API]:::traffic
G[Google Maps API]:::traffic
H[Affiliate Partners]:::traffic
end
A ==> Hub
Hub -->|Identity| B
Hub <-->|Data Sync| C
Hub -->|Triggers| D
D --> F
D --> G
D --> H
E -.->|Internal Access| C
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff,stroke-width:2px
`,
storyLabel: "DATA // UI EFFICIENCY",
},
{
slug: "datasaur",
category: "web",
title: "Datasaur",
subtitle: "Automated Statistical Analysis Engine",
role: "Lead Architect & Creator",
duration: "2019 — 2021", // Reflecting "one of my first things"
stack: ["Python", "Flask", "MongoDB", "Pandas", "SciPy"],
metrics: [
"Automated Stat-Testing",
"Multi-Format ETL",
"Self-Hosted Architecture",
],
description:
"A comprehensive survey data platform that automates complex statistical workflows, from raw data aggregation to advanced hypothesis testing and visualization.",
storyLabel: "ALGORITHMIC // STATISTICAL PROCESSING",
images: [
"/projects/datasaur/datasaur-1.jpg",
"/projects/datasaur/datasaur-2.jpg",
"/projects/datasaur/datasaur-3.jpg",
"/projects/datasaur/datasaur-4.jpg",
"/projects/datasaur/datasaur-5.jpg",
"/projects/datasaur/datasaur-6.jpg",
],
repoUrl: "https://git.georgew.dev/georgew/datasaur",
liveUrl: "https://datasaur.dev",
isPrivate: false,
engineeringStory: `
Datasaur was born out of a necessity to bridge the gap between raw survey data and academic-grade statistical insights. The challenge wasn't just displaying data, but architecting a system capable of performing complex mathematical computations on-the-fly.
#### Statistical Automation Pipeline
The core of the application is a robust processing engine built on **Pandas** and **SciPy**. I implemented automated workflows for non-parametric tests like **Kruskal-Wallis** and **Mann-Whitney U**, ensuring that the platform could intelligently suggest and execute the correct statistical test based on the data distribution.
#### Data Visualization & Export
To translate these numbers into insights, I built a visualization layer supporting everything from standard histograms to complex **Box and Whisker** plots. Using **XlsxWriter**, I developed a custom export engine that allowed users to pull processed data directly into professional-grade spreadsheets with pre-formatted statistical summaries.
#### Infrastructure & Monolithic Integrity
The project follows a classic monolithic architecture, which proved highly efficient for keeping memory-intensive dataframes close to the processing logic. Today, the platform is self-hosted using a **Caddy** reverse proxy and **MongoDB Atlas**, demonstrating the longevity and stability of a well-architected Flask ecosystem.
`,
mermaidChart: `
graph LR
subgraph Client_Layer [User Interface]
A[Vanilla JS / Browser]:::traffic
end
subgraph Server_Layer [Application Logic]
B[Caddy Reverse Proxy]:::node
C[Flask / Python Monolith]:::node
end
subgraph Processing_Engine [Data Science Core]
D[Pandas ETL]:::node
E[SciPy / Pingouin Stats]:::node
F[XlsxWriter Export]:::node
end
subgraph Storage [Data Persistence]
G[MongoDB Atlas]:::node
end
A <-->|HTTPS| B
B <-->|WSGI| C
C <-->|Query/Write| G
C ==>|Dataframes| D
D --> E
D --> F
%% Styles %%
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
`,
},
{
slug: "ayla",
category: "infrastructure",
title: "Ayla",
subtitle: "Regulatory-Compliant Medical Platform",
role: "Tech Lead & Scrum Master",
duration: "2022 — 2024",
stack: [
"Kubernetes",
"Ruby on Rails",
"Flutter",
"Terraform",
"GCP",
"OTC",
],
metrics: [
"Multi-Region Data Residency",
"ISO 27001 Compliant",
"Single-Click IaC Deployment",
],
description:
"A high-availability medical device platform supporting dementia treatment, featuring multi-cloud infrastructure, automated pipelines, cross-platform web and mobile deployments and strict regulatory requirements.",
storyLabel: "GOVERNANCE // CLOUD ORCHESTRATION",
images: [
"/projects/ayla/ayla-1.jpg",
"/projects/ayla/ayla-2.jpg",
"/projects/ayla/ayla-3.jpg",
"/projects/ayla/ayla-4.jpg",
"/projects/ayla/ayla-5.jpg",
],
isPrivate: true,
engineeringStory: `
As Tech Lead for Ayla, I was responsible for architecting a platform that met the rigorous safety and security standards of a certified medical device. This required a "Security-by-Design" approach, balancing high availability (SLA) with rigid data residency requirements across the UK and EU.
#### Multi-Cloud Infrastructure & IaC
To satisfy GDPR and local health data regulations, I architected a dual-cloud strategy: **Open Telekom Cloud (OTC)** for European users and **GCP** for the UK. Using **Terraform**, I codified the entire infrastructure, enabling us to spin up identical, audit-ready Kubernetes clusters or Cloud Run environments in minutes. This automation was critical for maintaining the "Release-Pre-Release" protocols required for medical certification.
#### Full-Stack Delivery & Automation
The platform featured a **Flutter** frontend for Web, iOS, and Android, all managed through automated **CICD** pipelines. I implemented a layered automation strategy, combining **GitHub Actions** for web deployments and server-side logic with **Fastlane** for mobile app store distribution. The backend was a high-performance **Ruby on Rails** API, architected as a stateless "mini-service" to ensure horizontal scalability within Kubernetes. I also integrated **Squidex CMS** to empower non-technical colleagues to manage content without compromising the system's core integrity.
#### Leadership & Compliance
Beyond the code, I served as Scrum Master and Product Owner, leading sprint planning, retro and demos. I worked closely with regulatory partners and personally oversaw the creation of **DPIAs**, **Cyber Essentials** certification, and the path to **ISO 27001** compliance. In the absence of a dedicated IT department, I managed the MDM systems and sysadmin duties, ensuring that every layer of the organization met the strict regulatory bar.
`,
mermaidChart: `
graph TB
%% Direction and Layout
direction TB
subgraph Shared_Ops [DevOps & CMS]
I[GitHub Actions CICD]:::traffic
J[Terraform IaC]:::traffic
K[Squidex CMS]:::node
end
subgraph Frontend_Layer [Omni-Channel]
A[Flutter Web / Mobile]:::traffic
B[Bunny CDN / Edge Storage]:::node
end
subgraph UK_Region [GCP]
G[Cloud Run Containers]:::node
H[Cloud SQL]:::node
end
subgraph EU_Region [Open Telekom Cloud]
D[NGINX Ingress]:::node
C[K8s Cluster]:::node
F[Object Storage]:::node
E[PostgreSQL RDS]:::node
end
%% Connections
A <--> B
I -->|Fastlane| A
J -->|Provision| G
J -->|Provision| C
B <-->|UK Traffic| G
B <-->|EU Traffic| D
D --> C
G <--> H
C <--> E
C --- F
G <--> K
C <--> K
%% Styles
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
`,
},
{
slug: "choosa",
category: "mobile",
title: "Choosa",
subtitle: "Social Content Discovery Engine",
role: "Lead Developer & Architect",
duration: "2023 — Present",
stack: [
"Flutter",
"Firebase",
"Firestore",
"Cloud Functions",
"Push Notifications",
],
metrics: [
"Real-time Match Engine",
"Cross-Platform (iOS/Android)",
"Multi-API Orchestration",
],
description:
"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.",
storyLabel: "UX // MOBILE SYNCHRONIZATION",
images: [
"/projects/choosa/choosa-1.jpg",
"/projects/choosa/choosa-2.jpg",
"/projects/choosa/choosa-3.jpg",
"/projects/choosa/choosa-4.jpg",
"/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: "nutriveat",
category: "mobile",
title: "Nutriveat",
subtitle: "AI-Powered Personalized Nutrition",
role: "Lead Developer & Architect",
duration: "2024 — Present",
stack: [
"Flutter",
"Firebase",
"OpenAI (GPT-4o)",
"Novita AI",
"StoreKit / Play Billing",
],
metrics: [
"Fine-tuned LLM Assistants",
"Direct Store Integrations",
"Multi-Tier Subscriptions",
],
description:
"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.",
storyLabel: "AI ORCHESTRATION // MONETIZATION",
images: [
"/projects/nutriveat/nutriveat-6.jpg",
"/projects/nutriveat/nutriveat-1.jpg",
"/projects/nutriveat/nutriveat-2.jpg",
"/projects/nutriveat/nutriveat-3.jpg",
"/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

@ -7,9 +7,7 @@ services:
- NODE_ENV=production
networks:
- web_traffic
env_file:
- .env
networks:
web_traffic:
external: true
external: true

View file

@ -1,13 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import eslintConfigPrettier from "eslint-config-prettier";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
eslintConfigPrettier,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -1,100 +0,0 @@
import { FORGE_PROJECTS } from "@/data/forge";
import { ChangelogEntry, ForgeProject } from "@/types";
import "server-only";
export async function getAllForgeProjects(): Promise<ForgeProject[]> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
return Promise.all(
FORGE_PROJECTS.map(async (metadata) => {
const changelog = await getPrivateChangelog(metadata.repoName);
// Determine if the latest entry happened in the last 30 days
const latestDate =
changelog.length > 0 ? new Date(changelog[0].date) : null;
const isRecent = latestDate ? latestDate >= thirtyDaysAgo : false;
return {
...metadata,
currentVersion:
changelog.length > 0
? `${changelog[0].version}_${metadata.status}`
: `v0.0.0_${metadata.status}`,
isRecent, // This is the new conditional flag
changelog: changelog.slice(0, 3),
};
}),
);
}
export async function getPrivateChangelog(
repoName: string,
): Promise<ChangelogEntry[]> {
const repoOwner = "georgew";
const filePath = "changelog.md";
const url = `https://git.georgew.dev/api/v1/repos/${repoOwner}/${repoName}/contents/${filePath}`;
const headers = {
headers: {
Authorization: `token ${process.env.FORGEJO_TOKEN}`,
Accept: "application/vnd.forgejo.raw",
},
next: { revalidate: 3600 },
};
try {
const response = await fetch(url, headers);
if (!response.ok) {
return [
{
version: "v0.0.0",
date: new Date().toISOString().split("T")[0],
changes: ["Documentation currently synchronizing with the Forge..."],
},
];
}
const data = await response.json();
// 2. Forgejo returns the content as a base64 string
// We need to decode it to UTF-8
const markdownText = Buffer.from(data.content, "base64").toString("utf-8");
return parseMarkdown(markdownText);
} catch (error) {
console.error("Git Fetch Error:", error);
return [];
}
}
// Simple parser for your ## [Version] - YYYY-MM-DD format
function parseMarkdown(text: string): ChangelogEntry[] {
// Split by "## " at the start of a line to isolate version blocks
const sections = text.split(/\n(?=## )/g);
return sections
.map((section) => {
const lines = section
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (lines.length === 0) return null;
// Match the pattern: ## [Version] - YYYY-MM-DD
const headerMatch = lines[0].match(/## \[(.*?)\] - (.*)/);
if (!headerMatch) return null;
const version = headerMatch[1];
const date = headerMatch[2];
// Collect all lines starting with "- " anywhere in this version block
const changes = lines
.filter((line) => line.startsWith("- "))
.map((line) => line.replace("- ", ""));
return { version, date, changes };
})
.filter((entry): entry is ChangelogEntry => entry !== null)
.slice(0, 3);
}

View file

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

3172
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,26 +9,20 @@
"lint": "eslint"
},
"dependencies": {
"@tailwindcss/upgrade": "^4.1.18",
"framer-motion": "^12.29.2",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.1.4",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.4",
"eslint-config-prettier": "^10.1.8",
"react-markdown": "^10.1.0",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View file

@ -1,5 +0,0 @@
{
"names": {
"muninn": "44e345442475c960433feb762c9d3f70e4fdb71c2f873a3473358d40e2ae01c1"
}
}

View file

@ -1,16 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0a0a0a"/>
<text
x="50%"
y="52%"
dominant-baseline="middle"
text-anchor="middle"
fill="#f97316"
font-family="system-ui, sans-serif"
font-weight="900"
font-size="22"
style="transform-origin: center; transform: scale(1, 1.2);"
>
&lt;&gt;
</text>
</svg>

Before

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

View file

@ -1,81 +0,0 @@
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;
status: "Alpha" | "Beta" | "R&D";
engine: string;
description: string;
imagePath?: string;
highlights: string[];
externalLink?: string;
projectName: string;
isRecent: boolean;
currentVersion: string;
changelog: ChangelogEntry[];
}
export interface ForgeProjectMetadata {
id: string;
projectName: string;
repoName: string;
status: "Alpha" | "Beta" | "R&D";
engine: string;
imagePath?: string;
description: string;
highlights: string[];
externalLink?: string;
}
export interface ChangelogEntry {
version: string;
date: string;
changes: string[];
}