Compare commits

...

23 commits

Author SHA1 Message Date
GeorgeWebberley 3cec6e7758 Added muninn
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-03-17 18:49:27 +01:00
GeorgeWebberley a1c84ccc58 Updated status uptime choices
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-12 20:37:57 +01:00
GeorgeWebberley 75a16af0c4 Updated dinos to paleo
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-08 15:57:28 +01:00
GeorgeWebberley deb370d5af Added dino-tracker
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-06 11:07:15 +01:00
GeorgeWebberley 8508b679c0 Fixed mobile layout of CV
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-04 16:45:06 +01:00
GeorgeWebberley fa7e1a5e09 Merge branch 'main' of ssh://git.georgew.dev:2222/georgew/portfolio
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-04 12:13:38 +01:00
GeorgeWebberley 7c40c82296 Added printable CV 2026-02-04 12:12:14 +01:00
georgew 7ad84c3ddb Updated title 2026-02-03 15:12:09 +00:00
GeorgeWebberley 1e94089b24 Fixed and updated README 2026-02-03 16:09:31 +01:00
GeorgeWebberley 4771990d3c Fixed datasaur link
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-03 15:53:34 +01:00
GeorgeWebberley d9df8700b4 Fixed linkedin and git links
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-03 15:41:14 +01:00
GeorgeWebberley 14173ff9ad Fixed favicon and updated title from the architect
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-03 15:26:42 +01:00
GeorgeWebberley f09b403282 Added favicon, bunny cdn and site name
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-03 14:52:42 +01:00
GeorgeWebberley cf19bed083 Added force-dynamic
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-03 14:29:17 +01:00
GeorgeWebberley 59fa92ef2f Mounted .env
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-03 14:14:18 +01:00
GeorgeWebberley 14a1ffb7b6 Made active link and active stream conditional
All checks were successful
ci/woodpecker/release/woodpecker Pipeline was successful
2026-02-03 14:04:06 +01:00
GeorgeWebberley af8c720ba0 Updated changelog versioning 2026-02-03 13:56:34 +01:00
GeorgeWebberley 8369d59310 Finalised lab page 2026-02-03 11:30:13 +01:00
GeorgeWebberley ab481053bf Fleshed out the lab page and projects 2026-02-02 18:55:07 +01:00
GeorgeWebberley 1e7a1c8a5f Fixed Image tags 2026-02-02 13:53:49 +01:00
GeorgeWebberley 0d3b304d9a Refactored footer, and added page load animations 2026-02-02 13:40:02 +01:00
GeorgeWebberley 02c12b6e15 Before refactor 2026-02-02 12:18:35 +01:00
GeorgeWebberley 56afa86704 Added mobile image viewer 2026-02-01 20:08:09 +01:00
56 changed files with 2265 additions and 694 deletions

View file

@ -1,36 +1,47 @@
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). # Portfolio
## Getting Started 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.
First, run the development server: ## 🏗️ 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
```bash ```bash
npm run dev docker buildx build --platform linux/amd64 -t portfolio:latest .
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ### Automated Pipeline
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 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.
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 Integration
## Learn More "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.
To learn more about Next.js, take a look at the following resources: ---
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. Built for performance and technical transparency.
- [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.

381
app/cv/page.tsx Normal file
View file

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

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

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

@ -31,3 +31,62 @@ body {
max-width: none; max-width: none;
word-break: break-word; 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;
}
}

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

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

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

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

View file

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

View file

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

View file

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

View file

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

31
components/Footer.tsx Normal file
View file

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

131
components/ForgeUI copy.tsx Normal file
View file

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

138
components/ForgeUI.tsx Normal file
View file

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

View file

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

View file

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

104
components/MobileStack.tsx Normal file
View file

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

View file

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

50
components/PageLayout.tsx Normal file
View file

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

View file

@ -43,11 +43,16 @@ export default function ProjectShowcase({ images }: { images: string[] }) {
: "border-neutral-800 opacity-40 hover:opacity-100" : "border-neutral-800 opacity-40 hover:opacity-100"
}`} }`}
> >
<img <div className="relative h-full w-full overflow-hidden">
src={img} <Image
className="h-full w-full object-cover" src={img}
alt={`Thumb ${i}`} 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 && ( {i === index && (
<motion.div <motion.div
layoutId="active-thumb" layoutId="active-thumb"

21
data/forge.ts Normal file
View file

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

113
data/lab.ts Normal file
View file

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

View file

@ -7,6 +7,8 @@ services:
- NODE_ENV=production - NODE_ENV=production
networks: networks:
- web_traffic - web_traffic
env_file:
- .env
networks: networks:
web_traffic: web_traffic:

100
lib/git.ts Normal file
View file

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

View file

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

16
public/favicon.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 409 B

BIN
public/forge/pixel-pals.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

1
public/forgejo.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

BIN
public/lab/dashboard.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

BIN
public/lab/dino-tracker.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
public/lab/observatory.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
public/lab/paperless.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
public/lab/portainer.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
public/lab/wikijs.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
public/lab/yamtrack.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

BIN
public/profile.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

81
types/index.ts Normal file
View file

@ -0,0 +1,81 @@
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[];
}

View file

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