Commiting changes before upgrade

This commit is contained in:
GeorgeWebberley 2026-01-30 11:22:44 +01:00
parent 5d0a86645d
commit 471b251fd7
14 changed files with 383 additions and 7898 deletions

View file

@ -1,5 +1,7 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;

View file

@ -4,38 +4,19 @@ import { use } from "react";
import { motion } from "framer-motion";
import Link from "next/link";
import { ArrowLeft, ExternalLink, Github, ShieldCheck, Cpu, Users } from "lucide-react";
import { PROJECT_REGISTRY } from "@/data/projects";
import Mermaid from "@/components/Mermaid";
import ProjectShowcase from "@/components/ProjectShowcase";
import ImageCarousel from "@/components/ImageCarousel";
import ReactMarkdown from 'react-markdown';
// This would eventually move to a separate data file
const PROJECT_DETAILS = {
ratoong: {
title: "Ratoong",
subtitle: "Regulatory-Compliant Data Platform",
role: "Lead Full-Stack Engineer",
duration: "2022 — 2024",
stack: ["Node.js", "PostgreSQL", "React", "Docker"],
metrics: ["99.9% Uptime", "Zero-Data-Loss Integrity", "ISO 27001 Ready"],
description: "Architected a high-integrity platform designed to meet rigid regulatory requirements. Focused on immutable audit trails and secure data orchestration.",
images: ["/ratoong-1.jpg", "/ratoong-2.jpg"], // Place these in /public
liveUrl: "https://ratoong.com",
repoUrl: "https://git.georgew.dev/georgew/datasaur",
},
datasaur: {
title: "Datasaur",
subtitle: "Personal R&D Pipeline",
role: "Architect & Creator",
duration: "2025 — Present",
stack: ["Python", "FastAPI", "Next.js", "Redis"],
metrics: ["Sub-50ms Latency", "Automated ETL", "Self-Hosted"],
description: "A data science pipeline tool built to explore high-speed processing and real-time visualization of large datasets.",
images: ["/datasaur-1.jpg"],
repoUrl: "https://git.georgew.dev/georgew/datasaur",
liveUrl: "https://ratoong.com",
}
};
export default function ProjectDetail({ params }: { params: Promise<{ category: string, slug: string }> }) {
const { category, slug } = use(params);
const project = PROJECT_DETAILS[slug as keyof typeof PROJECT_DETAILS];
const project = PROJECT_REGISTRY.find((p) => p.slug === slug);
if (!project) return <div>Project Not Found</div>;
if (!project) return <div className="p-24 text-white font-mono">Project Log Not Found.</div>;
@ -98,28 +79,55 @@ export default function ProjectDetail({ params }: { params: Promise<{ category:
</motion.div>
</header>
{/* Image Gallery */}
<section className="mb-20">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{project.images.map((img, i) => (
<motion.div key={i} whileHover={{ scale: 1.01 }} className="aspect-video bg-neutral-900 rounded-2xl border border-neutral-800 overflow-hidden relative">
{/* Placeholder for your actual images */}
<div className="absolute inset-0 flex items-center justify-center text-neutral-700 font-mono text-xs italic">
[Screenshot: {project.title} - Layer {i + 1}]
{/* Desktop Showcase View */}
<div className="hidden lg:block">
<ProjectShowcase images={project.images} />
</div>
</motion.div>
))}
{/* Mobile Carousel View */}
<div className="block lg:hidden">
<ImageCarousel images={project.images} />
</div>
<p className="mt-4 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]">
Interactive Gallery Select or swipe to explore
</p>
</section>
{/* Engineering Narrative */}
<article className="max-w-3xl border-t border-neutral-900 pt-16">
<h2 className="text-2xl font-bold mb-6 italic underline decoration-blue-500 underline-offset-8">The Engineering Story</h2>
<div className="prose prose-invert prose-neutral max-w-none text-neutral-400 leading-loose">
{/* Content drafted below */}
<p>Detail how you approached the architecture, the specific trade-offs you made, and how you ensured regulatory compliance in the data layer.</p>
{/* SYSTEM ARCHITECTURE (New Mermaid Section) */}
{project.mermaidChart && (
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<div className="h-px flex-1 bg-neutral-900" />
<h3 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.3em]">
System Architecture Log
</h3>
<div className="h-px flex-1 bg-neutral-900" />
</div>
</article>
<div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12">
<Mermaid chart={project.mermaidChart} />
</div>
</section>
)}
{/* Engineering Narrative */}
<article className="w-full max-w-3xl mx-auto px-6 py-20 border-t border-neutral-900 mt-20">
<h2 className="text-2xl font-bold mb-10 italic underline decoration-blue-500 underline-offset-8 text-left text-white">
The Engineering Story
</h2>
<div className="prose prose-invert prose-neutral max-w-none text-left
prose-h4:text-blue-400 prose-h4:font-mono prose-h4:uppercase prose-h4:text-[10px] prose-h4:tracking-[0.2em]
prose-p:text-neutral-400 prose-p:leading-relaxed">
<ReactMarkdown>
{project.engineeringStory}
</ReactMarkdown>
</div>
</article>
</div>
</main>
);

View file

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

88
components/Mermaid.tsx Normal file
View file

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

View file

@ -1,20 +1,72 @@
import { Project } from "@/types/project";
export const PROJECT_REGISTRY: Project[] = [
{
{
slug: "ratoong",
category: "web",
title: "Ratoong",
subtitle: "Regulatory-Compliant Data Platform",
role: "Lead Full-Stack Engineer",
duration: "2022 — 2024",
stack: ["Node.js", "PostgreSQL", "React", "Docker"],
metrics: ["99.9% Uptime", "Zero-Data-Loss Integrity", "ISO 27001 Ready"],
description: "Architected a high-integrity platform designed to meet rigid regulatory requirements.",
engineeringStory: "In this high-stakes medical environment, I implemented a custom audit logging system that ensured every state change was immutable...",
images: ["/ratoong-hero.jpg", "/ratoong-dashboard.jpg"],
subtitle: "High-Performance Ski & Travel Engine",
role: "Full-Stack Engineer",
duration: "2020 — 2022", // Adjusted based on your "long time ago" comment
stack: ["Angular", "Firebase", "GCP Cloud Functions", "TypeScript"],
metrics: ["< 200ms Search Latency", "10,000+ Active Data Points", "Fully Responsive Design"],
description: "A comprehensive ski resort planning and rating platform featuring real-time weather integration and complex multi-parameter search filters.",
engineeringStory: `
Building Ratoong was an exercise in managing **High-Density Data** within a reactive frontend ecosystem. The core challenge was transforming thousands of resort data pointsranging from piste lengths to real-time weatherinto a lightning-fast, searchable interface.
#### Data Orchestration & Efficiency
Leveraging a **Document-Based Architecture (Firestore)**, I designed a schema that balanced read efficiency with real-time updates. To handle complex filtering (altitude, lift types, pricing) without taxing the client-side, I utilized **GCP Cloud Functions** as a middleware layer to process and normalize data from various 3rd-party APIs, including Google Maps and Weather services.
#### Modern Angular & Responsive UI
The frontend was built using modern **Angular**, focusing on a component-based architecture that ensured high performance across both desktop and mobile. I implemented a custom state management flow to handle resort ratings and trip planning, ensuring that user interactions were instantly reflected in the UI while syncing seamlessly with **Firebase Authentication**.
#### Lessons in Scalability
Working with a **Backend-as-a-Service (BaaS)** model taught me the importance of cost-effective query design and the power of event-driven triggers. I was responsible for maintaining the development, staging, and production environments, ensuring a clean CI/CD flow from localhost to the Firebase cloud.
#### Security & Data Governance
A key architectural pillar was the implementation of a robust **Security Rules** layer within Firebase. By moving the logic from the client to the database level, we ensured that resort metadata was globally searchable while sensitive user planning data remained strictly isolated. This event-driven security model allowed us to scale the user base without increasing the risk surface area of the platform.
`,
images: [
"/projects/ratoong/ratoong-1.jpg",
"/projects/ratoong/ratoong-2.jpg",
"/projects/ratoong/ratoong-3.jpg",
"/projects/ratoong/ratoong-4.jpg",
"/projects/ratoong/ratoong-5.jpg"
],
liveUrl: "https://ratoong.com",
isPrivate: true,
isPrivate: false,
mermaidChart: `
graph TD
subgraph Client_Side [Frontend]
A[Angular Web App]:::traffic
end
subgraph Firebase_GCP [Cloud Infrastructure]
B[Firebase Auth]:::node
C[Firestore DB]:::node
D[Cloud Functions]:::node
E[Partner API Proxy]:::node
end
%% Move APIs to a vertical stack to save horizontal width %%
subgraph External [Third Party]
F[Weather API]:::traffic
G[Google Maps API]:::traffic
H[Affiliate Partners]:::traffic
end
A <-->|Real-time Sync| C
A -->|Auth Request| B
A -->|Triggers| D
D -->|Fetch & Normalize| F
D -->|Geocoding| G
D -->|Affiliate Logic| H
E -->|Read Data| C
%% Styles %%
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
classDef node fill:#16a34a,stroke:#4285F4,color:#fff
`,
},
{
slug: "datasaur",

7836
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,17 +13,20 @@
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.1.4",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.4",
"tailwindcss": "^4",
"react-markdown": "^10.1.0",
"tailwindcss": "^4.1.18",
"typescript": "^5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

View file

@ -12,5 +12,6 @@ export interface Project {
images: string[];
liveUrl?: string;
repoUrl?: string;
mermaidChart?: string;
isPrivate: boolean;
}