Compare commits
No commits in common. "main" and "v0.0.10" have entirely different histories.
61
README.md
|
|
@ -1,47 +1,36 @@
|
|||
# Portfolio
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
A high-performance, containerized professional portfolio and R&D lab built with **Next.js 15**, **Tailwind CSS**, and **TypeScript**. This project serves as both a public showcase and a dynamic bridge to a private **Forgejo** instance for real-time development tracking.
|
||||
## Getting Started
|
||||
|
||||
## 🏗️ Architecture Summary
|
||||
|
||||
- **Infrastructure**: Hosted on a **Hetzner** cloud node.
|
||||
- **Containerization**: Fully Dockerized for **linux/amd64** architectures.
|
||||
- **CI/CD**: Automated build and deployment via **Woodpecker CI**.
|
||||
- **CDN**: Assets and media served via **Bunny CDN** for global performance.
|
||||
- **Data Layer**: Dynamic changelog fetching from private repositories via **Forgejo API**.
|
||||
|
||||
## 🛠️ Technical Stack
|
||||
|
||||
- **Framework**: Next.js 15 (App Router).
|
||||
- **Styling**: Tailwind CSS with a monospace "Technical Lab" aesthetic.
|
||||
- **Icons**: Lucide React.
|
||||
- **Deployment**: Docker Compose with `force-dynamic` runtime environment injection.
|
||||
|
||||
## 🚀 Deployment & Build
|
||||
|
||||
The project utilizes a specialized build process to ensure compatibility with the production Hetzner node environment:
|
||||
|
||||
### Manual Build
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
docker buildx build --platform linux/amd64 -t portfolio:latest .
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Automated Pipeline
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
Deployments are triggered via Git Releases. The Woodpecker pipeline executes the following steps:
|
||||
-Build: Compiles the Next.js application for linux/amd64.
|
||||
-Push: Uploads the image to a private container registry.
|
||||
-Deploy: Re-creates the container on the Hetzner node, injecting the FORGEJO_TOKEN from a secure .env file at runtime.
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
## 🧪 The_Forge Integration
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
"The Forge" is a dynamic project log system that provides a glimpse into active R&D cycles.
|
||||
-Metadata: Configured via data/forge.ts for project-specific details.
|
||||
-Real-time Logs: Fetched server-side from private changelog.md files.
|
||||
-Status: Includes a conditional ACTIVE_STREAM pulse based on commit recency.
|
||||
-Performance: Utilizes Next.js data caching with a 1-hour revalidation window.
|
||||
## Learn More
|
||||
|
||||
---
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
Built for performance and technical transparency.
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
|
|
|||
381
app/cv/page.tsx
|
|
@ -1,381 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
import { Mail, Globe, MapPin, ExternalLink, Server, Zap } from "lucide-react";
|
||||
|
||||
export default function CVPage() {
|
||||
return (
|
||||
<PageLayout backLink="/" maxWidth="5xl">
|
||||
{/* Hide the print button itself during printing */}
|
||||
<div className="flex justify-end mb-4 no-print">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-orange-600 text-white text-xs font-mono tracking-widest uppercase transition-colors rounded-sm"
|
||||
>
|
||||
<Zap size={14} /> Print PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Container*/}
|
||||
<div className="bg-white text-slate-900 p-8 md:p-16 rounded-sm shadow-2xl print:shadow-none print:p-12 print:m-0 font-sans print:text-[12pt] leading-normal">
|
||||
<header className="border-b-4 border-slate-900 pb-8 print:pb-4 mb-8 print:mb-6 flex flex-col md:flex-row print:grid print:grid-cols-[1.5fr_1fr] justify-between items-start gap-6">
|
||||
<div className="flex flex-wrap items-center gap-4 sm:gap-6 mb-4 print:mb-2 print:flex-nowrap">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src="/profile.jpg"
|
||||
alt="George A. Webberley"
|
||||
className="w-20 h-20 md:w-24 md:h-24 print:w-16 print:h-16 rounded-full object-cover border-2 border-slate-100 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-3xl md:text-5xl print:text-3xl font-extrabold tracking-tighter mb-1 text-slate-900 lg:whitespace-nowrap print:whitespace-nowrap">
|
||||
George A. Webberley
|
||||
</h1>
|
||||
<p className="text-lg md:text-2xl print:text-[14pt] text-orange-600 font-bold uppercase tracking-tight lg:whitespace-nowrap print:whitespace-nowrap">
|
||||
Full Stack Engineer // Systems Architect
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-1 text-sm print:text-[9px] text-slate-500 font-mono text-right print:w-full print:flex print:flex-col print:items-end">
|
||||
<div className="flex items-center md:justify-end gap-2">
|
||||
<MapPin size={14} /> Copenhagen, Denmark
|
||||
</div>
|
||||
<div className="flex items-center md:justify-end gap-2">
|
||||
<Mail size={14} /> george@georgew.dev
|
||||
</div>
|
||||
<div className="flex items-center md:justify-end gap-2 text-orange-600 font-bold">
|
||||
<Globe size={14} /> georgew.dev
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 print:grid-cols-12 gap-8 print:gap-10">
|
||||
{/* Main Column */}
|
||||
<div className="lg:col-span-8 print:col-span-8 space-y-10 print:space-y-6">
|
||||
<section>
|
||||
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-4 print:mb-2 border-b border-slate-100 pb-2">
|
||||
Professional Summary
|
||||
</h2>
|
||||
<p className="text-md print:text-sm leading-relaxed text-slate-700">
|
||||
After 5 years as a dental surgeon, a serendipitous broken leg
|
||||
led me to discover that software engineering perfectly suits my
|
||||
analytical mind. Now an MSc (Distinction) graduate and Senior
|
||||
Engineer, my core expertise lies in the entire product
|
||||
lifecycle. I’m 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'm always looking for the most
|
||||
efficient way to get a project from 'concept' to
|
||||
'shipped'.
|
||||
</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 "not-pokemon" 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 på 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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
|
@ -1,33 +0,0 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
import ForgeUI from "@/components/ForgeUI";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
import { getAllForgeProjects } from "@/lib/git";
|
||||
import { Hammer } from "lucide-react";
|
||||
|
||||
export default async function ForgePage() {
|
||||
const projects = await getAllForgeProjects();
|
||||
|
||||
return (
|
||||
<PageLayout backLink="/" maxWidth="5xl">
|
||||
<header className="mb-20">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Hammer className="text-orange-500 animate-pulse" size={24} />
|
||||
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase font-mono">
|
||||
The Forge
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-neutral-500 max-w-2xl text-sm font-mono leading-relaxed">
|
||||
The Forge is my active development logs, providing a glimpse into my
|
||||
current projects and future potential releases.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-24">
|
||||
{projects.map((project) => (
|
||||
<ForgeUI key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,62 +31,3 @@ body {
|
|||
max-width: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0 !important; /* Use 0 here, and handle padding in the React component */
|
||||
-webkit-margin-before: 0;
|
||||
-webkit-margin-after: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Force the body to be the exact width of A4 to prevent scaling */
|
||||
body {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: white !important;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* Hide everything outside the CV content */
|
||||
#safari-extension-is-installed,
|
||||
main a[href="/"],
|
||||
main a[href*="back"],
|
||||
footer,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure the PageLayout wrapper doesn't add width or centering */
|
||||
main {
|
||||
display: block !important;
|
||||
min-height: auto !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* 3. Ensure the inner container doesn't push content down */
|
||||
main > div {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* 4. Fix the "page break" issue after the title */
|
||||
h1,
|
||||
header {
|
||||
break-after: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ShieldCheck, Zap, Cpu, Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
|
||||
const CAPABILITIES = [
|
||||
{
|
||||
title: "Cloud Orchestration",
|
||||
icon: <Cpu size={16} />,
|
||||
skills: ["Kubernetes", "Docker", "Cloud Run", "Multi-Region VPC"],
|
||||
description:
|
||||
"Managing containerized application lifecycles and software-defined networks to ensure high availability and regional data sovereignty.",
|
||||
},
|
||||
{
|
||||
title: "Provisioning & IaC",
|
||||
icon: <Terminal size={16} />,
|
||||
skills: ["Terraform", "Makefiles", "Shell Scripting", "Versioned State"],
|
||||
description:
|
||||
"Defining environment state through version-controlled configurations to ensure reproducible, predictable, and audit-ready cloud resources.",
|
||||
},
|
||||
{
|
||||
title: "Deployment Pipelines",
|
||||
icon: <Zap size={16} />,
|
||||
skills: [
|
||||
"GitHub Actions",
|
||||
"Woodpecker CI",
|
||||
"Fastlane",
|
||||
"Automated Testing",
|
||||
],
|
||||
description:
|
||||
"Architecting CI/CD workflows that bridge the gap between local development and production environments with zero-downtime strategies.",
|
||||
},
|
||||
{
|
||||
title: "Governance & Security",
|
||||
icon: <ShieldCheck size={16} />,
|
||||
skills: ["NHS DSPT", "Cyber Essentials", "DPIA", "Secret Management"],
|
||||
description:
|
||||
"Hardening infrastructure and delivery pipelines to maintain alignment with UK health data standards and government security frameworks.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function InfrastructurePage() {
|
||||
return (
|
||||
<PageLayout backLink="/" maxWidth="6xl">
|
||||
{/* Header Section */}
|
||||
<header className="mb-16 font-mono">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase">
|
||||
Infrastructure and Operations
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-neutral-500 max-w-xl leading-relaxed text-sm">
|
||||
Technical manifest of experiences in cloud orchestration, deployment
|
||||
pipelines, and security frameworks designed for resilient services in
|
||||
regulated environments.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Specification List */}
|
||||
<div className="space-y-6 mb-32 font-mono">
|
||||
{CAPABILITIES.map((cap, i) => (
|
||||
<motion.div
|
||||
key={cap.title}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.4 }}
|
||||
className="grid grid-cols-1 md:grid-cols-12 gap-6 border-t border-neutral-800/60 pt-10"
|
||||
>
|
||||
<div className="md:col-span-4">
|
||||
<div className="flex items-center gap-3 text-blue-500/90">
|
||||
{cap.icon}
|
||||
<h3 className="text-[10px] font-bold tracking-[0.25em] uppercase">
|
||||
{cap.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-8">
|
||||
<p className="text-neutral-400 text-sm leading-relaxed mb-6">
|
||||
{cap.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{cap.skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="text-[9px] font-mono text-neutral-600 uppercase tracking-widest"
|
||||
>
|
||||
{`// ${skill}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Featured Case Study Section */}
|
||||
<section className="bg-neutral-900/40 border border-neutral-800 p-8 md:p-12 rounded-3xl font-mono">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-10">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-[10px] font-mono text-blue-500 uppercase tracking-[0.3em] mb-4 font-bold">
|
||||
Selected Case Study
|
||||
</h2>
|
||||
<h3 className="text-xl font-bold text-white mb-3 tracking-tight">
|
||||
Medical-Grade Architecture Analysis
|
||||
</h3>
|
||||
<p className="text-neutral-500 leading-relaxed text-sm max-w-2xl">
|
||||
An examination of a multi-cloud environment built to satisfy
|
||||
<span className="text-neutral-300"> UK Cyber Essentials</span> and
|
||||
<span className="text-neutral-300"> NHS DSPT</span> standards,
|
||||
focusing on data residency and infrastructure-level security.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<Link href="/projects/infrastructure/ayla">
|
||||
<motion.div
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
backgroundColor: "#3b82f6",
|
||||
color: "#fff",
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="px-8 py-4 rounded-xl bg-white text-black font-bold text-[11px] uppercase tracking-[0.2em] flex items-center gap-3 transition-all duration-300"
|
||||
>
|
||||
View case
|
||||
<Zap size={14} />
|
||||
</motion.div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
136
app/lab/page.tsx
|
|
@ -1,136 +0,0 @@
|
|||
"use client";
|
||||
import { LAB_SERVICES } from "@/data/lab";
|
||||
import { Lock, Box, Globe } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
|
||||
export default function LabPage() {
|
||||
return (
|
||||
<PageLayout backLink="/" maxWidth="6xl">
|
||||
<header className="mb-32">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Box className="text-blue-500" size={20} />
|
||||
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase">
|
||||
System Lab
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-neutral-500 max-w-2xl leading-relaxed text-sm">
|
||||
A registry of self hosted operational services and experimental R&D.
|
||||
Services labeled
|
||||
<span className="text-blue-500"> [VPN]</span> are secured via
|
||||
Tailscale to maintain a hardened perimeter for sensitive telemetry.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-40 mb-20">
|
||||
{" "}
|
||||
{/* Increased spacing for alternating rhythm */}
|
||||
{LAB_SERVICES.map((service, i) => {
|
||||
const isEven = i % 2 === 0;
|
||||
return (
|
||||
<motion.section
|
||||
key={service.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
className={`flex flex-col ${isEven ? "md:flex-row" : "md:flex-row-reverse"} gap-12 md:gap-24 items-center group`}
|
||||
>
|
||||
{/* Image Side */}
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="relative group aspect-[1.9/1] rounded-2xl overflow-hidden border border-neutral-800 bg-black shadow-2xl transition-colors hover:border-blue-500/50">
|
||||
<Image
|
||||
src={service.image}
|
||||
alt={service.name}
|
||||
fill
|
||||
className="object-cover transition-all duration-500 ease-out brightness-100 grayscale-[0.2] group-hover:grayscale-0 scale-100 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Side */}
|
||||
<div className="w-full md:w-1/2 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center flex-wrap gap-3">
|
||||
<h3 className="text-2xl font-bold text-white tracking-tight">
|
||||
{service.name}
|
||||
</h3>
|
||||
|
||||
{/* Live Status Badge */}
|
||||
{service.uptimeId && (
|
||||
<div className="flex items-center shrink-0">
|
||||
<Image
|
||||
src={`https://status.georgew.dev/api/badge/${service.uptimeId}/status`}
|
||||
alt="Online"
|
||||
width={90}
|
||||
height={20}
|
||||
className="
|
||||
h-4 w-auto
|
||||
transition-all duration-500 ease-in-out
|
||||
grayscale opacity-60
|
||||
group-hover:grayscale-0 group-hover:opacity-100
|
||||
"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{service.stack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-[10px] text-blue-500/70 uppercase tracking-widest font-bold"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-400 text-sm leading-relaxed max-w-md">
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
{/* Conditional Actions */}
|
||||
<div className="flex items-center gap-6 pt-4 border-t border-neutral-800/50">
|
||||
{service.visibility === "public" && service.url ? (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-[10px] text-white hover:text-blue-400 transition-colors uppercase font-bold tracking-widest"
|
||||
>
|
||||
<Globe size={14} /> Visit Service
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-[10px] text-neutral-600 uppercase font-bold tracking-widest cursor-default">
|
||||
<Lock size={12} className="text-blue-500/50" /> VPN
|
||||
Encrypted
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.gitUrl && (
|
||||
<a
|
||||
href={service.gitUrl}
|
||||
target="_blank"
|
||||
className="group/git flex items-center gap-2 text-[10px] text-neutral-500 hover:text-white transition-colors uppercase font-bold tracking-widest bg-neutral-900/50 px-3 py-1.5 rounded-lg border border-neutral-800 hover:border-neutral-700"
|
||||
>
|
||||
<Image
|
||||
src="/forgejo.svg"
|
||||
alt="Forgejo"
|
||||
width={12}
|
||||
height={12}
|
||||
className="opacity-50 group-hover/git:opacity-100 transition-opacity"
|
||||
/>
|
||||
{`Source`}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,11 +13,8 @@ const geistMono = Geist_Mono({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "George W.",
|
||||
description: "Portfolio site",
|
||||
icons: {
|
||||
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
|
||||
},
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
327
app/page.tsx
|
|
@ -1,83 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { Globe, Smartphone, Server, Hammer } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Globe, Smartphone, Server, Gamepad2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import MonitorCard from "@/components/MonitorCard";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
import { CategoryCardProps } from "@/types/index";
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
const [isHoveringMonitors, setIsHoveringMonitors] = useState(false);
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="7xl">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col gap-12"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<motion.header variants={itemVariants}>
|
||||
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<header className="mb-12">
|
||||
<h1 className="text-4xl font-bold tracking-tight">George W.</h1>
|
||||
<p className="text-neutral-400 mt-2">
|
||||
Senior Full Stack Engineer & Tech Lead
|
||||
</p>
|
||||
<div className="flex gap-6 text-[10px] font-mono tracking-[0.2em] uppercase mt-4">
|
||||
<a
|
||||
href="https://git.georgew.dev/georgew"
|
||||
href="https://git.georgew.dev"
|
||||
className="text-neutral-500 hover:text-white transition-colors"
|
||||
>
|
||||
Git
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/in/george-webberley/"
|
||||
href="https://linkedin.com/in/georgew"
|
||||
className="text-neutral-500 hover:text-white transition-colors"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
<Link
|
||||
href="/cv"
|
||||
className="text-neutral-500 hover:text-white transition-colors"
|
||||
>
|
||||
CV
|
||||
</Link>
|
||||
</div>
|
||||
</motion.header>
|
||||
</header>
|
||||
|
||||
{/* Main Bento Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-6">
|
||||
{/* Top Row Left: Technical Focus */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="md:col-span-4 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex flex-col md:flex-row gap-8 min-h-[300px] overflow-hidden relative"
|
||||
>
|
||||
{/* Top Row Left: The Architect */}
|
||||
<div className="md:col-span-4 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex flex-col md:flex-row gap-8 min-h-[300px] overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-[0.03] pointer-events-none"></div>
|
||||
|
||||
{/* Description and tags */}
|
||||
<div className="flex-[1.5] flex flex-col justify-between relative z-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-4 tracking-tight">
|
||||
Technical Focus
|
||||
The Architect
|
||||
</h2>
|
||||
<p className="text-base text-neutral-400 leading-relaxed max-w-lg">
|
||||
Bridging the gap between rigid regulatory requirements and
|
||||
|
|
@ -110,155 +76,174 @@ export default function Home() {
|
|||
|
||||
<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="space-y-6">
|
||||
<TechnicalFocus
|
||||
label="Leadership"
|
||||
color="text-blue-500"
|
||||
text="Tech Lead & Scrum Master. Orchestrating sprint cycles and system design."
|
||||
/>
|
||||
<TechnicalFocus
|
||||
label="Integrity"
|
||||
color="text-purple-500"
|
||||
text="Medical/Regulatory environments, QMS, and Cyber Essentials."
|
||||
/>
|
||||
<TechnicalFocus
|
||||
label="Infrastructure"
|
||||
color="text-green-500"
|
||||
text="Kubernetes, GCP, and automated CI/CD pipelines."
|
||||
/>
|
||||
<section>
|
||||
<h4 className="text-[12px] font-mono text-blue-500 uppercase tracking-[0.2em] mb-2">
|
||||
Leadership
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-300 leading-tight">
|
||||
Tech Lead & Scrum Master. Orchestrating sprint cycles,
|
||||
system design, and cross-functional team growth.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-[12px] font-mono text-purple-500 uppercase tracking-[0.2em] mb-2">
|
||||
Integrity
|
||||
</h4>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
{/* Top Row Right: The Service Registry */}
|
||||
<Link href="/lab" className="md:col-span-2 flex flex-col group">
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5 }}
|
||||
onMouseEnter={() => setIsHoveringMonitors(true)}
|
||||
onMouseLeave={() => setIsHoveringMonitors(false)}
|
||||
className="flex-1 p-6 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-center relative overflow-hidden min-h-[180px] hover:border-blue-500/30"
|
||||
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>
|
||||
</Link>
|
||||
|
||||
{/* Project Category Cards */}
|
||||
<CategoryCard
|
||||
href="/projects/web"
|
||||
icon={<Globe className="text-blue-400 w-6 h-6 mb-4" />}
|
||||
title="Web Systems"
|
||||
description="Architecting distributed platforms with a focus on high-availability."
|
||||
tech={["Next.js", "Python", "Node.js", "Caddy", "PostgreSQL"]}
|
||||
hoverColor="hover:border-blue-500/30"
|
||||
activeTechColor="group-hover:text-blue-400"
|
||||
/>
|
||||
|
||||
<CategoryCard
|
||||
href="/projects/mobile"
|
||||
icon={<Smartphone className="text-purple-400 w-6 h-6 mb-4" />}
|
||||
title="Mobile Apps"
|
||||
description="Building fluid, cross-platform experiences using reactive state."
|
||||
tech={["Android", "iOS", "Flutter", "Riverpod", "Stores"]}
|
||||
hoverColor="hover:border-purple-500/30"
|
||||
activeTechColor="group-hover:text-purple-400"
|
||||
/>
|
||||
|
||||
<CategoryCard
|
||||
href="/infrastructure"
|
||||
icon={<Server className="text-green-400 w-6 h-6 mb-4" />}
|
||||
title="Infrastructure"
|
||||
description="Resilient cloud environments with automated IaC and multi-region orchestration."
|
||||
tech={["Kubernetes", "Terraform", "GCP", "CI/CD", "Security"]}
|
||||
hoverColor="hover:border-green-500/30"
|
||||
activeTechColor="group-hover:text-green-400"
|
||||
/>
|
||||
|
||||
{/* Bottom Row: The Forge */}
|
||||
<Link href="/forge" className="md:col-span-6">
|
||||
{/* Middle Row: Web Systems */}
|
||||
<Link href="/projects/web" className="group md:col-span-2">
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex items-center gap-6 group hover:border-orange-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="p-4 bg-orange-500/10 rounded-2xl border border-orange-500/20 group-hover:border-orange-500/40 transition-colors">
|
||||
<Hammer className="text-orange-500 w-8 h-8 group-hover:rotate-12 transition-transform" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<h3 className="font-bold text-xl tracking-tight">
|
||||
The Forge
|
||||
</h3>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
|
||||
</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>
|
||||
</motion.div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalFocus({
|
||||
label,
|
||||
color,
|
||||
text,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h4
|
||||
className={`text-[12px] font-mono ${color} uppercase tracking-[0.2em] mb-2`}
|
||||
>
|
||||
{label}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-300 leading-tight">{text}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryCard({
|
||||
href,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
tech,
|
||||
hoverColor,
|
||||
activeTechColor,
|
||||
}: CategoryCardProps) {
|
||||
return (
|
||||
<Link href={href} className="group md:col-span-2">
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5 }}
|
||||
className={`p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors ${hoverColor}`}
|
||||
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-blue-500/30"
|
||||
>
|
||||
<div>
|
||||
{icon}
|
||||
<h3 className="font-bold text-xl mb-2">{title}</h3>
|
||||
<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">
|
||||
{description}
|
||||
Architecting distributed platforms with a focus on
|
||||
high-availability and containerized deployment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{tech.map((t: string) => (
|
||||
{["Next.js", "Python", "Node.js", "Caddy", "PostgreSQL"].map(
|
||||
(tech) => (
|
||||
<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`}
|
||||
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"
|
||||
>
|
||||
{t}
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Middle Row: Mobile Apps */}
|
||||
<Link href="/projects/mobile" className="group md:col-span-2">
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-purple-500/30"
|
||||
>
|
||||
<div>
|
||||
<Smartphone className="text-purple-400 w-6 h-6 mb-4" />
|
||||
<h3 className="font-bold text-xl mb-2">Mobile Apps</h3>
|
||||
<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 */}
|
||||
<Link href="/projects/infrastructure" className="group md:col-span-2">
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-green-500/30"
|
||||
>
|
||||
<div>
|
||||
<Server className="text-green-400 w-6 h-6 mb-4" />
|
||||
<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
|
||||
pipelines and proactive monitoring.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{["Docker", "Woodpecker", "Hetzner", "Linux", "Uptime"].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-green-400 group-hover:border-green-500/20 transition-all"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* 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">
|
||||
<div className="p-4 bg-orange-500/10 rounded-2xl border border-orange-500/20 group-hover:border-orange-500/40 transition-colors">
|
||||
<Gamepad2 className="text-orange-500 w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-xl">The Forge</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Indie Game Dev & Creative Prototypes
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<p>Pipeline Status</p>
|
||||
<img
|
||||
src="https://ci.georgew.dev/api/badges/11/status.svg"
|
||||
alt="Build Status"
|
||||
className="h-3 grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" />
|
||||
<p>Engine: Next.js 15 (Standalone)</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { ExternalLink, Github, ShieldCheck, Cpu, Users } from "lucide-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";
|
||||
import MobileStack from "@/components/MobileStack";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
|
||||
// 1. Professional Animation Variants
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15, // Smoothly delay each section
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sectionVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.8, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
export default function ProjectDetail({
|
||||
params,
|
||||
|
|
@ -40,35 +25,30 @@ export default function ProjectDetail({
|
|||
const { category, slug } = use(params);
|
||||
const project = PROJECT_REGISTRY.find((p) => p.slug === slug);
|
||||
|
||||
if (!project) {
|
||||
if (!project) return <div>Project Not Found</div>;
|
||||
|
||||
if (!project)
|
||||
return (
|
||||
<PageLayout backLink={`/projects/${category}`}>
|
||||
<div className="font-mono text-white">Project Log Not Found.</div>
|
||||
</PageLayout>
|
||||
<div className="p-24 text-white font-mono">Project Log Not Found.</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
backLink={
|
||||
category == "infrastructure"
|
||||
? "/infrastructure"
|
||||
: `/projects/${category}`
|
||||
}
|
||||
backLabel={`Back to ${category}`}
|
||||
maxWidth="6xl"
|
||||
>
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Navigation */}
|
||||
<Link
|
||||
href={`/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"
|
||||
>
|
||||
<ArrowLeft size={12} /> Back to {category}
|
||||
</Link>
|
||||
|
||||
{/* Header Section */}
|
||||
<motion.header
|
||||
variants={sectionVariants}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20"
|
||||
<header className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h1 className="text-6xl font-bold tracking-tighter mb-4">
|
||||
{project.title}
|
||||
</h1>
|
||||
|
|
@ -83,66 +63,84 @@ export default function ProjectDetail({
|
|||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
className="flex items-center gap-2 bg-white text-black px-6 py-3 rounded-full font-bold text-sm hover:scale-105 transition-transform"
|
||||
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"
|
||||
>
|
||||
Launch Site <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{project?.repoUrl && (
|
||||
<a
|
||||
href={project.repoUrl}
|
||||
className="flex items-center gap-2 border border-neutral-800 px-6 py-3 rounded-full font-bold text-sm hover:bg-neutral-900 transition-colors"
|
||||
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"
|
||||
>
|
||||
View Source <Github size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Sidebar */}
|
||||
<div className="bg-neutral-900/30 border border-neutral-800 p-8 rounded-3xl h-fit backdrop-blur-sm">
|
||||
<div className="space-y-8 font-mono">
|
||||
<StatItem
|
||||
icon={<ShieldCheck className="text-blue-500" />}
|
||||
label="My Role"
|
||||
value={project.role}
|
||||
/>
|
||||
<StatItem
|
||||
icon={<Cpu className="text-purple-500" />}
|
||||
label="Stack"
|
||||
value={project.stack.join(", ")}
|
||||
/>
|
||||
<StatItem
|
||||
icon={<Users className="text-green-500" />}
|
||||
label="Impact"
|
||||
value={project.metrics.join(" • ")}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="bg-neutral-900/30 border border-neutral-800 p-8 rounded-3xl h-fit"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<ShieldCheck className="text-blue-500" />
|
||||
<div>
|
||||
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
|
||||
My Role
|
||||
</p>
|
||||
<p className="text-sm font-semibold">{project.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
<div className="flex items-center gap-4">
|
||||
<Cpu className="text-purple-500" />
|
||||
<div>
|
||||
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
|
||||
Stack
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{project.stack.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Users className="text-green-500" />
|
||||
<div>
|
||||
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
|
||||
Impact
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{project.metrics.join(" • ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</header>
|
||||
|
||||
{/* Media Showcase */}
|
||||
<motion.section variants={sectionVariants} className="mb-20">
|
||||
{project.category === "mobile" ? (
|
||||
<MobileStack images={project.images} />
|
||||
) : (
|
||||
<>
|
||||
<section className="mb-20">
|
||||
{/* Desktop Showcase View */}
|
||||
<div className="hidden lg:block">
|
||||
<ProjectShowcase images={project.images} />
|
||||
</div>
|
||||
|
||||
{/* Mobile Carousel View */}
|
||||
<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]">
|
||||
|
||||
<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>
|
||||
</motion.section>
|
||||
</section>
|
||||
|
||||
{/* System Architecture */}
|
||||
{/* Mermaid */}
|
||||
{project.mermaidChart && (
|
||||
<motion.section variants={sectionVariants} className="mb-16">
|
||||
<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]">
|
||||
|
|
@ -150,17 +148,15 @@ export default function ProjectDetail({
|
|||
</h3>
|
||||
<div className="h-px flex-1 bg-neutral-900" />
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12">
|
||||
<Mermaid chart={project.mermaidChart} />
|
||||
</div>
|
||||
</motion.section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Engineering Narrative */}
|
||||
<motion.section
|
||||
variants={sectionVariants}
|
||||
className="w-full pb-20 mt-12"
|
||||
>
|
||||
<section className="w-full pb-20 mt-12">
|
||||
<div className="flex items-center gap-3 mb-12">
|
||||
<h3 className="text-[10px] font-mono text-blue-500 uppercase tracking-[0.3em] font-bold">
|
||||
PROJECT LOG // {project.storyLabel || "NARRATIVE"}
|
||||
|
|
@ -170,34 +166,12 @@ export default function ProjectDetail({
|
|||
The Engineering Story
|
||||
</h2>
|
||||
</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>
|
||||
</div>
|
||||
</motion.section>
|
||||
</motion.div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Small helper component to keep the JSX clean
|
||||
function StatItem({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{icon}
|
||||
<div>
|
||||
<p className="text-[10px] text-neutral-500 uppercase tracking-tighter">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { Globe, Smartphone, Server } from "lucide-react";
|
||||
import { Globe, Smartphone, ArrowLeft, Server } from "lucide-react";
|
||||
import { use } from "react";
|
||||
import { PROJECT_REGISTRY } from "@/data/projects";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
|
||||
const CATEGORY_META = {
|
||||
web: {
|
||||
|
|
@ -28,27 +26,6 @@ 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({
|
||||
params,
|
||||
}: {
|
||||
|
|
@ -56,59 +33,78 @@ export default function CategoryPage({
|
|||
}) {
|
||||
const resolvedParams = use(params);
|
||||
const category = resolvedParams.category;
|
||||
|
||||
const meta = CATEGORY_META[category as keyof typeof CATEGORY_META];
|
||||
|
||||
const filteredProjects = PROJECT_REGISTRY.filter(
|
||||
(p) => p.category === category,
|
||||
);
|
||||
|
||||
if (!meta) return <PageLayout backLink="/">Sector not found.</PageLayout>;
|
||||
if (!meta) {
|
||||
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 (
|
||||
<PageLayout backLink="/" maxWidth="6xl">
|
||||
<div className="mb-16">
|
||||
<h1 className="flex items-center gap-4 text-5xl font-bold tracking-tighter mb-6">
|
||||
{meta.icon} {meta.title}
|
||||
</h1>
|
||||
<p className="text-xl text-neutral-400 max-w-2xl leading-relaxed">
|
||||
<main className="min-h-screen bg-[#0a0a0a] text-white p-8 md:p-24">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-xs"
|
||||
>
|
||||
<ArrowLeft size={14} /> BACK TO DASHBOARD
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2. The container manages the entrance of all children */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="grid grid-cols-1 gap-6"
|
||||
>
|
||||
{filteredProjects.map((project) => (
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{filteredProjects.map((project, index) => (
|
||||
<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"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{
|
||||
delay: index * 0.1,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="group p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 hover:border-neutral-700 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold mb-2 group-hover:text-blue-400 transition-colors">
|
||||
<h3 className="text-2xl font-semibold mb-2">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="text-neutral-500 text-sm max-w-xl">
|
||||
{project.description}
|
||||
</p>
|
||||
<p className="text-neutral-500">{project.description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
{project.stack.map((tech) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.stack.map((s) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-[9px] font-mono bg-neutral-800 text-neutral-400 px-3 py-1 rounded-full uppercase tracking-widest border border-neutral-800"
|
||||
key={s}
|
||||
className="text-[10px] font-mono bg-neutral-800 text-neutral-400 px-3 py-1 rounded-full uppercase"
|
||||
>
|
||||
{tech}
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -116,7 +112,8 @@ export default function CategoryPage({
|
|||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PageLayout>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-12 pt-8 border-t border-neutral-900 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] text-neutral-600 font-mono tracking-wider uppercase">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<p>Pipeline Status</p>
|
||||
<Image
|
||||
src="https://ci.georgew.dev/api/badges/11/status.svg"
|
||||
alt="CI Build Status"
|
||||
width={64}
|
||||
height={20}
|
||||
unoptimized
|
||||
className="h-3 w-auto grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" />
|
||||
<p>Engine: Next.js 15 (Standalone)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-neutral-900/50 px-3 py-1 rounded-full border border-neutral-800/50">
|
||||
<div className="w-1 h-1 rounded-full bg-blue-500" />
|
||||
<p className="text-neutral-400">
|
||||
Deploy: {process.env.NEXT_PUBLIC_APP_VERSION || "v1.0.0-dev"}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { History, ExternalLink, Cpu } from "lucide-react";
|
||||
import { ForgeProject } from "@/types";
|
||||
|
||||
export default function ForgeUI({ project }: { project: ForgeProject }) {
|
||||
return (
|
||||
<div className="bg-neutral-900/40 border border-neutral-800 rounded-3xl p-8 pt-0 mb-12 overflow-hidden">
|
||||
{/* Upper Section: Hero Area */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div className="lg:col-span-8 flex flex-col justify-center">
|
||||
<div className="flex flex-wrap justify-between items-start gap-6 mb-6">
|
||||
<div>
|
||||
<h2 className="text-4xl font-bold text-white mb-4 tracking-tighter">
|
||||
{project.projectName}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Version Badge */}
|
||||
<span className="px-3 py-1 bg-orange-500/10 border border-orange-500/20 text-orange-500 text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||
{project.currentVersion}
|
||||
</span>
|
||||
|
||||
{/* Engine Tag */}
|
||||
<span className="px-3 py-1 bg-neutral-800 text-neutral-400 text-[10px] font-bold uppercase tracking-widest rounded-full flex items-center gap-2">
|
||||
<Cpu size={12} /> {project.engine}
|
||||
</span>
|
||||
|
||||
{/* Status Indicator (Now part of the tag row) */}
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-green-500/60 ml-1">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"></span>
|
||||
</span>
|
||||
ACTIVE STREAM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project.externalLink && (
|
||||
<a
|
||||
href={project.externalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs font-bold text-white bg-neutral-800 hover:bg-neutral-700 transition-colors px-4 py-2 rounded-xl shrink-0"
|
||||
>
|
||||
Project Details <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-400 text-base leading-relaxed max-w-2xl">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{project.imagePath && (
|
||||
<div className="lg:col-span-4 flex justify-center lg:justify-end items-center">
|
||||
<div className="relative w-full aspect-square max-w-[280px] group">
|
||||
<Image
|
||||
src={project.imagePath}
|
||||
alt={`${project.projectName} Feature`}
|
||||
fill
|
||||
className="object-contain drop-shadow-[0_0_40px_rgba(249,115,22,0.15)] transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-orange-500/10 blur-[80px] rounded-full -z-10 opacity-40 group-hover:opacity-60 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Technical Highlights */}
|
||||
<div className="flex flex-wrap gap-2 mb-12">
|
||||
{project.highlights.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] text-neutral-600 font-mono uppercase tracking-tighter border border-neutral-800 px-3 py-1 rounded"
|
||||
>
|
||||
{`// ${tag}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lower Section: Logs with Gradient Fade */}
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500 uppercase tracking-widest border-b border-neutral-800 pb-2">
|
||||
<History size={14} /> Dev_Log
|
||||
</div>
|
||||
|
||||
{/* This container handles the fade effect */}
|
||||
<div
|
||||
className="space-y-8 relative"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
{project.changelog.map((entry) => (
|
||||
<div
|
||||
key={entry.version}
|
||||
className="relative pl-8 border-l border-neutral-800"
|
||||
>
|
||||
<div className="absolute -left-1.5 top-1.5 w-3 h-3 bg-neutral-950 border border-neutral-700 rounded-full" />
|
||||
<div className="flex items-baseline gap-4 mb-3">
|
||||
<span className="text-orange-500 font-mono text-sm font-bold leading-none">
|
||||
{entry.version}
|
||||
</span>
|
||||
<span className="text-neutral-600 font-mono text-xs leading-none">
|
||||
{entry.date}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2.5">
|
||||
{entry.changes.map((change, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="text-neutral-400 text-sm leading-relaxed max-w-4xl"
|
||||
>
|
||||
{change}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { History, ExternalLink, Cpu } from "lucide-react";
|
||||
import { ForgeProject } from "@/types";
|
||||
|
||||
export default function ForgeUI({ project }: { project: ForgeProject }) {
|
||||
return (
|
||||
<div className="bg-neutral-900/40 border border-neutral-800 rounded-3xl p-8 pt-0 mb-12 overflow-hidden">
|
||||
{/* Upper Section: Hero Area */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pt-4">
|
||||
{" "}
|
||||
{/* Added pt-8 here to replace the p-8 pt-0 conflict */}
|
||||
<div className="lg:col-span-8 flex flex-col justify-center">
|
||||
<div className="flex flex-wrap justify-between items-start gap-6 mb-6">
|
||||
<div>
|
||||
<h2 className="text-4xl font-bold text-white mb-4 tracking-tighter">
|
||||
{project.projectName}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="px-3 py-1 bg-orange-500/10 border border-orange-500/20 text-orange-500 text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||
{project.currentVersion}
|
||||
</span>
|
||||
|
||||
<span className="px-3 py-1 bg-neutral-800 text-neutral-400 text-[10px] font-bold uppercase tracking-widest rounded-full flex items-center gap-2">
|
||||
<Cpu size={12} /> {project.engine}
|
||||
</span>
|
||||
|
||||
{project.isRecent && (
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-green-500/60 ml-1">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"></span>
|
||||
</span>
|
||||
ACTIVE STREAM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project.externalLink && (
|
||||
<a
|
||||
href={project.externalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs font-bold text-white bg-neutral-800 hover:bg-neutral-700 transition-colors px-4 py-2 rounded-xl shrink-0"
|
||||
>
|
||||
Project Details <ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-400 text-base leading-relaxed max-w-2xl">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
{project.imagePath && (
|
||||
<div className="lg:col-span-4 flex justify-center lg:justify-end items-center">
|
||||
<div className="relative w-full aspect-square max-w-[280px] group">
|
||||
<Image
|
||||
src={project.imagePath}
|
||||
alt={`${project.projectName} Feature`}
|
||||
fill
|
||||
className="object-contain drop-shadow-[0_0_40px_rgba(249,115,22,0.15)] transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-orange-500/10 blur-[80px] rounded-full -z-10 opacity-40 group-hover:opacity-60 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Technical Highlights */}
|
||||
<div className="flex flex-wrap gap-2 mb-12 mt-6">
|
||||
{project.highlights.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] text-neutral-600 font-mono uppercase tracking-tighter border border-neutral-800 px-3 py-1 rounded"
|
||||
>
|
||||
{`// ${tag}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lower Section: Logs */}
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500 uppercase tracking-widest border-b border-neutral-800 pb-2">
|
||||
<History size={14} /> Dev_Log
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="space-y-10 relative pb-4" // Increased space between version blocks
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
{project.changelog.map((entry) => (
|
||||
<div
|
||||
key={entry.version}
|
||||
className="ml-1.5 relative pl-10 border-l border-neutral-800" // Increased pl-8 to pl-10 to fix clipping
|
||||
>
|
||||
{/* The Version Indicator Circle */}
|
||||
<div className="absolute -left-1.5 top-1 w-3 h-3 bg-neutral-950 border border-neutral-700 rounded-full" />
|
||||
|
||||
<div className="flex items-baseline gap-4 mb-4">
|
||||
<span className="text-orange-500 font-mono text-sm font-bold leading-none">
|
||||
{entry.version}
|
||||
</span>
|
||||
<span className="text-neutral-600 font-mono text-[10px] uppercase tracking-wider leading-none">
|
||||
{entry.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Individual Changes with Bullet Points */}
|
||||
<ul className="space-y-3">
|
||||
{entry.changes.map((change, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="text-neutral-400 text-sm leading-relaxed max-w-4xl flex items-start gap-3"
|
||||
>
|
||||
{/* The Bullet Character */}
|
||||
<span className="text-orange-500/40 mt-1.5 shrink-0 text-[10px]">
|
||||
•
|
||||
</span>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
components/Mermaid copy 2.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
98
components/Mermaid copy.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export function MobileFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative mx-auto border-neutral-800 bg-neutral-800 border-[8px] rounded-[2.5rem] h-[600px] w-[300px] shadow-2xl overflow-hidden">
|
||||
<div className="absolute top-0 inset-x-0 h-6 bg-neutral-800 rounded-b-xl z-20 w-32 mx-auto" />
|
||||
<div className="relative h-full w-full bg-black overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { MobileFrame } from "./MobileFrame";
|
||||
|
||||
export default function MobileStack({ images }: { images: string[] }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const DRAG_THRESHOLD = -150;
|
||||
|
||||
const getRelativeIndex = (index: number) => {
|
||||
const len = images.length;
|
||||
return (index - currentIndex + len) % len;
|
||||
};
|
||||
|
||||
const next = () => setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
|
||||
return (
|
||||
<div className="relative h-[750px] w-full flex flex-col items-center py-20 overflow-hidden group">
|
||||
<div className="relative h-[650px] w-full flex justify-center items-center">
|
||||
<AnimatePresence initial={false}>
|
||||
{images.map((img, index) => {
|
||||
const relIndex = getRelativeIndex(index);
|
||||
const isTop = relIndex === 0;
|
||||
|
||||
const xOffset = relIndex * 90;
|
||||
|
||||
if (relIndex > 5) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={img}
|
||||
style={{ zIndex: images.length - relIndex }}
|
||||
initial={{ opacity: 0, x: 400 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: isTop ? 0 : xOffset,
|
||||
scale: isTop ? 1 : 0.96,
|
||||
filter: isTop ? "brightness(1)" : "brightness(0.4)",
|
||||
pointerEvents: isTop ? "auto" : "all",
|
||||
}}
|
||||
exit={{
|
||||
x: -1000,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.4, ease: "easeIn" },
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
whileHover={
|
||||
!isTop ? { scale: 0.98, filter: "brightness(0.6)" } : {}
|
||||
}
|
||||
drag={isTop ? "x" : false}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.8}
|
||||
onDrag={(_, info) => {
|
||||
if (isTop && info.offset.x < DRAG_THRESHOLD) {
|
||||
next();
|
||||
}
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
// Backup check for quick flicks
|
||||
if (isTop && info.offset.x < -100) {
|
||||
next();
|
||||
}
|
||||
}}
|
||||
onClick={() => !isTop && setCurrentIndex(index)}
|
||||
className="absolute"
|
||||
>
|
||||
<div
|
||||
className={`${isTop ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"}`}
|
||||
>
|
||||
<MobileFrame>
|
||||
<img
|
||||
src={img}
|
||||
alt="App Screenshot"
|
||||
draggable="false"
|
||||
className="w-full h-full object-cover select-none"
|
||||
/>
|
||||
</MobileFrame>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Button */}
|
||||
<div className="absolute -bottom-12 z-[100]">
|
||||
<button
|
||||
onClick={next}
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-full bg-black/40 backdrop-blur-xl border border-white/5 text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 group-hover:translate-y-[-20px]"
|
||||
>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.2em]">
|
||||
Next Screen
|
||||
</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,56 +7,52 @@ import Image from "next/image";
|
|||
|
||||
const MONITORS = [
|
||||
{ id: 2, name: "Datasaur" },
|
||||
{ id: 12, name: "Observatory" },
|
||||
{ id: 16, name: "Fossil tracker" },
|
||||
{ id: 6, name: "Audiobookshelf" },
|
||||
{ id: 7, name: "Woodpecker CI" },
|
||||
{ id: 8, name: "Forgejo Git" },
|
||||
{ id: 9, name: "Server dashboard" },
|
||||
{ id: 10, name: "Ratoong" },
|
||||
{ id: 3, name: "Dozzle" },
|
||||
{ id: 12, name: "Observatory" },
|
||||
{ id: 13, name: "Surf hub" },
|
||||
{ id: 11, name: "Anime list" },
|
||||
{ id: 5, name: "Wiki" },
|
||||
{ id: 14, name: "Paperless" },
|
||||
{ id: 4, name: "Watchtower" },
|
||||
];
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
const INTERVAL_TIME = 2500;
|
||||
|
||||
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
||||
export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
|
||||
const [page, setPage] = useState(0);
|
||||
const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (isHovered) {
|
||||
// Start rotating pages only when hovered
|
||||
if (isHovered && totalPages > 1) {
|
||||
interval = setInterval(() => {
|
||||
setPage((p) => (p + 1) % totalPages);
|
||||
}, INTERVAL_TIME);
|
||||
} else {
|
||||
// Defer state reset to avoid "cascading render" error
|
||||
// and allow the fade-out animation to play smoothly
|
||||
const timeout = setTimeout(() => {
|
||||
setPage(0);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
setPage((prev) => (prev + 1) % totalPages);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
if (!isHovered) {
|
||||
setPage(0);
|
||||
}
|
||||
};
|
||||
}, [isHovered, totalPages]);
|
||||
|
||||
const currentMonitors = MONITORS.slice(
|
||||
page * ITEMS_PER_PAGE,
|
||||
(page + 1) * ITEMS_PER_PAGE,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
|
||||
{/* --- DEFAULT VIEW --- */}
|
||||
<div
|
||||
className={`transition-opacity duration-300 ${
|
||||
isHovered ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<>
|
||||
{/* 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">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
|
@ -65,9 +61,12 @@ export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
|||
Hetzner Node-01
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-neutral-500">
|
||||
SYS_STATUS: ONLINE
|
||||
</p>
|
||||
<div className="flex gap-2 items-center">
|
||||
<p className="text-xs text-neutral-500 font-mono">SYS_STATUS:</p>
|
||||
<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">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Activity className="text-neutral-800 w-10 h-10 -mt-1" />
|
||||
</div>
|
||||
|
|
@ -88,99 +87,42 @@ export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- REGISTRY VIEW --- */}
|
||||
<div
|
||||
className={`absolute inset-0 transition-all duration-300 flex flex-col ${
|
||||
isHovered
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{/* HEADER WITH SYNCED TIMER */}
|
||||
<div className="flex items-center justify-between mb-3 group/header">
|
||||
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] flex items-center gap-2 group-hover:text-blue-400 transition-colors">
|
||||
Explore Systems
|
||||
<motion.span
|
||||
animate={{ x: [0, 4, 0] }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="inline-block"
|
||||
>
|
||||
→
|
||||
</motion.span>
|
||||
{/* Hover 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 className="flex justify-between items-center mb-2 px-1">
|
||||
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em]">
|
||||
Service Registry
|
||||
</h4>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-12 h-[2.5px] bg-neutral-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
key={`${isHovered}-${page}`}
|
||||
initial={{ width: "0%" }}
|
||||
animate={isHovered ? { width: "100%" } : { width: "0%" }}
|
||||
transition={{
|
||||
duration: isHovered ? INTERVAL_TIME / 1000 : 0,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="h-full bg-blue-500/50" // Changed to blue to match Link intent
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-neutral-600">
|
||||
{String(page + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<RegistrySlider page={page} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrySlider({ page }: { page: number }) {
|
||||
const currentItems = MONITORS.slice(
|
||||
page * ITEMS_PER_PAGE,
|
||||
(page + 1) * ITEMS_PER_PAGE,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<div className="flex-1 relative min-h-0 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
{currentItems.map((m) => (
|
||||
{currentMonitors.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"
|
||||
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-[11px] font-medium text-neutral-300 truncate mr-2">
|
||||
<span className="text-[12px] font-medium text-neutral-300 truncate mr-2">
|
||||
{m.name}
|
||||
</span>
|
||||
<div className="flex gap-1 shrink-0 scale-75 origin-right">
|
||||
<Image
|
||||
<div className="flex gap-1 shrink-0 scale-90 origin-right">
|
||||
<img
|
||||
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
|
||||
width={60}
|
||||
height={20}
|
||||
className="h-5 w-auto"
|
||||
alt="System Status"
|
||||
unoptimized
|
||||
className="h-5"
|
||||
alt="up"
|
||||
/>
|
||||
<Image
|
||||
<img
|
||||
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
|
||||
className="h-5 opacity-60"
|
||||
alt="ms"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -188,5 +130,7 @@ function RegistrySlider({ page }: { page: number }) {
|
|||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Footer from "./Footer"; // Assuming you moved it to a component
|
||||
import { PageLayoutProps } from "@/types/index";
|
||||
|
||||
export default function PageLayout({
|
||||
children,
|
||||
backLink,
|
||||
backLabel = "BACK TO DASHBOARD",
|
||||
maxWidth = "5xl",
|
||||
}: PageLayoutProps) {
|
||||
const widthClass = {
|
||||
"5xl": "max-w-5xl",
|
||||
"6xl": "max-w-6xl",
|
||||
"7xl": "max-w-7xl",
|
||||
}[maxWidth];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a0a0a] text-white pt-6 md:pt-12 lg:pt-24 px-6 md:px-12 lg:px-24 pb-8 flex flex-col">
|
||||
<div className={`${widthClass} mx-auto w-full flex-grow flex flex-col`}>
|
||||
<div className="flex-grow">
|
||||
{backLink && (
|
||||
<Link
|
||||
href={backLink}
|
||||
className="group flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-[10px] uppercase tracking-widest w-fit"
|
||||
>
|
||||
<ArrowLeft
|
||||
size={12}
|
||||
className="transition-transform group-hover:-translate-x-1"
|
||||
/>
|
||||
{backLabel}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,16 +43,11 @@ export default function ProjectShowcase({ images }: { images: string[] }) {
|
|||
: "border-neutral-800 opacity-40 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<Image
|
||||
<img
|
||||
src={img}
|
||||
alt={`Project showcase thumbnail ${i}`}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="h-full w-full object-cover"
|
||||
alt={`Thumb ${i}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{i === index && (
|
||||
<motion.div
|
||||
layoutId="active-thumb"
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { ForgeProjectMetadata } from "@/types";
|
||||
|
||||
export const FORGE_PROJECTS: ForgeProjectMetadata[] = [
|
||||
{
|
||||
id: "pixelpals",
|
||||
projectName: "PixelPals",
|
||||
repoName: "pixel-pals",
|
||||
status: "Alpha",
|
||||
engine: "Flutter / Firebase",
|
||||
imagePath: "/forge/pixel-pals.png",
|
||||
description:
|
||||
"A GPS-driven, cooperative creature collection game built to encourage exercise. Features turn-based tactical combat, class-based progression, and AI-generated companion art based on player descriptions.",
|
||||
highlights: [
|
||||
"GPS / Mapbox Integration",
|
||||
"Generative AI (Pet Synthesis)",
|
||||
"Real-time Firebase Sync",
|
||||
"Turn-based Battle Engine",
|
||||
],
|
||||
// externalLink: "https://pixelpals.app",
|
||||
},
|
||||
];
|
||||
113
data/lab.ts
|
|
@ -1,113 +0,0 @@
|
|||
import { LabService } from "@/types/index";
|
||||
|
||||
export const LAB_SERVICES: LabService[] = [
|
||||
{
|
||||
id: "observatory",
|
||||
name: "The Observatory",
|
||||
description:
|
||||
"Astronomical API orchestration and orbital visualization. Built to track ISS transits and lunar phases over Copenhagen.",
|
||||
stack: ["Next.js", "Node", "SQLite"],
|
||||
visibility: "public",
|
||||
url: "https://observatory.georgew.dev",
|
||||
gitUrl: "https://git.georgew.dev/georgew/mission-control",
|
||||
image: "/lab/observatory.jpg",
|
||||
uptimeId: 12,
|
||||
},
|
||||
{
|
||||
id: "dino-tracker",
|
||||
name: "GW Paleo",
|
||||
description:
|
||||
"A digital field journal and taxonomic registry for Dinosauria. Designed with a 'Museum Archive' aesthetic, this specimen tracker syncs with the Paleobiology Database (PBDB) to catalog thousands of validated species and genera.",
|
||||
stack: ["Next.js", "Prisma", "SQLite", "Tailwind CSS"],
|
||||
visibility: "public",
|
||||
url: "https://paleo.georgew.dev",
|
||||
gitUrl: "https://git.georgew.dev/georgew/dino-tracker",
|
||||
image: "/lab/dino-tracker.jpg",
|
||||
uptimeId: 16,
|
||||
},
|
||||
{
|
||||
id: "surf-hub",
|
||||
name: "Surf Sentinel",
|
||||
description:
|
||||
"Custom telemetry dashboard for Llangennith Bay, Wales. Pulling real-time buoy data and wave height predictions to find the perfect window for a session.",
|
||||
stack: ["Grafana", "Influx DB", "Node"],
|
||||
visibility: "public",
|
||||
url: "https://surf.georgew.dev/d/adrx6b4/llangennith-beach-surf-data?orgId=1&from=now-24h&to=now&timezone=browser&refresh=1h&theme=dark&kiosk=true",
|
||||
image: "/lab/surf-hub.jpg",
|
||||
gitUrl: "https://git.georgew.dev/georgew/surf-hub",
|
||||
uptimeId: 13,
|
||||
},
|
||||
{
|
||||
id: "audiobookshelf",
|
||||
name: "The Archive",
|
||||
description:
|
||||
"Dedicated audiobook server for the household. Primarily used for our Brandon Sanderson, Patrick Rothfuss, and Dungeon Crawler Carl marathons.",
|
||||
stack: ["Docker", "Compose", "Tailscale", "rclone"],
|
||||
visibility: "tailscale",
|
||||
image: "/lab/audiobookshelf.jpg",
|
||||
gitUrl: "https://git.georgew.dev/georgew/audiobookshelf",
|
||||
uptimeId: 6,
|
||||
},
|
||||
{
|
||||
id: "yamtrack",
|
||||
name: "Yamtrack",
|
||||
description:
|
||||
"A specialized tracker for our anime watch-lists! Features custom metadata hooks to keep our seasonal progress in sync.",
|
||||
stack: ["Docker", "Redis", "Tailscale"],
|
||||
visibility: "tailscale",
|
||||
image: "/lab/yamtrack.jpg",
|
||||
gitUrl: "https://git.georgew.dev/georgew/yamtrack",
|
||||
uptimeId: 11,
|
||||
},
|
||||
{
|
||||
id: "paperless",
|
||||
name: "Paperless-ngx",
|
||||
description:
|
||||
"Personal document management system with OCR and automated tagging. Digitizing our physical mail and records into a searchable, versioned archive.",
|
||||
stack: ["Docker", "Redis", "PostgreSQL"],
|
||||
visibility: "tailscale",
|
||||
image: "/lab/paperless.jpg",
|
||||
gitUrl: "https://git.georgew.dev/georgew/paperless-ngx",
|
||||
|
||||
uptimeId: 14,
|
||||
},
|
||||
{
|
||||
id: "change-detection",
|
||||
name: "Signal Watcher",
|
||||
description:
|
||||
"Automated monitoring for the essentials: NASA news updates, hobby stock alerts, and Telegram pings the second a new episode of 'The Traitors' drops.",
|
||||
stack: ["Telegram API", "Webhooks"],
|
||||
visibility: "tailscale",
|
||||
image: "/lab/change-detection.jpg",
|
||||
gitUrl: "https://git.georgew.dev/georgew/change-detection",
|
||||
uptimeId: 15,
|
||||
},
|
||||
{
|
||||
id: "ops-suite",
|
||||
name: "System Operations",
|
||||
description:
|
||||
"The 'Engine Room.' Utilizing Portainer for orchestration, Dozzle for log streaming, and Watchtower for automated container lifecycle management across the Hetzner node.",
|
||||
stack: ["Portainer", "Dozzle", "Watchtower"],
|
||||
visibility: "tailscale",
|
||||
image: "/lab/portainer.jpg",
|
||||
},
|
||||
{
|
||||
id: "wikijs",
|
||||
name: "System Wiki",
|
||||
description:
|
||||
"The 'Source of Truth' for the home infrastructure. Contains deployment guides, network maps, and disaster recovery procedures for the entire node.",
|
||||
stack: ["Wiki.js", "Markdown"],
|
||||
visibility: "tailscale",
|
||||
image: "/lab/wikijs.jpg",
|
||||
uptimeId: 5,
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
name: "System Dashboard",
|
||||
description:
|
||||
"The central entry point for the GeorgeW ecosystem, used as my personal home page. A high-level overview providing unified access to all public and VPN-secured services.",
|
||||
stack: ["Homepage", "Docker", "Reverse Proxy"],
|
||||
visibility: "tailscale",
|
||||
image: "/lab/dashboard.jpg",
|
||||
},
|
||||
];
|
||||
185
data/projects.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { Project } from "@/types/index";
|
||||
import { Project } from "@/types/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-5.jpg",
|
||||
],
|
||||
liveUrl: "https://www.ratoong.com/",
|
||||
liveUrl: "https://ratoong.com",
|
||||
isPrivate: false,
|
||||
mermaidChart: `
|
||||
graph LR
|
||||
|
|
@ -104,7 +104,7 @@ graph LR
|
|||
"/projects/datasaur/datasaur-6.jpg",
|
||||
],
|
||||
repoUrl: "https://git.georgew.dev/georgew/datasaur",
|
||||
liveUrl: "https://datasaur.dev",
|
||||
liveUrl: "https://datasaur.georgew.dev", // Adjusted based on your self-hosting mention
|
||||
isPrivate: false,
|
||||
engineeringStory: `
|
||||
Datasaur was born out of a necessity to bridge the gap between raw survey data and academic-grade statistical insights. The challenge wasn't just displaying data, but architecting a system capable of performing complex mathematical computations on-the-fly.
|
||||
|
|
@ -245,160 +245,41 @@ graph TB
|
|||
`,
|
||||
},
|
||||
{
|
||||
slug: "choosa",
|
||||
slug: "flutter-1",
|
||||
category: "mobile",
|
||||
title: "Choosa",
|
||||
subtitle: "Social Content Discovery Engine",
|
||||
role: "Lead Developer & Architect",
|
||||
duration: "2023 — Present",
|
||||
stack: [
|
||||
"Flutter",
|
||||
"Firebase",
|
||||
"Firestore",
|
||||
"Cloud Functions",
|
||||
"Push Notifications",
|
||||
],
|
||||
metrics: [
|
||||
"Real-time Match Engine",
|
||||
"Cross-Platform (iOS/Android)",
|
||||
"Multi-API Orchestration",
|
||||
],
|
||||
title: "Flutter-1",
|
||||
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 social decision-making app that solves 'choice paralysis' by allowing groups to swipe on movies and TV shows, using a real-time matching algorithm to find common interests.",
|
||||
storyLabel: "UX // MOBILE SYNCHRONIZATION",
|
||||
images: [
|
||||
"/projects/choosa/choosa-1.jpg",
|
||||
"/projects/choosa/choosa-2.jpg",
|
||||
"/projects/choosa/choosa-3.jpg",
|
||||
"/projects/choosa/choosa-4.jpg",
|
||||
"/projects/choosa/choosa-5.jpg",
|
||||
],
|
||||
isPrivate: false,
|
||||
engineeringStory: `
|
||||
Choosa was built to solve the universal problem of "choice paralysis" in social settings. The challenge was creating a low-latency, real-time environment where group preferences could be aggregated and matched instantaneously.
|
||||
|
||||
#### Real-time State & Match Logic
|
||||
The core engine utilizes **Firestore's** real-time listeners to sync swipe states across multiple devices simultaneously. I architected a custom matching algorithm within **Firebase Cloud Functions** that monitors group sessions; the moment a consensus is reached, the system triggers **Firebase Cloud Messaging (FCM)** to send push notifications to all participants, ensuring a seamless transition from "deciding" to "watching."
|
||||
|
||||
#### Data Orchestration & External APIs
|
||||
To provide a rich library of content, I integrated the **TMDB** and **Movie of the Night** APIs. By utilizing a middleware layer in Cloud Functions, I was able to normalize data from different sources, filter results based on user-specific streaming subscriptions, and cache results to minimize API overhead and latency.
|
||||
|
||||
#### Mobile Deployment & Native Experience
|
||||
Developing Choosa in **Flutter** allowed for a unified codebase while maintaining native performance on both iOS and Android. I managed the full deployment lifecycle, from configuring **Fastlane** for automated App Store and Play Store releases to handling platform-specific requirements like adaptive icons and deep-linking.
|
||||
`,
|
||||
mermaidChart: `
|
||||
graph LR
|
||||
subgraph Client_Mobile [Mobile Frontend]
|
||||
A[Flutter App]:::traffic
|
||||
end
|
||||
|
||||
subgraph Firebase_Core [Backend Services]
|
||||
Hub((Firebase SDK)):::hub
|
||||
B[Auth]:::node
|
||||
C[Firestore DB]:::node
|
||||
D[Cloud Functions]:::node
|
||||
E[Cloud Messaging]:::node
|
||||
end
|
||||
|
||||
subgraph External_Data [Content Providers]
|
||||
F[TMDB API]:::traffic
|
||||
G[Movie of Night API]:::traffic
|
||||
end
|
||||
|
||||
A <--> Hub
|
||||
Hub --> B
|
||||
Hub <-->|Sync State| C
|
||||
Hub -->|Trigger Match| D
|
||||
D -->|Push Notification| E
|
||||
E -->|Alert Group| A
|
||||
|
||||
D --> F
|
||||
D --> G
|
||||
|
||||
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
|
||||
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff
|
||||
`,
|
||||
"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",
|
||||
engineeringStory:
|
||||
"In this high-stakes medical environment, I implemented a custom audit logging system that ensured every state change was immutable...",
|
||||
storyLabel: "DATA EFFICIENCY",
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
{
|
||||
slug: "nutriveat",
|
||||
slug: "flutter-2",
|
||||
category: "mobile",
|
||||
title: "Nutriveat",
|
||||
subtitle: "AI-Powered Personalized Nutrition",
|
||||
role: "Lead Developer & Architect",
|
||||
duration: "2024 — Present",
|
||||
stack: [
|
||||
"Flutter",
|
||||
"Firebase",
|
||||
"OpenAI (GPT-4o)",
|
||||
"Novita AI",
|
||||
"StoreKit / Play Billing",
|
||||
],
|
||||
metrics: [
|
||||
"Fine-tuned LLM Assistants",
|
||||
"Direct Store Integrations",
|
||||
"Multi-Tier Subscriptions",
|
||||
],
|
||||
title: "Flutter-1",
|
||||
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 comprehensive health and nutrition platform that leverages fine-tuned generative AI to architect personalized meal plans, automate shopping lists, and provide real-time culinary assistance.",
|
||||
storyLabel: "AI ORCHESTRATION // MONETIZATION",
|
||||
images: [
|
||||
"/projects/nutriveat/nutriveat-6.jpg",
|
||||
"/projects/nutriveat/nutriveat-1.jpg",
|
||||
"/projects/nutriveat/nutriveat-2.jpg",
|
||||
"/projects/nutriveat/nutriveat-3.jpg",
|
||||
"/projects/nutriveat/nutriveat-4.jpg",
|
||||
"/projects/nutriveat/nutriveat-5.jpg",
|
||||
],
|
||||
isPrivate: false,
|
||||
engineeringStory: `
|
||||
Nutriveat represents a deep dive into the practical application of Large Language Models (LLMs) in a consumer-facing mobile environment. The goal was to move beyond a standard "chat wrapper" and create a deeply integrated tool that understands the nuance of dietary constraints, kitchen logistics, and user budgets.
|
||||
|
||||
#### Fine-Tuned AI & Structured Output
|
||||
A major engineering hurdle was ensuring the AI generated valid, consistent, and safe meal plans. I implemented a system of fine-tuned system prompts and strict schema validation within **Cloud Functions** to force GPT-4o to return structured data. This allowed the app to take raw AI output and instantly transform it into actionable Firestore documents, shopping list items, and high-fidelity image prompts for **Novita AI**.
|
||||
|
||||
#### Native Subscription Architecture
|
||||
To support the ongoing API costs of generative AI, I architected a robust multi-tier subscription model (Monthly/Annual). I implemented the monetization layer by integrating directly with the **Apple App Store (StoreKit)** and **Google Play Console (Billing Library)**. This involved architecting a custom server-side validation system in Cloud Functions to handle real-time subscription status, grace periods, and cross-platform entitlement logic without the use of third-party middleware.
|
||||
|
||||
#### Context-Aware Culinary Assistance
|
||||
I developed a specialized AI Chatbot designed to function as a "Kitchen Assistant." Unlike general-purpose bots, this assistant is provided with the specific context of the user's current meal plan, dietary allergies, and available utensils. By using **RAG-lite (Retrieval-Augmented Generation)** principles, the bot can provide accurate unit conversions and tailored cooking instructions that respect the user's specific kitchen setup.
|
||||
`,
|
||||
mermaidChart: `
|
||||
graph LR
|
||||
subgraph Client_Mobile [Flutter Frontend]
|
||||
A[Mobile App]:::traffic
|
||||
end
|
||||
|
||||
subgraph Firebase_Backend [Control Plane]
|
||||
Hub((Firebase SDK)):::hub
|
||||
C[Firestore DB]:::node
|
||||
D[Cloud Functions]:::node
|
||||
end
|
||||
|
||||
subgraph AI_Orchestration [Intelligence Layer]
|
||||
F[OpenAI / GPT-4o]:::node
|
||||
G[Novita AI / Stable Diffusion]:::node
|
||||
end
|
||||
|
||||
subgraph Store_Integrations [Native Billing]
|
||||
H[App Store / Play Store]:::traffic
|
||||
end
|
||||
|
||||
A <--> Hub
|
||||
Hub <--> C
|
||||
Hub --> D
|
||||
|
||||
D ==>|Fine-tuned Prompts| F
|
||||
D ==>|Image Generation| G
|
||||
F -.->|JSON Parsing| D
|
||||
|
||||
A <-->|Native IAP Flow| H
|
||||
H -.->|Server-to-Server Hooks| D
|
||||
|
||||
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
|
||||
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff
|
||||
`,
|
||||
"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",
|
||||
engineeringStory:
|
||||
"In this high-stakes medical environment, I implemented a custom audit logging system that ensured every state change was immutable...",
|
||||
storyLabel: "DATA EFFICIENCY",
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ services:
|
|||
- NODE_ENV=production
|
||||
networks:
|
||||
- web_traffic
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
networks:
|
||||
web_traffic:
|
||||
|
|
|
|||
100
lib/git.ts
|
|
@ -1,100 +0,0 @@
|
|||
import { FORGE_PROJECTS } from "@/data/forge";
|
||||
import { ChangelogEntry, ForgeProject } from "@/types";
|
||||
import "server-only";
|
||||
|
||||
export async function getAllForgeProjects(): Promise<ForgeProject[]> {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
return Promise.all(
|
||||
FORGE_PROJECTS.map(async (metadata) => {
|
||||
const changelog = await getPrivateChangelog(metadata.repoName);
|
||||
|
||||
// Determine if the latest entry happened in the last 30 days
|
||||
const latestDate =
|
||||
changelog.length > 0 ? new Date(changelog[0].date) : null;
|
||||
const isRecent = latestDate ? latestDate >= thirtyDaysAgo : false;
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
currentVersion:
|
||||
changelog.length > 0
|
||||
? `${changelog[0].version}_${metadata.status}`
|
||||
: `v0.0.0_${metadata.status}`,
|
||||
isRecent, // This is the new conditional flag
|
||||
changelog: changelog.slice(0, 3),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPrivateChangelog(
|
||||
repoName: string,
|
||||
): Promise<ChangelogEntry[]> {
|
||||
const repoOwner = "georgew";
|
||||
const filePath = "changelog.md";
|
||||
const url = `https://git.georgew.dev/api/v1/repos/${repoOwner}/${repoName}/contents/${filePath}`;
|
||||
const headers = {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.FORGEJO_TOKEN}`,
|
||||
Accept: "application/vnd.forgejo.raw",
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, headers);
|
||||
|
||||
if (!response.ok) {
|
||||
return [
|
||||
{
|
||||
version: "v0.0.0",
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
changes: ["Documentation currently synchronizing with the Forge..."],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 2. Forgejo returns the content as a base64 string
|
||||
// We need to decode it to UTF-8
|
||||
const markdownText = Buffer.from(data.content, "base64").toString("utf-8");
|
||||
|
||||
return parseMarkdown(markdownText);
|
||||
} catch (error) {
|
||||
console.error("Git Fetch Error:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Simple parser for your ## [Version] - YYYY-MM-DD format
|
||||
function parseMarkdown(text: string): ChangelogEntry[] {
|
||||
// Split by "## " at the start of a line to isolate version blocks
|
||||
const sections = text.split(/\n(?=## )/g);
|
||||
|
||||
return sections
|
||||
.map((section) => {
|
||||
const lines = section
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
// Match the pattern: ## [Version] - YYYY-MM-DD
|
||||
const headerMatch = lines[0].match(/## \[(.*?)\] - (.*)/);
|
||||
if (!headerMatch) return null;
|
||||
|
||||
const version = headerMatch[1];
|
||||
const date = headerMatch[2];
|
||||
|
||||
// Collect all lines starting with "- " anywhere in this version block
|
||||
const changes = lines
|
||||
.filter((line) => line.startsWith("- "))
|
||||
.map((line) => line.replace("- ", ""));
|
||||
|
||||
return { version, date, changes };
|
||||
})
|
||||
.filter((entry): entry is ChangelogEntry => entry !== null)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
|
@ -1,15 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
assetPrefix:
|
||||
process.env.NODE_ENV === "production"
|
||||
? "https://georgew.b-cdn.net"
|
||||
: undefined,
|
||||
images: {
|
||||
domains: ["georgew.b-cdn.net"],
|
||||
loader: "default",
|
||||
},
|
||||
output: 'standalone'
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"names": {
|
||||
"muninn": "44e345442475c960433feb762c9d3f70e4fdb71c2f873a3473358d40e2ae01c1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#0a0a0a"/>
|
||||
<text
|
||||
x="50%"
|
||||
y="52%"
|
||||
dominant-baseline="middle"
|
||||
text-anchor="middle"
|
||||
fill="#f97316"
|
||||
font-family="system-ui, sans-serif"
|
||||
font-weight="900"
|
||||
font-size="22"
|
||||
style="transform-origin: center; transform: scale(1, 1.2);"
|
||||
>
|
||||
<>
|
||||
</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 409 B |
|
Before Width: | Height: | Size: 205 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="38.5 0.5 135 211"><style>.st3{fill:none;stroke:#d40000;stroke-width:15}</style><g transform="translate(6 6)"><path d="M58 168V70c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#f60;stroke-width:25"/><path d="M58 168v-30c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#d40000;stroke-width:25"/><circle cx="142" cy="20" r="18" style="fill:none;stroke:#f60;stroke-width:15"/><circle cx="142" cy="88" r="18" class="st3"/><circle cx="58" cy="180" r="18" class="st3"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
|
@ -1,81 +0,0 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export interface CategoryCardProps {
|
||||
href: string;
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
tech: string[];
|
||||
hoverColor: string;
|
||||
activeTechColor: string;
|
||||
}
|
||||
|
||||
export interface PageLayoutProps {
|
||||
children: ReactNode;
|
||||
backLink?: string;
|
||||
backLabel?: string;
|
||||
maxWidth?: "5xl" | "6xl" | "7xl";
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
slug: string;
|
||||
category: "web" | "mobile" | "infrastructure";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
role: string;
|
||||
duration: string;
|
||||
stack: string[];
|
||||
metrics: string[];
|
||||
description: string;
|
||||
engineeringStory: string;
|
||||
storyLabel?: string;
|
||||
images: string[];
|
||||
liveUrl?: string;
|
||||
repoUrl?: string;
|
||||
mermaidChart?: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
export interface LabService {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
stack: string[];
|
||||
visibility: "public" | "tailscale";
|
||||
url?: string;
|
||||
gitUrl?: string;
|
||||
image: string;
|
||||
uptimeId?: number;
|
||||
}
|
||||
|
||||
export interface ForgeProject {
|
||||
id: string;
|
||||
status: "Alpha" | "Beta" | "R&D";
|
||||
engine: string;
|
||||
description: string;
|
||||
imagePath?: string;
|
||||
highlights: string[];
|
||||
externalLink?: string;
|
||||
projectName: string;
|
||||
isRecent: boolean;
|
||||
currentVersion: string;
|
||||
changelog: ChangelogEntry[];
|
||||
}
|
||||
|
||||
export interface ForgeProjectMetadata {
|
||||
id: string;
|
||||
projectName: string;
|
||||
repoName: string;
|
||||
status: "Alpha" | "Beta" | "R&D";
|
||||
engine: string;
|
||||
imagePath?: string;
|
||||
description: string;
|
||||
highlights: string[];
|
||||
externalLink?: string;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: string[];
|
||||
}
|
||||
18
types/project.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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;
|
||||
}
|
||||