Compare commits
37 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cec6e7758 | ||
|
|
a1c84ccc58 | ||
|
|
75a16af0c4 | ||
|
|
deb370d5af | ||
|
|
8508b679c0 | ||
|
|
fa7e1a5e09 | ||
|
|
7c40c82296 | ||
|
|
7ad84c3ddb | ||
|
|
1e94089b24 | ||
|
|
4771990d3c | ||
|
|
d9df8700b4 | ||
|
|
14173ff9ad | ||
|
|
f09b403282 | ||
|
|
cf19bed083 | ||
|
|
59fa92ef2f | ||
|
|
14a1ffb7b6 | ||
|
|
af8c720ba0 | ||
|
|
8369d59310 | ||
|
|
ab481053bf | ||
|
|
1e7a1c8a5f | ||
|
|
0d3b304d9a | ||
|
|
02c12b6e15 | ||
|
|
56afa86704 | ||
|
|
a09509581f | ||
|
|
bd038c4b0d | ||
|
|
1ff0d61a19 | ||
|
|
49e62d5e2f | ||
|
|
471b251fd7 | ||
|
|
5d0a86645d | ||
|
|
b0f5d62e3e | ||
|
|
f26f81d941 | ||
|
|
104e179cc0 | ||
|
|
03a4dc75ea | ||
|
|
a716bade81 | ||
|
|
769419cdc0 | ||
|
|
6dfd37b5d0 | ||
|
|
db5cba9347 |
0
.prettierrc
Normal file
8
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ steps:
|
||||||
privileged: true
|
privileged: true
|
||||||
settings:
|
settings:
|
||||||
build_args:
|
build_args:
|
||||||
- APP_VERSION=${CI_COMMIT_TAG}
|
APP_VERSION: ${CI_COMMIT_TAG}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: git.georgew.dev
|
registry: git.georgew.dev
|
||||||
repo: git.georgew.dev/georgew/${CI_REPO_NAME}
|
repo: git.georgew.dev/georgew/${CI_REPO_NAME}
|
||||||
|
|
@ -40,4 +40,4 @@ steps:
|
||||||
- mkdir -p /home/george/$APP_NAME
|
- mkdir -p /home/george/$APP_NAME
|
||||||
- cp docker-compose.yaml /home/george/$APP_NAME/
|
- cp docker-compose.yaml /home/george/$APP_NAME/
|
||||||
- docker compose -p $APP_NAME -f /home/george/$APP_NAME/docker-compose.yaml pull
|
- docker compose -p $APP_NAME -f /home/george/$APP_NAME/docker-compose.yaml pull
|
||||||
- docker compose -p $APP_NAME -f /home/george/$APP_NAME/docker-compose.yaml up -d --force-recreate --remove-orphans
|
- docker compose -p $APP_NAME -f /home/george/$APP_NAME/docker-compose.yaml up -d --force-recreate --remove-orphans
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
ARG APP_VERSION
|
||||||
|
|
||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -7,9 +9,9 @@ RUN npm ci
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
ARG APP_VERSION
|
||||||
ARG APP_VERSION=v0.0.0
|
|
||||||
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
|
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
|
||||||
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
61
README.md
|
|
@ -1,36 +1,47 @@
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Portfolio
|
||||||
|
|
||||||
## Getting Started
|
A high-performance, containerized professional portfolio and R&D lab built with **Next.js 15**, **Tailwind CSS**, and **TypeScript**. This project serves as both a public showcase and a dynamic bridge to a private **Forgejo** instance for real-time development tracking.
|
||||||
|
|
||||||
First, run the development server:
|
## 🏗️ Architecture Summary
|
||||||
|
|
||||||
|
- **Infrastructure**: Hosted on a **Hetzner** cloud node.
|
||||||
|
- **Containerization**: Fully Dockerized for **linux/amd64** architectures.
|
||||||
|
- **CI/CD**: Automated build and deployment via **Woodpecker CI**.
|
||||||
|
- **CDN**: Assets and media served via **Bunny CDN** for global performance.
|
||||||
|
- **Data Layer**: Dynamic changelog fetching from private repositories via **Forgejo API**.
|
||||||
|
|
||||||
|
## 🛠️ Technical Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 15 (App Router).
|
||||||
|
- **Styling**: Tailwind CSS with a monospace "Technical Lab" aesthetic.
|
||||||
|
- **Icons**: Lucide React.
|
||||||
|
- **Deployment**: Docker Compose with `force-dynamic` runtime environment injection.
|
||||||
|
|
||||||
|
## 🚀 Deployment & Build
|
||||||
|
|
||||||
|
The project utilizes a specialized build process to ensure compatibility with the production Hetzner node environment:
|
||||||
|
|
||||||
|
### Manual Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
docker buildx build --platform linux/amd64 -t portfolio:latest .
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
### Automated Pipeline
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
Deployments are triggered via Git Releases. The Woodpecker pipeline executes the following steps:
|
||||||
|
-Build: Compiles the Next.js application for linux/amd64.
|
||||||
|
-Push: Uploads the image to a private container registry.
|
||||||
|
-Deploy: Re-creates the container on the Hetzner node, injecting the FORGEJO_TOKEN from a secure .env file at runtime.
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## 🧪 The_Forge Integration
|
||||||
|
|
||||||
## Learn More
|
"The Forge" is a dynamic project log system that provides a glimpse into active R&D cycles.
|
||||||
|
-Metadata: Configured via data/forge.ts for project-specific details.
|
||||||
|
-Real-time Logs: Fetched server-side from private changelog.md files.
|
||||||
|
-Status: Includes a conditional ACTIVE_STREAM pulse based on commit recency.
|
||||||
|
-Performance: Utilizes Next.js data caching with a 1-hour revalidation window.
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
---
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
Built for performance and technical transparency.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|
|
||||||
381
app/cv/page.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
import { Mail, Globe, MapPin, ExternalLink, Server, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
export default function CVPage() {
|
||||||
|
return (
|
||||||
|
<PageLayout backLink="/" maxWidth="5xl">
|
||||||
|
{/* Hide the print button itself during printing */}
|
||||||
|
<div className="flex justify-end mb-4 no-print">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-orange-600 text-white text-xs font-mono tracking-widest uppercase transition-colors rounded-sm"
|
||||||
|
>
|
||||||
|
<Zap size={14} /> Print PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Container*/}
|
||||||
|
<div className="bg-white text-slate-900 p-8 md:p-16 rounded-sm shadow-2xl print:shadow-none print:p-12 print:m-0 font-sans print:text-[12pt] leading-normal">
|
||||||
|
<header className="border-b-4 border-slate-900 pb-8 print:pb-4 mb-8 print:mb-6 flex flex-col md:flex-row print:grid print:grid-cols-[1.5fr_1fr] justify-between items-start gap-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 sm:gap-6 mb-4 print:mb-2 print:flex-nowrap">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<img
|
||||||
|
src="/profile.jpg"
|
||||||
|
alt="George A. Webberley"
|
||||||
|
className="w-20 h-20 md:w-24 md:h-24 print:w-16 print:h-16 rounded-full object-cover border-2 border-slate-100 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="text-3xl md:text-5xl print:text-3xl font-extrabold tracking-tighter mb-1 text-slate-900 lg:whitespace-nowrap print:whitespace-nowrap">
|
||||||
|
George A. Webberley
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-2xl print:text-[14pt] text-orange-600 font-bold uppercase tracking-tight lg:whitespace-nowrap print:whitespace-nowrap">
|
||||||
|
Full Stack Engineer // Systems Architect
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="space-y-1 text-sm print:text-[9px] text-slate-500 font-mono text-right print:w-full print:flex print:flex-col print:items-end">
|
||||||
|
<div className="flex items-center md:justify-end gap-2">
|
||||||
|
<MapPin size={14} /> Copenhagen, Denmark
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center md:justify-end gap-2">
|
||||||
|
<Mail size={14} /> george@georgew.dev
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center md:justify-end gap-2 text-orange-600 font-bold">
|
||||||
|
<Globe size={14} /> georgew.dev
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 print:grid-cols-12 gap-8 print:gap-10">
|
||||||
|
{/* Main Column */}
|
||||||
|
<div className="lg:col-span-8 print:col-span-8 space-y-10 print:space-y-6">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs uppercase tracking-[0.2em] font-black text-slate-400 mb-4 print:mb-2 border-b border-slate-100 pb-2">
|
||||||
|
Professional Summary
|
||||||
|
</h2>
|
||||||
|
<p className="text-md print:text-sm leading-relaxed text-slate-700">
|
||||||
|
After 5 years as a dental surgeon, a serendipitous broken leg
|
||||||
|
led me to discover that software engineering perfectly suits my
|
||||||
|
analytical mind. Now an MSc (Distinction) graduate and Senior
|
||||||
|
Engineer, my core expertise lies in the entire product
|
||||||
|
lifecycle. 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 |
33
app/forge/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import ForgeUI from "@/components/ForgeUI";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
import { getAllForgeProjects } from "@/lib/git";
|
||||||
|
import { Hammer } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function ForgePage() {
|
||||||
|
const projects = await getAllForgeProjects();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout backLink="/" maxWidth="5xl">
|
||||||
|
<header className="mb-20">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<Hammer className="text-orange-500 animate-pulse" size={24} />
|
||||||
|
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase font-mono">
|
||||||
|
The Forge
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-500 max-w-2xl text-sm font-mono leading-relaxed">
|
||||||
|
The Forge is my active development logs, providing a glimpse into my
|
||||||
|
current projects and future potential releases.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-24">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ForgeUI key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
|
|
@ -24,3 +25,68 @@ body {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 0 !important; /* Use 0 here, and handle padding in the React component */
|
||||||
|
-webkit-margin-before: 0;
|
||||||
|
-webkit-margin-after: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force the body to be the exact width of A4 to prevent scaling */
|
||||||
|
body {
|
||||||
|
width: 210mm;
|
||||||
|
height: 297mm;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: white !important;
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide everything outside the CV content */
|
||||||
|
#safari-extension-is-installed,
|
||||||
|
main a[href="/"],
|
||||||
|
main a[href*="back"],
|
||||||
|
footer,
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure the PageLayout wrapper doesn't add width or centering */
|
||||||
|
main {
|
||||||
|
display: block !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Ensure the inner container doesn't push content down */
|
||||||
|
main > div {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. Fix the "page break" issue after the title */
|
||||||
|
h1,
|
||||||
|
header {
|
||||||
|
break-after: avoid;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
139
app/infrastructure/page.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ShieldCheck, Zap, Cpu, Terminal } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
|
||||||
|
const CAPABILITIES = [
|
||||||
|
{
|
||||||
|
title: "Cloud Orchestration",
|
||||||
|
icon: <Cpu size={16} />,
|
||||||
|
skills: ["Kubernetes", "Docker", "Cloud Run", "Multi-Region VPC"],
|
||||||
|
description:
|
||||||
|
"Managing containerized application lifecycles and software-defined networks to ensure high availability and regional data sovereignty.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Provisioning & IaC",
|
||||||
|
icon: <Terminal size={16} />,
|
||||||
|
skills: ["Terraform", "Makefiles", "Shell Scripting", "Versioned State"],
|
||||||
|
description:
|
||||||
|
"Defining environment state through version-controlled configurations to ensure reproducible, predictable, and audit-ready cloud resources.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Deployment Pipelines",
|
||||||
|
icon: <Zap size={16} />,
|
||||||
|
skills: [
|
||||||
|
"GitHub Actions",
|
||||||
|
"Woodpecker CI",
|
||||||
|
"Fastlane",
|
||||||
|
"Automated Testing",
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Architecting CI/CD workflows that bridge the gap between local development and production environments with zero-downtime strategies.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Governance & Security",
|
||||||
|
icon: <ShieldCheck size={16} />,
|
||||||
|
skills: ["NHS DSPT", "Cyber Essentials", "DPIA", "Secret Management"],
|
||||||
|
description:
|
||||||
|
"Hardening infrastructure and delivery pipelines to maintain alignment with UK health data standards and government security frameworks.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InfrastructurePage() {
|
||||||
|
return (
|
||||||
|
<PageLayout backLink="/" maxWidth="6xl">
|
||||||
|
{/* Header Section */}
|
||||||
|
<header className="mb-16 font-mono">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||||
|
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase">
|
||||||
|
Infrastructure and Operations
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-500 max-w-xl leading-relaxed text-sm">
|
||||||
|
Technical manifest of experiences in cloud orchestration, deployment
|
||||||
|
pipelines, and security frameworks designed for resilient services in
|
||||||
|
regulated environments.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Specification List */}
|
||||||
|
<div className="space-y-6 mb-32 font-mono">
|
||||||
|
{CAPABILITIES.map((cap, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={cap.title}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: i * 0.1, duration: 0.4 }}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-12 gap-6 border-t border-neutral-800/60 pt-10"
|
||||||
|
>
|
||||||
|
<div className="md:col-span-4">
|
||||||
|
<div className="flex items-center gap-3 text-blue-500/90">
|
||||||
|
{cap.icon}
|
||||||
|
<h3 className="text-[10px] font-bold tracking-[0.25em] uppercase">
|
||||||
|
{cap.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-8">
|
||||||
|
<p className="text-neutral-400 text-sm leading-relaxed mb-6">
|
||||||
|
{cap.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
{cap.skills.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="text-[9px] font-mono text-neutral-600 uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
{`// ${skill}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured Case Study Section */}
|
||||||
|
<section className="bg-neutral-900/40 border border-neutral-800 p-8 md:p-12 rounded-3xl font-mono">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-10">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-[10px] font-mono text-blue-500 uppercase tracking-[0.3em] mb-4 font-bold">
|
||||||
|
Selected Case Study
|
||||||
|
</h2>
|
||||||
|
<h3 className="text-xl font-bold text-white mb-3 tracking-tight">
|
||||||
|
Medical-Grade Architecture Analysis
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-500 leading-relaxed text-sm max-w-2xl">
|
||||||
|
An examination of a multi-cloud environment built to satisfy
|
||||||
|
<span className="text-neutral-300"> UK Cyber Essentials</span> and
|
||||||
|
<span className="text-neutral-300"> NHS DSPT</span> standards,
|
||||||
|
focusing on data residency and infrastructure-level security.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Link href="/projects/infrastructure/ayla">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{
|
||||||
|
scale: 1.02,
|
||||||
|
backgroundColor: "#3b82f6",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className="px-8 py-4 rounded-xl bg-white text-black font-bold text-[11px] uppercase tracking-[0.2em] flex items-center gap-3 transition-all duration-300"
|
||||||
|
>
|
||||||
|
View case
|
||||||
|
<Zap size={14} />
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
app/lab/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"use client";
|
||||||
|
import { LAB_SERVICES } from "@/data/lab";
|
||||||
|
import { Lock, Box, Globe } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
|
||||||
|
export default function LabPage() {
|
||||||
|
return (
|
||||||
|
<PageLayout backLink="/" maxWidth="6xl">
|
||||||
|
<header className="mb-32">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Box className="text-blue-500" size={20} />
|
||||||
|
<h1 className="text-4xl font-bold tracking-tighter text-white uppercase">
|
||||||
|
System Lab
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-500 max-w-2xl leading-relaxed text-sm">
|
||||||
|
A registry of self hosted operational services and experimental R&D.
|
||||||
|
Services labeled
|
||||||
|
<span className="text-blue-500"> [VPN]</span> are secured via
|
||||||
|
Tailscale to maintain a hardened perimeter for sensitive telemetry.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-40 mb-20">
|
||||||
|
{" "}
|
||||||
|
{/* Increased spacing for alternating rhythm */}
|
||||||
|
{LAB_SERVICES.map((service, i) => {
|
||||||
|
const isEven = i % 2 === 0;
|
||||||
|
return (
|
||||||
|
<motion.section
|
||||||
|
key={service.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
|
className={`flex flex-col ${isEven ? "md:flex-row" : "md:flex-row-reverse"} gap-12 md:gap-24 items-center group`}
|
||||||
|
>
|
||||||
|
{/* Image Side */}
|
||||||
|
<div className="w-full md:w-1/2">
|
||||||
|
<div className="relative group aspect-[1.9/1] rounded-2xl overflow-hidden border border-neutral-800 bg-black shadow-2xl transition-colors hover:border-blue-500/50">
|
||||||
|
<Image
|
||||||
|
src={service.image}
|
||||||
|
alt={service.name}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-all duration-500 ease-out brightness-100 grayscale-[0.2] group-hover:grayscale-0 scale-100 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Side */}
|
||||||
|
<div className="w-full md:w-1/2 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center flex-wrap gap-3">
|
||||||
|
<h3 className="text-2xl font-bold text-white tracking-tight">
|
||||||
|
{service.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Live Status Badge */}
|
||||||
|
{service.uptimeId && (
|
||||||
|
<div className="flex items-center shrink-0">
|
||||||
|
<Image
|
||||||
|
src={`https://status.georgew.dev/api/badge/${service.uptimeId}/status`}
|
||||||
|
alt="Online"
|
||||||
|
width={90}
|
||||||
|
height={20}
|
||||||
|
className="
|
||||||
|
h-4 w-auto
|
||||||
|
transition-all duration-500 ease-in-out
|
||||||
|
grayscale opacity-60
|
||||||
|
group-hover:grayscale-0 group-hover:opacity-100
|
||||||
|
"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{service.stack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="text-[10px] text-blue-500/70 uppercase tracking-widest font-bold"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-neutral-400 text-sm leading-relaxed max-w-md">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Conditional Actions */}
|
||||||
|
<div className="flex items-center gap-6 pt-4 border-t border-neutral-800/50">
|
||||||
|
{service.visibility === "public" && service.url ? (
|
||||||
|
<a
|
||||||
|
href={service.url}
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-2 text-[10px] text-white hover:text-blue-400 transition-colors uppercase font-bold tracking-widest"
|
||||||
|
>
|
||||||
|
<Globe size={14} /> Visit Service
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-neutral-600 uppercase font-bold tracking-widest cursor-default">
|
||||||
|
<Lock size={12} className="text-blue-500/50" /> VPN
|
||||||
|
Encrypted
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{service.gitUrl && (
|
||||||
|
<a
|
||||||
|
href={service.gitUrl}
|
||||||
|
target="_blank"
|
||||||
|
className="group/git flex items-center gap-2 text-[10px] text-neutral-500 hover:text-white transition-colors uppercase font-bold tracking-widest bg-neutral-900/50 px-3 py-1.5 rounded-lg border border-neutral-800 hover:border-neutral-700"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/forgejo.svg"
|
||||||
|
alt="Forgejo"
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
className="opacity-50 group-hover/git:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
{`Source`}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,11 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "George W.",
|
||||||
description: "Generated by create next app",
|
description: "Portfolio site",
|
||||||
|
icons: {
|
||||||
|
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
|
||||||
391
app/page.tsx
|
|
@ -1,151 +1,264 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Server, Globe, Smartphone, Gamepad2, Activity } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { motion, Variants } from "framer-motion";
|
||||||
|
import { Globe, Smartphone, Server, Hammer } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import MonitorCard from "@/components/MonitorCard";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
import { CategoryCardProps } from "@/types/index";
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.6, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [isHoveringMonitors, setIsHoveringMonitors] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24">
|
<PageLayout maxWidth="7xl">
|
||||||
{/* Header section */}
|
<motion.div
|
||||||
<header className="mb-12">
|
variants={containerVariants}
|
||||||
<h1 className="text-4xl font-bold tracking-tight">George W.</h1>
|
initial="hidden"
|
||||||
<p className="text-neutral-400 mt-2">Senior Full Stack Engineer & Tech Lead</p>
|
animate="visible"
|
||||||
</header>
|
className="flex flex-col gap-12"
|
||||||
|
>
|
||||||
{/* Bento Grid */}
|
{/* Header Section */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 auto-rows-[180px]">
|
<motion.header variants={itemVariants}>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">George W.</h1>
|
||||||
{/* About Me - Large Card */}
|
<p className="text-neutral-400 mt-2">
|
||||||
<motion.div
|
Senior Full Stack Engineer & Tech Lead
|
||||||
whileHover={{ y: -5 }}
|
</p>
|
||||||
className="md:col-span-2 md:row-span-2 p-8 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-between"
|
<div className="flex gap-6 text-[10px] font-mono tracking-[0.2em] uppercase mt-4">
|
||||||
>
|
<a
|
||||||
<div>
|
href="https://git.georgew.dev/georgew"
|
||||||
<h2 className="text-2xl font-semibold mb-4">The Architect</h2>
|
className="text-neutral-500 hover:text-white transition-colors"
|
||||||
<p className="text-neutral-400 leading-relaxed">
|
>
|
||||||
Engineering high-scale web systems and mobile experiences.
|
Git
|
||||||
Passionate about self-hosting, clean architecture, and performance.
|
</a>
|
||||||
</p>
|
<a
|
||||||
|
href="https://www.linkedin.com/in/george-webberley/"
|
||||||
|
className="text-neutral-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/cv"
|
||||||
|
className="text-neutral-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
CV
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-xs font-mono text-neutral-500">
|
</motion.header>
|
||||||
<span>#NextJS</span> <span>#Flutter</span> <span>#Docker</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Live Pulse Card */}
|
{/* Main Bento Grid */}
|
||||||
<motion.div
|
<div className="grid grid-cols-1 md:grid-cols-6 gap-6">
|
||||||
whileHover={{ y: -5 }}
|
{/* Top Row Left: Technical Focus */}
|
||||||
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]"
|
<motion.div
|
||||||
>
|
variants={itemVariants}
|
||||||
{/* The Monitor Map (Easily editable) */}
|
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"
|
||||||
{(() => {
|
>
|
||||||
const monitors = [
|
<div className="flex-[1.5] flex flex-col justify-between relative z-10">
|
||||||
{ id: 2, name: "Datasaur" },
|
<div>
|
||||||
{ id: 6, name: "Audiobookshelf" },
|
<h2 className="text-3xl font-bold mb-4 tracking-tight">
|
||||||
{ id: 7, name: "Woodpecker CI" },
|
Technical Focus
|
||||||
{ id: 8, name: "Forgejo Git" },
|
</h2>
|
||||||
{ id: 9, name: "Server dashboard" },
|
<p className="text-base text-neutral-400 leading-relaxed max-w-lg">
|
||||||
{ id: 10, name: "Ratoong" },
|
Bridging the gap between rigid regulatory requirements and
|
||||||
];
|
fluid user experiences. I specialize in designing{" "}
|
||||||
|
<span className="text-white">distributed systems</span> and
|
||||||
|
<span className="text-white">
|
||||||
|
{" "}
|
||||||
|
cross-platform mobile apps
|
||||||
|
</span>{" "}
|
||||||
|
with a focus on automated delivery and high-integrity code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="flex flex-wrap gap-2 mt-8">
|
||||||
<>
|
{[
|
||||||
{/* Default View */}
|
"#Architecture",
|
||||||
<div className="flex items-center justify-between w-full group-hover:opacity-0 group-hover:pointer-events-none transition-opacity duration-300">
|
"#Regulatory Compliance",
|
||||||
<div>
|
"#Agile Leadership",
|
||||||
<div className="flex items-center gap-2 mb-1">
|
"#DevOps",
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
].map((tag) => (
|
||||||
<span className="font-medium text-white">Hetzner Node-01</span>
|
<span
|
||||||
</div>
|
key={tag}
|
||||||
<div className="flex gap-2 items-center">
|
className="text-[10px] font-mono text-neutral-500 border border-neutral-800 px-2 py-1 rounded"
|
||||||
<p className="text-sm text-neutral-500">System Status:</p>
|
>
|
||||||
<img
|
{tag}
|
||||||
src="https://status.georgew.dev/api/status-page/dashboard/badge"
|
</span>
|
||||||
alt="Overall Status"
|
))}
|
||||||
className="h-5"
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div className="hidden md:block w-px bg-neutral-800/50 self-stretch" />
|
||||||
<Activity className="text-neutral-700 w-8 h-8" />
|
|
||||||
|
<div className="flex-1 flex flex-col justify-around py-2 relative z-10">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TechnicalFocus
|
||||||
|
label="Leadership"
|
||||||
|
color="text-blue-500"
|
||||||
|
text="Tech Lead & Scrum Master. Orchestrating sprint cycles and system design."
|
||||||
|
/>
|
||||||
|
<TechnicalFocus
|
||||||
|
label="Integrity"
|
||||||
|
color="text-purple-500"
|
||||||
|
text="Medical/Regulatory environments, QMS, and Cyber Essentials."
|
||||||
|
/>
|
||||||
|
<TechnicalFocus
|
||||||
|
label="Infrastructure"
|
||||||
|
color="text-green-500"
|
||||||
|
text="Kubernetes, GCP, and automated CI/CD pipelines."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Top Row Right: The Service Registry */}
|
||||||
|
<Link href="/lab" className="md:col-span-2 flex flex-col group">
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
onMouseEnter={() => setIsHoveringMonitors(true)}
|
||||||
|
onMouseLeave={() => setIsHoveringMonitors(false)}
|
||||||
|
className="flex-1 p-6 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-center relative overflow-hidden min-h-[180px] hover:border-blue-500/30"
|
||||||
|
>
|
||||||
|
<MonitorCard isHovered={isHoveringMonitors} />
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Project Category Cards */}
|
||||||
|
<CategoryCard
|
||||||
|
href="/projects/web"
|
||||||
|
icon={<Globe className="text-blue-400 w-6 h-6 mb-4" />}
|
||||||
|
title="Web Systems"
|
||||||
|
description="Architecting distributed platforms with a focus on high-availability."
|
||||||
|
tech={["Next.js", "Python", "Node.js", "Caddy", "PostgreSQL"]}
|
||||||
|
hoverColor="hover:border-blue-500/30"
|
||||||
|
activeTechColor="group-hover:text-blue-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CategoryCard
|
||||||
|
href="/projects/mobile"
|
||||||
|
icon={<Smartphone className="text-purple-400 w-6 h-6 mb-4" />}
|
||||||
|
title="Mobile Apps"
|
||||||
|
description="Building fluid, cross-platform experiences using reactive state."
|
||||||
|
tech={["Android", "iOS", "Flutter", "Riverpod", "Stores"]}
|
||||||
|
hoverColor="hover:border-purple-500/30"
|
||||||
|
activeTechColor="group-hover:text-purple-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CategoryCard
|
||||||
|
href="/infrastructure"
|
||||||
|
icon={<Server className="text-green-400 w-6 h-6 mb-4" />}
|
||||||
|
title="Infrastructure"
|
||||||
|
description="Resilient cloud environments with automated IaC and multi-region orchestration."
|
||||||
|
tech={["Kubernetes", "Terraform", "GCP", "CI/CD", "Security"]}
|
||||||
|
hoverColor="hover:border-green-500/30"
|
||||||
|
activeTechColor="group-hover:text-green-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom Row: The Forge */}
|
||||||
|
<Link href="/forge" className="md:col-span-6">
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex items-center gap-6 group hover:border-orange-500/30 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="p-4 bg-orange-500/10 rounded-2xl border border-orange-500/20 group-hover:border-orange-500/40 transition-colors">
|
||||||
|
<Hammer className="text-orange-500 w-8 h-8 group-hover:rotate-12 transition-transform" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<h3 className="font-bold text-xl tracking-tight">
|
||||||
|
The Forge
|
||||||
|
</h3>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 leading-tight">
|
||||||
{/* Hover View: Friendly Names */}
|
A space where I demonstrate what I am currently working on and
|
||||||
<div className="absolute inset-0 p-6 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-center bg-neutral-900/95 backdrop-blur-sm">
|
any future projects.
|
||||||
<h4 className="text-[10px] font-mono text-neutral-500 mb-3 uppercase tracking-[0.2em]">Service Registry</h4>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
</div>
|
||||||
{monitors.map((m) => (
|
</motion.div>
|
||||||
<div key={m.id} className="flex items-center justify-between bg-neutral-800/40 p-2 rounded-lg border border-neutral-700/30">
|
</Link>
|
||||||
<span className="text-[11px] font-medium text-neutral-300 truncate mr-2">{m.name}</span>
|
|
||||||
<div className="flex gap-1 shrink-0">
|
|
||||||
<img src={`https://status.georgew.dev/api/badge/${m.id}/status`} className="h-3" alt="up" />
|
|
||||||
<img src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`} className="h-3 opacity-60" alt="ms" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Project One */}
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
className="md:col-span-1 p-6 rounded-3xl bg-[#1a1a1a] border border-neutral-800 flex flex-col justify-end group cursor-pointer"
|
|
||||||
>
|
|
||||||
<Globe className="mb-auto text-blue-400" />
|
|
||||||
<h3 className="font-semibold mt-4">Prod Website</h3>
|
|
||||||
<p className="text-xs text-neutral-500">Case Study 01</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Project Two */}
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
className="md:col-span-1 p-6 rounded-3xl bg-[#1a1a1a] border border-neutral-800 flex flex-col justify-end cursor-pointer"
|
|
||||||
>
|
|
||||||
<Smartphone className="mb-auto text-purple-400" />
|
|
||||||
<h3 className="font-semibold mt-4">Mobile App</h3>
|
|
||||||
<p className="text-xs text-neutral-500">Active Dev</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Game Teaser / The Lab */}
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
className="md:col-span-2 p-6 rounded-3xl bg-gradient-to-br from-[#111] to-[#1a1a1a] border border-neutral-800 flex items-center gap-6"
|
|
||||||
>
|
|
||||||
<div className="p-4 rounded-2xl bg-neutral-800">
|
|
||||||
<Gamepad2 className="w-8 h-8 text-orange-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold italic">The Forge</h3>
|
|
||||||
<p className="text-sm text-neutral-500">Indie Game Dev & Prototypes</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deployment Footer */}
|
|
||||||
<footer className="mt-12 pt-8 border-t border-neutral-900 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] text-neutral-600 font-mono tracking-wider uppercase">
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p>Pipeline Status</p>
|
|
||||||
<img
|
|
||||||
src="https://ci.georgew.dev/api/badges/11/status.svg"
|
|
||||||
alt="Build Status"
|
|
||||||
className="h-3 grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" />
|
|
||||||
<p>Engine: Next.js 15 (Standalone)</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
<div className="flex items-center gap-4 bg-neutral-900/50 px-3 py-1 rounded-full border border-neutral-800/50">
|
</PageLayout>
|
||||||
<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'}
|
|
||||||
|
function TechnicalFocus({
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h4
|
||||||
|
className={`text-[12px] font-mono ${color} uppercase tracking-[0.2em] mb-2`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-neutral-300 leading-tight">{text}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryCard({
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tech,
|
||||||
|
hoverColor,
|
||||||
|
activeTechColor,
|
||||||
|
}: CategoryCardProps) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="group md:col-span-2">
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
className={`p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors ${hoverColor}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{icon}
|
||||||
|
<h3 className="font-bold text-xl mb-2">{title}</h3>
|
||||||
|
<p className="text-sm text-neutral-500 leading-relaxed">
|
||||||
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
<div className="flex flex-wrap gap-2 mt-6">
|
||||||
</main>
|
{tech.map((t: string) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className={`text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase ${activeTechColor} group-hover:border-current/20 transition-all`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
app/projects/[category]/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import { motion, Variants } from "framer-motion";
|
||||||
|
import { ExternalLink, Github, ShieldCheck, Cpu, Users } from "lucide-react";
|
||||||
|
import { PROJECT_REGISTRY } from "@/data/projects";
|
||||||
|
import Mermaid from "@/components/Mermaid";
|
||||||
|
import ProjectShowcase from "@/components/ProjectShowcase";
|
||||||
|
import ImageCarousel from "@/components/ImageCarousel";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import MobileStack from "@/components/MobileStack";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
|
||||||
|
// 1. Professional Animation Variants
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.15, // Smoothly delay each section
|
||||||
|
delayChildren: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.8, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectDetail({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ category: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { category, slug } = use(params);
|
||||||
|
const project = PROJECT_REGISTRY.find((p) => p.slug === slug);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<PageLayout backLink={`/projects/${category}`}>
|
||||||
|
<div className="font-mono text-white">Project Log Not Found.</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
backLink={
|
||||||
|
category == "infrastructure"
|
||||||
|
? "/infrastructure"
|
||||||
|
: `/projects/${category}`
|
||||||
|
}
|
||||||
|
backLabel={`Back to ${category}`}
|
||||||
|
maxWidth="6xl"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
{/* Header Section */}
|
||||||
|
<motion.header
|
||||||
|
variants={sectionVariants}
|
||||||
|
className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h1 className="text-6xl font-bold tracking-tighter mb-4">
|
||||||
|
{project.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-blue-500 font-mono text-sm uppercase tracking-widest mb-6">
|
||||||
|
{project.subtitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-neutral-400 text-lg leading-relaxed">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mt-8">
|
||||||
|
{project.liveUrl && (
|
||||||
|
<a
|
||||||
|
href={project.liveUrl}
|
||||||
|
className="flex items-center gap-2 bg-white text-black px-6 py-3 rounded-full font-bold text-sm hover:scale-105 transition-transform"
|
||||||
|
>
|
||||||
|
Launch Site <ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{project?.repoUrl && (
|
||||||
|
<a
|
||||||
|
href={project.repoUrl}
|
||||||
|
className="flex items-center gap-2 border border-neutral-800 px-6 py-3 rounded-full font-bold text-sm hover:bg-neutral-900 transition-colors"
|
||||||
|
>
|
||||||
|
View Source <Github size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Sidebar */}
|
||||||
|
<div className="bg-neutral-900/30 border border-neutral-800 p-8 rounded-3xl h-fit backdrop-blur-sm">
|
||||||
|
<div className="space-y-8 font-mono">
|
||||||
|
<StatItem
|
||||||
|
icon={<ShieldCheck className="text-blue-500" />}
|
||||||
|
label="My Role"
|
||||||
|
value={project.role}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
icon={<Cpu className="text-purple-500" />}
|
||||||
|
label="Stack"
|
||||||
|
value={project.stack.join(", ")}
|
||||||
|
/>
|
||||||
|
<StatItem
|
||||||
|
icon={<Users className="text-green-500" />}
|
||||||
|
label="Impact"
|
||||||
|
value={project.metrics.join(" • ")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
|
||||||
|
{/* Media Showcase */}
|
||||||
|
<motion.section variants={sectionVariants} className="mb-20">
|
||||||
|
{project.category === "mobile" ? (
|
||||||
|
<MobileStack images={project.images} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<ProjectShowcase images={project.images} />
|
||||||
|
</div>
|
||||||
|
<div className="block lg:hidden">
|
||||||
|
<ImageCarousel images={project.images} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-6 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]">
|
||||||
|
Interactive Gallery — Select or swipe to explore
|
||||||
|
</p>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* System Architecture */}
|
||||||
|
{project.mermaidChart && (
|
||||||
|
<motion.section variants={sectionVariants} className="mb-16">
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<div className="h-px flex-1 bg-neutral-900" />
|
||||||
|
<h3 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.3em]">
|
||||||
|
System Architecture Log
|
||||||
|
</h3>
|
||||||
|
<div className="h-px flex-1 bg-neutral-900" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12">
|
||||||
|
<Mermaid chart={project.mermaidChart} />
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Engineering Narrative */}
|
||||||
|
<motion.section
|
||||||
|
variants={sectionVariants}
|
||||||
|
className="w-full pb-20 mt-12"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-12">
|
||||||
|
<h3 className="text-[10px] font-mono text-blue-500 uppercase tracking-[0.3em] font-bold">
|
||||||
|
PROJECT LOG // {project.storyLabel || "NARRATIVE"}
|
||||||
|
</h3>
|
||||||
|
<div className="h-px flex-1 bg-neutral-900" />
|
||||||
|
<h2 className="text-xl font-bold tracking-tighter">
|
||||||
|
The Engineering Story
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="prose prose-invert prose-neutral max-w-none">
|
||||||
|
<ReactMarkdown>{project.engineeringStory}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
</motion.div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small helper component to keep the JSX clean
|
||||||
|
function StatItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{icon}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-neutral-500 uppercase tracking-tighter">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
app/projects/[category]/page.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use } from "react";
|
||||||
|
import { motion, Variants } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Globe, Smartphone, Server } from "lucide-react";
|
||||||
|
import { PROJECT_REGISTRY } from "@/data/projects";
|
||||||
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
|
||||||
|
const CATEGORY_META = {
|
||||||
|
web: {
|
||||||
|
title: "Web Systems",
|
||||||
|
icon: <Globe className="w-8 h-8 text-blue-400" />,
|
||||||
|
description:
|
||||||
|
"Architecting scalable web applications and distributed systems.",
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
title: "Mobile Apps",
|
||||||
|
icon: <Smartphone className="w-8 h-8 text-purple-400" />,
|
||||||
|
description:
|
||||||
|
"Building cross-platform experiences with Flutter and native integrations.",
|
||||||
|
},
|
||||||
|
infrastructure: {
|
||||||
|
title: "DevOps & Infrastructure",
|
||||||
|
icon: <Server className="w-8 h-8 text-green-400" />,
|
||||||
|
description:
|
||||||
|
"Self-hosted systems architecture and automated deployment pipelines.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Cast the container variants
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Cast the item variants
|
||||||
|
const itemVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, x: -20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeOut", // TypeScript now knows this is a valid Easing string
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default function CategoryPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ category: string }>;
|
||||||
|
}) {
|
||||||
|
const resolvedParams = use(params);
|
||||||
|
const category = resolvedParams.category;
|
||||||
|
const meta = CATEGORY_META[category as keyof typeof CATEGORY_META];
|
||||||
|
const filteredProjects = PROJECT_REGISTRY.filter(
|
||||||
|
(p) => p.category === category,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!meta) return <PageLayout backLink="/">Sector not found.</PageLayout>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout backLink="/" maxWidth="6xl">
|
||||||
|
<div className="mb-16">
|
||||||
|
<h1 className="flex items-center gap-4 text-5xl font-bold tracking-tighter mb-6">
|
||||||
|
{meta.icon} {meta.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-neutral-400 max-w-2xl leading-relaxed">
|
||||||
|
{meta.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. The container manages the entrance of all children */}
|
||||||
|
<motion.div
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
className="grid grid-cols-1 gap-6"
|
||||||
|
>
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.slug}
|
||||||
|
href={`/projects/${category}/${project.slug}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
variants={itemVariants}
|
||||||
|
whileHover={{ x: 8 }}
|
||||||
|
className="group p-8 rounded-3xl bg-neutral-900/40 border border-neutral-800 hover:border-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold mb-2 group-hover:text-blue-400 transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-500 text-sm max-w-xl">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||||
|
{project.stack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="text-[9px] font-mono bg-neutral-800 text-neutral-400 px-3 py-1 rounded-full uppercase tracking-widest border border-neutral-800"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/Footer.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="mt-12 pt-8 border-t border-neutral-900 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] text-neutral-600 font-mono tracking-wider uppercase">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p>Pipeline Status</p>
|
||||||
|
<Image
|
||||||
|
src="https://ci.georgew.dev/api/badges/11/status.svg"
|
||||||
|
alt="CI Build Status"
|
||||||
|
width={64}
|
||||||
|
height={20}
|
||||||
|
unoptimized
|
||||||
|
className="h-3 w-auto grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" />
|
||||||
|
<p>Engine: Next.js 15 (Standalone)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 bg-neutral-900/50 px-3 py-1 rounded-full border border-neutral-800/50">
|
||||||
|
<div className="w-1 h-1 rounded-full bg-blue-500" />
|
||||||
|
<p className="text-neutral-400">
|
||||||
|
Deploy: {process.env.NEXT_PUBLIC_APP_VERSION || "v1.0.0-dev"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
components/ForgeUI copy.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { History, ExternalLink, Cpu } from "lucide-react";
|
||||||
|
import { ForgeProject } from "@/types";
|
||||||
|
|
||||||
|
export default function ForgeUI({ project }: { project: ForgeProject }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-neutral-900/40 border border-neutral-800 rounded-3xl p-8 pt-0 mb-12 overflow-hidden">
|
||||||
|
{/* Upper Section: Hero Area */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
<div className="lg:col-span-8 flex flex-col justify-center">
|
||||||
|
<div className="flex flex-wrap justify-between items-start gap-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-bold text-white mb-4 tracking-tighter">
|
||||||
|
{project.projectName}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Version Badge */}
|
||||||
|
<span className="px-3 py-1 bg-orange-500/10 border border-orange-500/20 text-orange-500 text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||||
|
{project.currentVersion}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Engine Tag */}
|
||||||
|
<span className="px-3 py-1 bg-neutral-800 text-neutral-400 text-[10px] font-bold uppercase tracking-widest rounded-full flex items-center gap-2">
|
||||||
|
<Cpu size={12} /> {project.engine}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status Indicator (Now part of the tag row) */}
|
||||||
|
<div className="flex items-center gap-2 text-[9px] font-mono text-green-500/60 ml-1">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"></span>
|
||||||
|
</span>
|
||||||
|
ACTIVE STREAM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.externalLink && (
|
||||||
|
<a
|
||||||
|
href={project.externalLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-xs font-bold text-white bg-neutral-800 hover:bg-neutral-700 transition-colors px-4 py-2 rounded-xl shrink-0"
|
||||||
|
>
|
||||||
|
Project Details <ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-neutral-400 text-base leading-relaxed max-w-2xl">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.imagePath && (
|
||||||
|
<div className="lg:col-span-4 flex justify-center lg:justify-end items-center">
|
||||||
|
<div className="relative w-full aspect-square max-w-[280px] group">
|
||||||
|
<Image
|
||||||
|
src={project.imagePath}
|
||||||
|
alt={`${project.projectName} Feature`}
|
||||||
|
fill
|
||||||
|
className="object-contain drop-shadow-[0_0_40px_rgba(249,115,22,0.15)] transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-orange-500/10 blur-[80px] rounded-full -z-10 opacity-40 group-hover:opacity-60 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical Highlights */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-12">
|
||||||
|
{project.highlights.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="text-[10px] text-neutral-600 font-mono uppercase tracking-tighter border border-neutral-800 px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
{`// ${tag}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lower Section: Logs with Gradient Fade */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500 uppercase tracking-widest border-b border-neutral-800 pb-2">
|
||||||
|
<History size={14} /> Dev_Log
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* This container handles the fade effect */}
|
||||||
|
<div
|
||||||
|
className="space-y-8 relative"
|
||||||
|
style={{
|
||||||
|
maskImage:
|
||||||
|
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.changelog.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.version}
|
||||||
|
className="relative pl-8 border-l border-neutral-800"
|
||||||
|
>
|
||||||
|
<div className="absolute -left-1.5 top-1.5 w-3 h-3 bg-neutral-950 border border-neutral-700 rounded-full" />
|
||||||
|
<div className="flex items-baseline gap-4 mb-3">
|
||||||
|
<span className="text-orange-500 font-mono text-sm font-bold leading-none">
|
||||||
|
{entry.version}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-600 font-mono text-xs leading-none">
|
||||||
|
{entry.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{entry.changes.map((change, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="text-neutral-400 text-sm leading-relaxed max-w-4xl"
|
||||||
|
>
|
||||||
|
{change}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/ForgeUI.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { History, ExternalLink, Cpu } from "lucide-react";
|
||||||
|
import { ForgeProject } from "@/types";
|
||||||
|
|
||||||
|
export default function ForgeUI({ project }: { project: ForgeProject }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-neutral-900/40 border border-neutral-800 rounded-3xl p-8 pt-0 mb-12 overflow-hidden">
|
||||||
|
{/* Upper Section: Hero Area */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 pt-4">
|
||||||
|
{" "}
|
||||||
|
{/* Added pt-8 here to replace the p-8 pt-0 conflict */}
|
||||||
|
<div className="lg:col-span-8 flex flex-col justify-center">
|
||||||
|
<div className="flex flex-wrap justify-between items-start gap-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-bold text-white mb-4 tracking-tighter">
|
||||||
|
{project.projectName}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="px-3 py-1 bg-orange-500/10 border border-orange-500/20 text-orange-500 text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||||
|
{project.currentVersion}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="px-3 py-1 bg-neutral-800 text-neutral-400 text-[10px] font-bold uppercase tracking-widest rounded-full flex items-center gap-2">
|
||||||
|
<Cpu size={12} /> {project.engine}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{project.isRecent && (
|
||||||
|
<div className="flex items-center gap-2 text-[9px] font-mono text-green-500/60 ml-1">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"></span>
|
||||||
|
</span>
|
||||||
|
ACTIVE STREAM
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.externalLink && (
|
||||||
|
<a
|
||||||
|
href={project.externalLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-xs font-bold text-white bg-neutral-800 hover:bg-neutral-700 transition-colors px-4 py-2 rounded-xl shrink-0"
|
||||||
|
>
|
||||||
|
Project Details <ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-neutral-400 text-base leading-relaxed max-w-2xl">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{project.imagePath && (
|
||||||
|
<div className="lg:col-span-4 flex justify-center lg:justify-end items-center">
|
||||||
|
<div className="relative w-full aspect-square max-w-[280px] group">
|
||||||
|
<Image
|
||||||
|
src={project.imagePath}
|
||||||
|
alt={`${project.projectName} Feature`}
|
||||||
|
fill
|
||||||
|
className="object-contain drop-shadow-[0_0_40px_rgba(249,115,22,0.15)] transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-orange-500/10 blur-[80px] rounded-full -z-10 opacity-40 group-hover:opacity-60 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technical Highlights */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-12 mt-6">
|
||||||
|
{project.highlights.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="text-[10px] text-neutral-600 font-mono uppercase tracking-tighter border border-neutral-800 px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
{`// ${tag}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lower Section: Logs */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-neutral-500 uppercase tracking-widest border-b border-neutral-800 pb-2">
|
||||||
|
<History size={14} /> Dev_Log
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="space-y-10 relative pb-4" // Increased space between version blocks
|
||||||
|
style={{
|
||||||
|
maskImage:
|
||||||
|
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"linear-gradient(to bottom, black 60%, transparent 100%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.changelog.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.version}
|
||||||
|
className="ml-1.5 relative pl-10 border-l border-neutral-800" // Increased pl-8 to pl-10 to fix clipping
|
||||||
|
>
|
||||||
|
{/* The Version Indicator Circle */}
|
||||||
|
<div className="absolute -left-1.5 top-1 w-3 h-3 bg-neutral-950 border border-neutral-700 rounded-full" />
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-4 mb-4">
|
||||||
|
<span className="text-orange-500 font-mono text-sm font-bold leading-none">
|
||||||
|
{entry.version}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-600 font-mono text-[10px] uppercase tracking-wider leading-none">
|
||||||
|
{entry.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Individual Changes with Bullet Points */}
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{entry.changes.map((change, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className="text-neutral-400 text-sm leading-relaxed max-w-4xl flex items-start gap-3"
|
||||||
|
>
|
||||||
|
{/* The Bullet Character */}
|
||||||
|
<span className="text-orange-500/40 mt-1.5 shrink-0 text-[10px]">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span>{change}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/ImageCarousel.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface GalleryProps {
|
||||||
|
images: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageCarousel({ images }: GalleryProps) {
|
||||||
|
const [[page, direction], setPage] = useState([0, 0]);
|
||||||
|
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
|
||||||
|
|
||||||
|
const imageIndex = Math.abs(page % images.length);
|
||||||
|
|
||||||
|
const paginate = useCallback(
|
||||||
|
(newDirection: number) => {
|
||||||
|
setPage([page + newDirection, newDirection]);
|
||||||
|
},
|
||||||
|
[page],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAutoPlaying) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
paginate(1);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [paginate, isAutoPlaying]);
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
enter: (direction: number) => ({
|
||||||
|
x: direction > 0 ? 1000 : -1000,
|
||||||
|
opacity: 0,
|
||||||
|
}),
|
||||||
|
center: { zIndex: 1, x: 0, opacity: 1 },
|
||||||
|
exit: (direction: number) => ({
|
||||||
|
zIndex: 0,
|
||||||
|
x: direction < 0 ? 1000 : -1000,
|
||||||
|
opacity: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative aspect-video w-full overflow-hidden rounded-3xl border border-neutral-800 bg-neutral-900"
|
||||||
|
onMouseEnter={() => setIsAutoPlaying(false)}
|
||||||
|
onMouseLeave={() => setIsAutoPlaying(true)}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} custom={direction}>
|
||||||
|
<motion.img
|
||||||
|
key={page}
|
||||||
|
src={images[imageIndex]}
|
||||||
|
custom={direction}
|
||||||
|
variants={variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{
|
||||||
|
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||||
|
opacity: { duration: 0.2 },
|
||||||
|
}}
|
||||||
|
drag="x"
|
||||||
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
|
dragElastic={1}
|
||||||
|
onDragStart={() => setIsAutoPlaying(false)}
|
||||||
|
onDragEnd={(e, { offset }) => {
|
||||||
|
const swipe = Math.abs(offset.x) > 50;
|
||||||
|
if (swipe) paginate(offset.x > 0 ? -1 : 1);
|
||||||
|
}}
|
||||||
|
className="absolute h-full w-full object-cover cursor-grab active:cursor-grabbing"
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation Arrows */}
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-between p-4 pointer-events-none">
|
||||||
|
<button
|
||||||
|
className="p-2 rounded-full bg-black/50 backdrop-blur-md border border-white/10 text-white pointer-events-auto hover:bg-black/80 transition-all"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
paginate(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-2 rounded-full bg-black/50 backdrop-blur-md border border-white/10 text-white pointer-events-auto hover:bg-black/80 transition-all"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
paginate(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{isAutoPlaying && (
|
||||||
|
<motion.div
|
||||||
|
key={imageIndex}
|
||||||
|
initial={{ width: "0%" }}
|
||||||
|
animate={{ width: "100%" }}
|
||||||
|
transition={{ duration: 5, ease: "linear" }}
|
||||||
|
className="absolute bottom-0 left-0 h-1 bg-blue-500/50 z-20"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
components/Mermaid.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
components/MobileFrame.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export function MobileFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto border-neutral-800 bg-neutral-800 border-[8px] rounded-[2.5rem] h-[600px] w-[300px] shadow-2xl overflow-hidden">
|
||||||
|
<div className="absolute top-0 inset-x-0 h-6 bg-neutral-800 rounded-b-xl z-20 w-32 mx-auto" />
|
||||||
|
<div className="relative h-full w-full bg-black overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/MobileStack.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { MobileFrame } from "./MobileFrame";
|
||||||
|
|
||||||
|
export default function MobileStack({ images }: { images: string[] }) {
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = -150;
|
||||||
|
|
||||||
|
const getRelativeIndex = (index: number) => {
|
||||||
|
const len = images.length;
|
||||||
|
return (index - currentIndex + len) % len;
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[750px] w-full flex flex-col items-center py-20 overflow-hidden group">
|
||||||
|
<div className="relative h-[650px] w-full flex justify-center items-center">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{images.map((img, index) => {
|
||||||
|
const relIndex = getRelativeIndex(index);
|
||||||
|
const isTop = relIndex === 0;
|
||||||
|
|
||||||
|
const xOffset = relIndex * 90;
|
||||||
|
|
||||||
|
if (relIndex > 5) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={img}
|
||||||
|
style={{ zIndex: images.length - relIndex }}
|
||||||
|
initial={{ opacity: 0, x: 400 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
x: isTop ? 0 : xOffset,
|
||||||
|
scale: isTop ? 1 : 0.96,
|
||||||
|
filter: isTop ? "brightness(1)" : "brightness(0.4)",
|
||||||
|
pointerEvents: isTop ? "auto" : "all",
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
x: -1000,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.4, ease: "easeIn" },
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
ease: [0.22, 1, 0.36, 1],
|
||||||
|
}}
|
||||||
|
whileHover={
|
||||||
|
!isTop ? { scale: 0.98, filter: "brightness(0.6)" } : {}
|
||||||
|
}
|
||||||
|
drag={isTop ? "x" : false}
|
||||||
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
|
dragElastic={0.8}
|
||||||
|
onDrag={(_, info) => {
|
||||||
|
if (isTop && info.offset.x < DRAG_THRESHOLD) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
// Backup check for quick flicks
|
||||||
|
if (isTop && info.offset.x < -100) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => !isTop && setCurrentIndex(index)}
|
||||||
|
className="absolute"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${isTop ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
<MobileFrame>
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt="App Screenshot"
|
||||||
|
draggable="false"
|
||||||
|
className="w-full h-full object-cover select-none"
|
||||||
|
/>
|
||||||
|
</MobileFrame>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation Button */}
|
||||||
|
<div className="absolute -bottom-12 z-[100]">
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
className="flex items-center gap-3 px-6 py-3 rounded-full bg-black/40 backdrop-blur-xl border border-white/5 text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 group-hover:translate-y-[-20px]"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-[0.2em]">
|
||||||
|
Next Screen
|
||||||
|
</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
components/MonitorCard.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Activity } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const MONITORS = [
|
||||||
|
{ id: 2, name: "Datasaur" },
|
||||||
|
{ id: 12, name: "Observatory" },
|
||||||
|
{ id: 16, name: "Fossil tracker" },
|
||||||
|
{ id: 6, name: "Audiobookshelf" },
|
||||||
|
{ id: 7, name: "Woodpecker CI" },
|
||||||
|
{ id: 8, name: "Forgejo Git" },
|
||||||
|
{ id: 9, name: "Server dashboard" },
|
||||||
|
{ id: 3, name: "Dozzle" },
|
||||||
|
{ id: 13, name: "Surf hub" },
|
||||||
|
{ id: 11, name: "Anime list" },
|
||||||
|
{ id: 5, name: "Wiki" },
|
||||||
|
{ id: 14, name: "Paperless" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 6;
|
||||||
|
const INTERVAL_TIME = 2500;
|
||||||
|
|
||||||
|
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
if (isHovered) {
|
||||||
|
// Start rotating pages only when hovered
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setPage((p) => (p + 1) % totalPages);
|
||||||
|
}, INTERVAL_TIME);
|
||||||
|
} else {
|
||||||
|
// Defer state reset to avoid "cascading render" error
|
||||||
|
// and allow the fade-out animation to play smoothly
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [isHovered, totalPages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
|
||||||
|
{/* --- DEFAULT VIEW --- */}
|
||||||
|
<div
|
||||||
|
className={`transition-opacity duration-300 ${
|
||||||
|
isHovered ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.6)]" />
|
||||||
|
<span className="font-medium text-white tracking-tight">
|
||||||
|
Hetzner Node-01
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] font-mono text-neutral-500">
|
||||||
|
SYS_STATUS: ONLINE
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Activity className="text-neutral-800 w-10 h-10 -mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-2 gap-4 border-t border-neutral-800/50 pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-mono text-neutral-600 uppercase">
|
||||||
|
Architecture
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-400">linux/amd64</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-mono text-neutral-600 uppercase">
|
||||||
|
Provider
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-400">Hetzner Cloud</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- REGISTRY VIEW --- */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 transition-all duration-300 flex flex-col ${
|
||||||
|
isHovered
|
||||||
|
? "opacity-100 translate-y-0"
|
||||||
|
: "opacity-0 translate-y-4 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* HEADER WITH SYNCED TIMER */}
|
||||||
|
<div className="flex items-center justify-between mb-3 group/header">
|
||||||
|
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] flex items-center gap-2 group-hover:text-blue-400 transition-colors">
|
||||||
|
Explore Systems
|
||||||
|
<motion.span
|
||||||
|
animate={{ x: [0, 4, 0] }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className="inline-block"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</motion.span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative w-12 h-[2.5px] bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
key={`${isHovered}-${page}`}
|
||||||
|
initial={{ width: "0%" }}
|
||||||
|
animate={isHovered ? { width: "100%" } : { width: "0%" }}
|
||||||
|
transition={{
|
||||||
|
duration: isHovered ? INTERVAL_TIME / 1000 : 0,
|
||||||
|
ease: "linear",
|
||||||
|
}}
|
||||||
|
className="h-full bg-blue-500/50" // Changed to blue to match Link intent
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-mono text-neutral-600">
|
||||||
|
{String(page + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<RegistrySlider page={page} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegistrySlider({ page }: { page: number }) {
|
||||||
|
const currentItems = MONITORS.slice(
|
||||||
|
page * ITEMS_PER_PAGE,
|
||||||
|
(page + 1) * ITEMS_PER_PAGE,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.div
|
||||||
|
key={page}
|
||||||
|
initial={{ opacity: 0, x: 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -10 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
className="grid grid-cols-1 gap-1.5 w-full"
|
||||||
|
>
|
||||||
|
{currentItems.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center justify-between bg-neutral-800/40 p-1.5 px-3 rounded-lg border border-neutral-700/30"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-medium text-neutral-300 truncate mr-2">
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1 shrink-0 scale-75 origin-right">
|
||||||
|
<Image
|
||||||
|
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
|
||||||
|
width={60}
|
||||||
|
height={20}
|
||||||
|
className="h-5 w-auto"
|
||||||
|
alt="System Status"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`}
|
||||||
|
width={80}
|
||||||
|
height={20}
|
||||||
|
className="h-5 w-auto opacity-60"
|
||||||
|
alt="Average Response Time"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
components/PageLayout.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"use client";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import Footer from "./Footer"; // Assuming you moved it to a component
|
||||||
|
import { PageLayoutProps } from "@/types/index";
|
||||||
|
|
||||||
|
export default function PageLayout({
|
||||||
|
children,
|
||||||
|
backLink,
|
||||||
|
backLabel = "BACK TO DASHBOARD",
|
||||||
|
maxWidth = "5xl",
|
||||||
|
}: PageLayoutProps) {
|
||||||
|
const widthClass = {
|
||||||
|
"5xl": "max-w-5xl",
|
||||||
|
"6xl": "max-w-6xl",
|
||||||
|
"7xl": "max-w-7xl",
|
||||||
|
}[maxWidth];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#0a0a0a] text-white pt-6 md:pt-12 lg:pt-24 px-6 md:px-12 lg:px-24 pb-8 flex flex-col">
|
||||||
|
<div className={`${widthClass} mx-auto w-full flex-grow flex flex-col`}>
|
||||||
|
<div className="flex-grow">
|
||||||
|
{backLink && (
|
||||||
|
<Link
|
||||||
|
href={backLink}
|
||||||
|
className="group flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-[10px] uppercase tracking-widest w-fit"
|
||||||
|
>
|
||||||
|
<ArrowLeft
|
||||||
|
size={12}
|
||||||
|
className="transition-transform group-hover:-translate-x-1"
|
||||||
|
/>
|
||||||
|
{backLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
components/ProjectShowcase.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function ProjectShowcase({ images }: { images: string[] }) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 h-[500px]">
|
||||||
|
{/* Main Image */}
|
||||||
|
<div className="lg:col-span-9 relative overflow-hidden rounded-3xl border border-neutral-800 bg-neutral-900 group">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.img
|
||||||
|
key={index}
|
||||||
|
src={images[index]}
|
||||||
|
initial={{ opacity: 0, scale: 1.05 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Subtle Overlay Label */}
|
||||||
|
<div className="absolute bottom-4 left-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<p className="text-[10px] font-mono uppercase tracking-widest text-white/70">
|
||||||
|
View {index + 1} of {images.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail Column */}
|
||||||
|
<div className="lg:col-span-3 flex lg:flex-col gap-3 overflow-x-auto lg:overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
className={`relative flex-shrink-0 w-24 lg:w-full aspect-video rounded-xl border-2 transition-all overflow-hidden ${
|
||||||
|
i === index
|
||||||
|
? "border-blue-500 ring-4 ring-blue-500/10"
|
||||||
|
: "border-neutral-800 opacity-40 hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt={`Project showcase thumbnail ${i}`}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{i === index && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="active-thumb"
|
||||||
|
className="absolute inset-0 bg-blue-500/10 z-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
data/forge.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ForgeProjectMetadata } from "@/types";
|
||||||
|
|
||||||
|
export const FORGE_PROJECTS: ForgeProjectMetadata[] = [
|
||||||
|
{
|
||||||
|
id: "pixelpals",
|
||||||
|
projectName: "PixelPals",
|
||||||
|
repoName: "pixel-pals",
|
||||||
|
status: "Alpha",
|
||||||
|
engine: "Flutter / Firebase",
|
||||||
|
imagePath: "/forge/pixel-pals.png",
|
||||||
|
description:
|
||||||
|
"A GPS-driven, cooperative creature collection game built to encourage exercise. Features turn-based tactical combat, class-based progression, and AI-generated companion art based on player descriptions.",
|
||||||
|
highlights: [
|
||||||
|
"GPS / Mapbox Integration",
|
||||||
|
"Generative AI (Pet Synthesis)",
|
||||||
|
"Real-time Firebase Sync",
|
||||||
|
"Turn-based Battle Engine",
|
||||||
|
],
|
||||||
|
// externalLink: "https://pixelpals.app",
|
||||||
|
},
|
||||||
|
];
|
||||||
113
data/lab.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { LabService } from "@/types/index";
|
||||||
|
|
||||||
|
export const LAB_SERVICES: LabService[] = [
|
||||||
|
{
|
||||||
|
id: "observatory",
|
||||||
|
name: "The Observatory",
|
||||||
|
description:
|
||||||
|
"Astronomical API orchestration and orbital visualization. Built to track ISS transits and lunar phases over Copenhagen.",
|
||||||
|
stack: ["Next.js", "Node", "SQLite"],
|
||||||
|
visibility: "public",
|
||||||
|
url: "https://observatory.georgew.dev",
|
||||||
|
gitUrl: "https://git.georgew.dev/georgew/mission-control",
|
||||||
|
image: "/lab/observatory.jpg",
|
||||||
|
uptimeId: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dino-tracker",
|
||||||
|
name: "GW Paleo",
|
||||||
|
description:
|
||||||
|
"A digital field journal and taxonomic registry for Dinosauria. Designed with a 'Museum Archive' aesthetic, this specimen tracker syncs with the Paleobiology Database (PBDB) to catalog thousands of validated species and genera.",
|
||||||
|
stack: ["Next.js", "Prisma", "SQLite", "Tailwind CSS"],
|
||||||
|
visibility: "public",
|
||||||
|
url: "https://paleo.georgew.dev",
|
||||||
|
gitUrl: "https://git.georgew.dev/georgew/dino-tracker",
|
||||||
|
image: "/lab/dino-tracker.jpg",
|
||||||
|
uptimeId: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "surf-hub",
|
||||||
|
name: "Surf Sentinel",
|
||||||
|
description:
|
||||||
|
"Custom telemetry dashboard for Llangennith Bay, Wales. Pulling real-time buoy data and wave height predictions to find the perfect window for a session.",
|
||||||
|
stack: ["Grafana", "Influx DB", "Node"],
|
||||||
|
visibility: "public",
|
||||||
|
url: "https://surf.georgew.dev/d/adrx6b4/llangennith-beach-surf-data?orgId=1&from=now-24h&to=now&timezone=browser&refresh=1h&theme=dark&kiosk=true",
|
||||||
|
image: "/lab/surf-hub.jpg",
|
||||||
|
gitUrl: "https://git.georgew.dev/georgew/surf-hub",
|
||||||
|
uptimeId: 13,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audiobookshelf",
|
||||||
|
name: "The Archive",
|
||||||
|
description:
|
||||||
|
"Dedicated audiobook server for the household. Primarily used for our Brandon Sanderson, Patrick Rothfuss, and Dungeon Crawler Carl marathons.",
|
||||||
|
stack: ["Docker", "Compose", "Tailscale", "rclone"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
image: "/lab/audiobookshelf.jpg",
|
||||||
|
gitUrl: "https://git.georgew.dev/georgew/audiobookshelf",
|
||||||
|
uptimeId: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "yamtrack",
|
||||||
|
name: "Yamtrack",
|
||||||
|
description:
|
||||||
|
"A specialized tracker for our anime watch-lists! Features custom metadata hooks to keep our seasonal progress in sync.",
|
||||||
|
stack: ["Docker", "Redis", "Tailscale"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
image: "/lab/yamtrack.jpg",
|
||||||
|
gitUrl: "https://git.georgew.dev/georgew/yamtrack",
|
||||||
|
uptimeId: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "paperless",
|
||||||
|
name: "Paperless-ngx",
|
||||||
|
description:
|
||||||
|
"Personal document management system with OCR and automated tagging. Digitizing our physical mail and records into a searchable, versioned archive.",
|
||||||
|
stack: ["Docker", "Redis", "PostgreSQL"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
image: "/lab/paperless.jpg",
|
||||||
|
gitUrl: "https://git.georgew.dev/georgew/paperless-ngx",
|
||||||
|
|
||||||
|
uptimeId: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "change-detection",
|
||||||
|
name: "Signal Watcher",
|
||||||
|
description:
|
||||||
|
"Automated monitoring for the essentials: NASA news updates, hobby stock alerts, and Telegram pings the second a new episode of 'The Traitors' drops.",
|
||||||
|
stack: ["Telegram API", "Webhooks"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
image: "/lab/change-detection.jpg",
|
||||||
|
gitUrl: "https://git.georgew.dev/georgew/change-detection",
|
||||||
|
uptimeId: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ops-suite",
|
||||||
|
name: "System Operations",
|
||||||
|
description:
|
||||||
|
"The 'Engine Room.' Utilizing Portainer for orchestration, Dozzle for log streaming, and Watchtower for automated container lifecycle management across the Hetzner node.",
|
||||||
|
stack: ["Portainer", "Dozzle", "Watchtower"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
image: "/lab/portainer.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wikijs",
|
||||||
|
name: "System Wiki",
|
||||||
|
description:
|
||||||
|
"The 'Source of Truth' for the home infrastructure. Contains deployment guides, network maps, and disaster recovery procedures for the entire node.",
|
||||||
|
stack: ["Wiki.js", "Markdown"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
image: "/lab/wikijs.jpg",
|
||||||
|
uptimeId: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
name: "System Dashboard",
|
||||||
|
description:
|
||||||
|
"The central entry point for the GeorgeW ecosystem, used as my personal home page. A high-level overview providing unified access to all public and VPN-secured services.",
|
||||||
|
stack: ["Homepage", "Docker", "Reverse Proxy"],
|
||||||
|
visibility: "tailscale",
|
||||||
|
image: "/lab/dashboard.jpg",
|
||||||
|
},
|
||||||
|
];
|
||||||
404
data/projects.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
import { Project } from "@/types/index";
|
||||||
|
|
||||||
|
export const PROJECT_REGISTRY: Project[] = [
|
||||||
|
{
|
||||||
|
slug: "ratoong",
|
||||||
|
category: "web",
|
||||||
|
title: "Ratoong",
|
||||||
|
subtitle: "High-Performance Ski & Travel Engine",
|
||||||
|
role: "Full-Stack Engineer",
|
||||||
|
duration: "2020 — 2022",
|
||||||
|
stack: ["Angular", "Firebase", "GCP Cloud Functions", "TypeScript"],
|
||||||
|
metrics: [
|
||||||
|
"< 200ms Search Latency",
|
||||||
|
"10,000+ Active Data Points",
|
||||||
|
"Fully Responsive Design",
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"A comprehensive ski resort planning and rating platform featuring real-time weather integration and complex multi-parameter search filters.",
|
||||||
|
engineeringStory: `
|
||||||
|
Building Ratoong was an exercise in managing **High-Density Data** within a reactive frontend ecosystem. The core challenge was transforming thousands of resort data points—ranging from piste lengths to real-time weather—into a lightning-fast, searchable interface.
|
||||||
|
|
||||||
|
#### Data Orchestration & Efficiency
|
||||||
|
Leveraging a **Document-Based Architecture (Firestore)**, I designed a schema that balanced read efficiency with real-time updates. To handle complex filtering (altitude, lift types, pricing) without taxing the client-side, I utilized **GCP Cloud Functions** as a middleware layer to process and normalize data from various 3rd-party APIs, including Google Maps and Weather services.
|
||||||
|
|
||||||
|
#### Modern Angular & Responsive UI
|
||||||
|
The frontend was built using modern **Angular**, focusing on a component-based architecture that ensured high performance across both desktop and mobile. I implemented a custom state management flow to handle resort ratings and trip planning, ensuring that user interactions were instantly reflected in the UI while syncing seamlessly with **Firebase Authentication**.
|
||||||
|
|
||||||
|
#### Lessons in Scalability
|
||||||
|
Working with a **Backend-as-a-Service (BaaS)** model taught me the importance of cost-effective query design and the power of event-driven triggers. I was responsible for maintaining the development, staging, and production environments, ensuring a clean CI/CD flow from localhost to the Firebase cloud.
|
||||||
|
|
||||||
|
#### Security & Data Governance
|
||||||
|
A key architectural pillar was the implementation of a robust **Security Rules** layer within Firebase. By moving the logic from the client to the database level, we ensured that resort metadata was globally searchable while sensitive user planning data remained strictly isolated. This event-driven security model allowed us to scale the user base without increasing the risk surface area of the platform.
|
||||||
|
`,
|
||||||
|
images: [
|
||||||
|
"/projects/ratoong/ratoong-1.jpg",
|
||||||
|
"/projects/ratoong/ratoong-2.jpg",
|
||||||
|
"/projects/ratoong/ratoong-3.jpg",
|
||||||
|
"/projects/ratoong/ratoong-4.jpg",
|
||||||
|
"/projects/ratoong/ratoong-5.jpg",
|
||||||
|
],
|
||||||
|
liveUrl: "https://www.ratoong.com/",
|
||||||
|
isPrivate: false,
|
||||||
|
mermaidChart: `
|
||||||
|
graph LR
|
||||||
|
subgraph Client_Side [Frontend]
|
||||||
|
A[Angular Web App]:::traffic
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Firebase_GCP [Cloud Infrastructure]
|
||||||
|
direction TB
|
||||||
|
Hub((Firebase SDK)):::hub
|
||||||
|
|
||||||
|
B[Firebase Auth]:::node
|
||||||
|
C[Firestore DB]:::node
|
||||||
|
D[Cloud Functions]:::node
|
||||||
|
E[Partner API Proxy]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph External [Third Party]
|
||||||
|
direction TB
|
||||||
|
F[Weather API]:::traffic
|
||||||
|
G[Google Maps API]:::traffic
|
||||||
|
H[Affiliate Partners]:::traffic
|
||||||
|
end
|
||||||
|
|
||||||
|
A ==> Hub
|
||||||
|
Hub -->|Identity| B
|
||||||
|
Hub <-->|Data Sync| C
|
||||||
|
Hub -->|Triggers| D
|
||||||
|
|
||||||
|
D --> F
|
||||||
|
D --> G
|
||||||
|
D --> H
|
||||||
|
E -.->|Internal Access| C
|
||||||
|
|
||||||
|
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||||
|
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
|
||||||
|
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff,stroke-width:2px
|
||||||
|
`,
|
||||||
|
storyLabel: "DATA // UI EFFICIENCY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "datasaur",
|
||||||
|
category: "web",
|
||||||
|
title: "Datasaur",
|
||||||
|
subtitle: "Automated Statistical Analysis Engine",
|
||||||
|
role: "Lead Architect & Creator",
|
||||||
|
duration: "2019 — 2021", // Reflecting "one of my first things"
|
||||||
|
stack: ["Python", "Flask", "MongoDB", "Pandas", "SciPy"],
|
||||||
|
metrics: [
|
||||||
|
"Automated Stat-Testing",
|
||||||
|
"Multi-Format ETL",
|
||||||
|
"Self-Hosted Architecture",
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"A comprehensive survey data platform that automates complex statistical workflows, from raw data aggregation to advanced hypothesis testing and visualization.",
|
||||||
|
storyLabel: "ALGORITHMIC // STATISTICAL PROCESSING",
|
||||||
|
images: [
|
||||||
|
"/projects/datasaur/datasaur-1.jpg",
|
||||||
|
"/projects/datasaur/datasaur-2.jpg",
|
||||||
|
"/projects/datasaur/datasaur-3.jpg",
|
||||||
|
"/projects/datasaur/datasaur-4.jpg",
|
||||||
|
"/projects/datasaur/datasaur-5.jpg",
|
||||||
|
"/projects/datasaur/datasaur-6.jpg",
|
||||||
|
],
|
||||||
|
repoUrl: "https://git.georgew.dev/georgew/datasaur",
|
||||||
|
liveUrl: "https://datasaur.dev",
|
||||||
|
isPrivate: false,
|
||||||
|
engineeringStory: `
|
||||||
|
Datasaur was born out of a necessity to bridge the gap between raw survey data and academic-grade statistical insights. The challenge wasn't just displaying data, but architecting a system capable of performing complex mathematical computations on-the-fly.
|
||||||
|
|
||||||
|
#### Statistical Automation Pipeline
|
||||||
|
The core of the application is a robust processing engine built on **Pandas** and **SciPy**. I implemented automated workflows for non-parametric tests like **Kruskal-Wallis** and **Mann-Whitney U**, ensuring that the platform could intelligently suggest and execute the correct statistical test based on the data distribution.
|
||||||
|
|
||||||
|
#### Data Visualization & Export
|
||||||
|
To translate these numbers into insights, I built a visualization layer supporting everything from standard histograms to complex **Box and Whisker** plots. Using **XlsxWriter**, I developed a custom export engine that allowed users to pull processed data directly into professional-grade spreadsheets with pre-formatted statistical summaries.
|
||||||
|
|
||||||
|
#### Infrastructure & Monolithic Integrity
|
||||||
|
The project follows a classic monolithic architecture, which proved highly efficient for keeping memory-intensive dataframes close to the processing logic. Today, the platform is self-hosted using a **Caddy** reverse proxy and **MongoDB Atlas**, demonstrating the longevity and stability of a well-architected Flask ecosystem.
|
||||||
|
`,
|
||||||
|
mermaidChart: `
|
||||||
|
graph LR
|
||||||
|
subgraph Client_Layer [User Interface]
|
||||||
|
A[Vanilla JS / Browser]:::traffic
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Server_Layer [Application Logic]
|
||||||
|
B[Caddy Reverse Proxy]:::node
|
||||||
|
C[Flask / Python Monolith]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Processing_Engine [Data Science Core]
|
||||||
|
D[Pandas ETL]:::node
|
||||||
|
E[SciPy / Pingouin Stats]:::node
|
||||||
|
F[XlsxWriter Export]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Storage [Data Persistence]
|
||||||
|
G[MongoDB Atlas]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
A <-->|HTTPS| B
|
||||||
|
B <-->|WSGI| C
|
||||||
|
C <-->|Query/Write| G
|
||||||
|
C ==>|Dataframes| D
|
||||||
|
D --> E
|
||||||
|
D --> F
|
||||||
|
|
||||||
|
%% Styles %%
|
||||||
|
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||||
|
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "ayla",
|
||||||
|
category: "infrastructure",
|
||||||
|
title: "Ayla",
|
||||||
|
subtitle: "Regulatory-Compliant Medical Platform",
|
||||||
|
role: "Tech Lead & Scrum Master",
|
||||||
|
duration: "2022 — 2024",
|
||||||
|
stack: [
|
||||||
|
"Kubernetes",
|
||||||
|
"Ruby on Rails",
|
||||||
|
"Flutter",
|
||||||
|
"Terraform",
|
||||||
|
"GCP",
|
||||||
|
"OTC",
|
||||||
|
],
|
||||||
|
metrics: [
|
||||||
|
"Multi-Region Data Residency",
|
||||||
|
"ISO 27001 Compliant",
|
||||||
|
"Single-Click IaC Deployment",
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"A high-availability medical device platform supporting dementia treatment, featuring multi-cloud infrastructure, automated pipelines, cross-platform web and mobile deployments and strict regulatory requirements.",
|
||||||
|
storyLabel: "GOVERNANCE // CLOUD ORCHESTRATION",
|
||||||
|
images: [
|
||||||
|
"/projects/ayla/ayla-1.jpg",
|
||||||
|
"/projects/ayla/ayla-2.jpg",
|
||||||
|
"/projects/ayla/ayla-3.jpg",
|
||||||
|
"/projects/ayla/ayla-4.jpg",
|
||||||
|
"/projects/ayla/ayla-5.jpg",
|
||||||
|
],
|
||||||
|
isPrivate: true,
|
||||||
|
engineeringStory: `
|
||||||
|
As Tech Lead for Ayla, I was responsible for architecting a platform that met the rigorous safety and security standards of a certified medical device. This required a "Security-by-Design" approach, balancing high availability (SLA) with rigid data residency requirements across the UK and EU.
|
||||||
|
|
||||||
|
#### Multi-Cloud Infrastructure & IaC
|
||||||
|
To satisfy GDPR and local health data regulations, I architected a dual-cloud strategy: **Open Telekom Cloud (OTC)** for European users and **GCP** for the UK. Using **Terraform**, I codified the entire infrastructure, enabling us to spin up identical, audit-ready Kubernetes clusters or Cloud Run environments in minutes. This automation was critical for maintaining the "Release-Pre-Release" protocols required for medical certification.
|
||||||
|
|
||||||
|
#### Full-Stack Delivery & Automation
|
||||||
|
The platform featured a **Flutter** frontend for Web, iOS, and Android, all managed through automated **CICD** pipelines. I implemented a layered automation strategy, combining **GitHub Actions** for web deployments and server-side logic with **Fastlane** for mobile app store distribution. The backend was a high-performance **Ruby on Rails** API, architected as a stateless "mini-service" to ensure horizontal scalability within Kubernetes. I also integrated **Squidex CMS** to empower non-technical colleagues to manage content without compromising the system's core integrity.
|
||||||
|
|
||||||
|
#### Leadership & Compliance
|
||||||
|
Beyond the code, I served as Scrum Master and Product Owner, leading sprint planning, retro and demos. I worked closely with regulatory partners and personally oversaw the creation of **DPIAs**, **Cyber Essentials** certification, and the path to **ISO 27001** compliance. In the absence of a dedicated IT department, I managed the MDM systems and sysadmin duties, ensuring that every layer of the organization met the strict regulatory bar.
|
||||||
|
`,
|
||||||
|
mermaidChart: `
|
||||||
|
graph TB
|
||||||
|
%% Direction and Layout
|
||||||
|
direction TB
|
||||||
|
|
||||||
|
subgraph Shared_Ops [DevOps & CMS]
|
||||||
|
I[GitHub Actions CICD]:::traffic
|
||||||
|
J[Terraform IaC]:::traffic
|
||||||
|
K[Squidex CMS]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Frontend_Layer [Omni-Channel]
|
||||||
|
A[Flutter Web / Mobile]:::traffic
|
||||||
|
B[Bunny CDN / Edge Storage]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph UK_Region [GCP]
|
||||||
|
G[Cloud Run Containers]:::node
|
||||||
|
H[Cloud SQL]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EU_Region [Open Telekom Cloud]
|
||||||
|
D[NGINX Ingress]:::node
|
||||||
|
C[K8s Cluster]:::node
|
||||||
|
F[Object Storage]:::node
|
||||||
|
E[PostgreSQL RDS]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Connections
|
||||||
|
A <--> B
|
||||||
|
I -->|Fastlane| A
|
||||||
|
J -->|Provision| G
|
||||||
|
J -->|Provision| C
|
||||||
|
|
||||||
|
B <-->|UK Traffic| G
|
||||||
|
B <-->|EU Traffic| D
|
||||||
|
|
||||||
|
D --> C
|
||||||
|
G <--> H
|
||||||
|
C <--> E
|
||||||
|
C --- F
|
||||||
|
|
||||||
|
G <--> K
|
||||||
|
C <--> K
|
||||||
|
|
||||||
|
%% Styles
|
||||||
|
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||||
|
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "choosa",
|
||||||
|
category: "mobile",
|
||||||
|
title: "Choosa",
|
||||||
|
subtitle: "Social Content Discovery Engine",
|
||||||
|
role: "Lead Developer & Architect",
|
||||||
|
duration: "2023 — Present",
|
||||||
|
stack: [
|
||||||
|
"Flutter",
|
||||||
|
"Firebase",
|
||||||
|
"Firestore",
|
||||||
|
"Cloud Functions",
|
||||||
|
"Push Notifications",
|
||||||
|
],
|
||||||
|
metrics: [
|
||||||
|
"Real-time Match Engine",
|
||||||
|
"Cross-Platform (iOS/Android)",
|
||||||
|
"Multi-API Orchestration",
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"A social decision-making app that solves 'choice paralysis' by allowing groups to swipe on movies and TV shows, using a real-time matching algorithm to find common interests.",
|
||||||
|
storyLabel: "UX // MOBILE SYNCHRONIZATION",
|
||||||
|
images: [
|
||||||
|
"/projects/choosa/choosa-1.jpg",
|
||||||
|
"/projects/choosa/choosa-2.jpg",
|
||||||
|
"/projects/choosa/choosa-3.jpg",
|
||||||
|
"/projects/choosa/choosa-4.jpg",
|
||||||
|
"/projects/choosa/choosa-5.jpg",
|
||||||
|
],
|
||||||
|
isPrivate: false,
|
||||||
|
engineeringStory: `
|
||||||
|
Choosa was built to solve the universal problem of "choice paralysis" in social settings. The challenge was creating a low-latency, real-time environment where group preferences could be aggregated and matched instantaneously.
|
||||||
|
|
||||||
|
#### Real-time State & Match Logic
|
||||||
|
The core engine utilizes **Firestore's** real-time listeners to sync swipe states across multiple devices simultaneously. I architected a custom matching algorithm within **Firebase Cloud Functions** that monitors group sessions; the moment a consensus is reached, the system triggers **Firebase Cloud Messaging (FCM)** to send push notifications to all participants, ensuring a seamless transition from "deciding" to "watching."
|
||||||
|
|
||||||
|
#### Data Orchestration & External APIs
|
||||||
|
To provide a rich library of content, I integrated the **TMDB** and **Movie of the Night** APIs. By utilizing a middleware layer in Cloud Functions, I was able to normalize data from different sources, filter results based on user-specific streaming subscriptions, and cache results to minimize API overhead and latency.
|
||||||
|
|
||||||
|
#### Mobile Deployment & Native Experience
|
||||||
|
Developing Choosa in **Flutter** allowed for a unified codebase while maintaining native performance on both iOS and Android. I managed the full deployment lifecycle, from configuring **Fastlane** for automated App Store and Play Store releases to handling platform-specific requirements like adaptive icons and deep-linking.
|
||||||
|
`,
|
||||||
|
mermaidChart: `
|
||||||
|
graph LR
|
||||||
|
subgraph Client_Mobile [Mobile Frontend]
|
||||||
|
A[Flutter App]:::traffic
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Firebase_Core [Backend Services]
|
||||||
|
Hub((Firebase SDK)):::hub
|
||||||
|
B[Auth]:::node
|
||||||
|
C[Firestore DB]:::node
|
||||||
|
D[Cloud Functions]:::node
|
||||||
|
E[Cloud Messaging]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph External_Data [Content Providers]
|
||||||
|
F[TMDB API]:::traffic
|
||||||
|
G[Movie of Night API]:::traffic
|
||||||
|
end
|
||||||
|
|
||||||
|
A <--> Hub
|
||||||
|
Hub --> B
|
||||||
|
Hub <-->|Sync State| C
|
||||||
|
Hub -->|Trigger Match| D
|
||||||
|
D -->|Push Notification| E
|
||||||
|
E -->|Alert Group| A
|
||||||
|
|
||||||
|
D --> F
|
||||||
|
D --> G
|
||||||
|
|
||||||
|
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||||
|
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
|
||||||
|
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
slug: "nutriveat",
|
||||||
|
category: "mobile",
|
||||||
|
title: "Nutriveat",
|
||||||
|
subtitle: "AI-Powered Personalized Nutrition",
|
||||||
|
role: "Lead Developer & Architect",
|
||||||
|
duration: "2024 — Present",
|
||||||
|
stack: [
|
||||||
|
"Flutter",
|
||||||
|
"Firebase",
|
||||||
|
"OpenAI (GPT-4o)",
|
||||||
|
"Novita AI",
|
||||||
|
"StoreKit / Play Billing",
|
||||||
|
],
|
||||||
|
metrics: [
|
||||||
|
"Fine-tuned LLM Assistants",
|
||||||
|
"Direct Store Integrations",
|
||||||
|
"Multi-Tier Subscriptions",
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"A comprehensive health and nutrition platform that leverages fine-tuned generative AI to architect personalized meal plans, automate shopping lists, and provide real-time culinary assistance.",
|
||||||
|
storyLabel: "AI ORCHESTRATION // MONETIZATION",
|
||||||
|
images: [
|
||||||
|
"/projects/nutriveat/nutriveat-6.jpg",
|
||||||
|
"/projects/nutriveat/nutriveat-1.jpg",
|
||||||
|
"/projects/nutriveat/nutriveat-2.jpg",
|
||||||
|
"/projects/nutriveat/nutriveat-3.jpg",
|
||||||
|
"/projects/nutriveat/nutriveat-4.jpg",
|
||||||
|
"/projects/nutriveat/nutriveat-5.jpg",
|
||||||
|
],
|
||||||
|
isPrivate: false,
|
||||||
|
engineeringStory: `
|
||||||
|
Nutriveat represents a deep dive into the practical application of Large Language Models (LLMs) in a consumer-facing mobile environment. The goal was to move beyond a standard "chat wrapper" and create a deeply integrated tool that understands the nuance of dietary constraints, kitchen logistics, and user budgets.
|
||||||
|
|
||||||
|
#### Fine-Tuned AI & Structured Output
|
||||||
|
A major engineering hurdle was ensuring the AI generated valid, consistent, and safe meal plans. I implemented a system of fine-tuned system prompts and strict schema validation within **Cloud Functions** to force GPT-4o to return structured data. This allowed the app to take raw AI output and instantly transform it into actionable Firestore documents, shopping list items, and high-fidelity image prompts for **Novita AI**.
|
||||||
|
|
||||||
|
#### Native Subscription Architecture
|
||||||
|
To support the ongoing API costs of generative AI, I architected a robust multi-tier subscription model (Monthly/Annual). I implemented the monetization layer by integrating directly with the **Apple App Store (StoreKit)** and **Google Play Console (Billing Library)**. This involved architecting a custom server-side validation system in Cloud Functions to handle real-time subscription status, grace periods, and cross-platform entitlement logic without the use of third-party middleware.
|
||||||
|
|
||||||
|
#### Context-Aware Culinary Assistance
|
||||||
|
I developed a specialized AI Chatbot designed to function as a "Kitchen Assistant." Unlike general-purpose bots, this assistant is provided with the specific context of the user's current meal plan, dietary allergies, and available utensils. By using **RAG-lite (Retrieval-Augmented Generation)** principles, the bot can provide accurate unit conversions and tailored cooking instructions that respect the user's specific kitchen setup.
|
||||||
|
`,
|
||||||
|
mermaidChart: `
|
||||||
|
graph LR
|
||||||
|
subgraph Client_Mobile [Flutter Frontend]
|
||||||
|
A[Mobile App]:::traffic
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Firebase_Backend [Control Plane]
|
||||||
|
Hub((Firebase SDK)):::hub
|
||||||
|
C[Firestore DB]:::node
|
||||||
|
D[Cloud Functions]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph AI_Orchestration [Intelligence Layer]
|
||||||
|
F[OpenAI / GPT-4o]:::node
|
||||||
|
G[Novita AI / Stable Diffusion]:::node
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Store_Integrations [Native Billing]
|
||||||
|
H[App Store / Play Store]:::traffic
|
||||||
|
end
|
||||||
|
|
||||||
|
A <--> Hub
|
||||||
|
Hub <--> C
|
||||||
|
Hub --> D
|
||||||
|
|
||||||
|
D ==>|Fine-tuned Prompts| F
|
||||||
|
D ==>|Image Generation| G
|
||||||
|
F -.->|JSON Parsing| D
|
||||||
|
|
||||||
|
A <-->|Native IAP Flow| H
|
||||||
|
H -.->|Server-to-Server Hooks| D
|
||||||
|
|
||||||
|
classDef traffic fill:#2563eb,stroke:#3b82f6,color:#fff
|
||||||
|
classDef node fill:#16a34a,stroke:#22c55e,color:#fff
|
||||||
|
classDef hub fill:#f59e0b,stroke:#d97706,color:#fff
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -7,7 +7,9 @@ services:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
networks:
|
networks:
|
||||||
- web_traffic
|
- web_traffic
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web_traffic:
|
web_traffic:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
import eslintConfigPrettier from "eslint-config-prettier";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...nextTs,
|
||||||
// Override default ignores of eslint-config-next.
|
eslintConfigPrettier,
|
||||||
globalIgnores([
|
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|
|
||||||
100
lib/git.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { FORGE_PROJECTS } from "@/data/forge";
|
||||||
|
import { ChangelogEntry, ForgeProject } from "@/types";
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
export async function getAllForgeProjects(): Promise<ForgeProject[]> {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
FORGE_PROJECTS.map(async (metadata) => {
|
||||||
|
const changelog = await getPrivateChangelog(metadata.repoName);
|
||||||
|
|
||||||
|
// Determine if the latest entry happened in the last 30 days
|
||||||
|
const latestDate =
|
||||||
|
changelog.length > 0 ? new Date(changelog[0].date) : null;
|
||||||
|
const isRecent = latestDate ? latestDate >= thirtyDaysAgo : false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
currentVersion:
|
||||||
|
changelog.length > 0
|
||||||
|
? `${changelog[0].version}_${metadata.status}`
|
||||||
|
: `v0.0.0_${metadata.status}`,
|
||||||
|
isRecent, // This is the new conditional flag
|
||||||
|
changelog: changelog.slice(0, 3),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPrivateChangelog(
|
||||||
|
repoName: string,
|
||||||
|
): Promise<ChangelogEntry[]> {
|
||||||
|
const repoOwner = "georgew";
|
||||||
|
const filePath = "changelog.md";
|
||||||
|
const url = `https://git.georgew.dev/api/v1/repos/${repoOwner}/${repoName}/contents/${filePath}`;
|
||||||
|
const headers = {
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${process.env.FORGEJO_TOKEN}`,
|
||||||
|
Accept: "application/vnd.forgejo.raw",
|
||||||
|
},
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, headers);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
version: "v0.0.0",
|
||||||
|
date: new Date().toISOString().split("T")[0],
|
||||||
|
changes: ["Documentation currently synchronizing with the Forge..."],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 2. Forgejo returns the content as a base64 string
|
||||||
|
// We need to decode it to UTF-8
|
||||||
|
const markdownText = Buffer.from(data.content, "base64").toString("utf-8");
|
||||||
|
|
||||||
|
return parseMarkdown(markdownText);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Git Fetch Error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple parser for your ## [Version] - YYYY-MM-DD format
|
||||||
|
function parseMarkdown(text: string): ChangelogEntry[] {
|
||||||
|
// Split by "## " at the start of a line to isolate version blocks
|
||||||
|
const sections = text.split(/\n(?=## )/g);
|
||||||
|
|
||||||
|
return sections
|
||||||
|
.map((section) => {
|
||||||
|
const lines = section
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
|
// Match the pattern: ## [Version] - YYYY-MM-DD
|
||||||
|
const headerMatch = lines[0].match(/## \[(.*?)\] - (.*)/);
|
||||||
|
if (!headerMatch) return null;
|
||||||
|
|
||||||
|
const version = headerMatch[1];
|
||||||
|
const date = headerMatch[2];
|
||||||
|
|
||||||
|
// Collect all lines starting with "- " anywhere in this version block
|
||||||
|
const changes = lines
|
||||||
|
.filter((line) => line.startsWith("- "))
|
||||||
|
.map((line) => line.replace("- ", ""));
|
||||||
|
|
||||||
|
return { version, date, changes };
|
||||||
|
})
|
||||||
|
.filter((entry): entry is ChangelogEntry => entry !== null)
|
||||||
|
.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone'
|
output: "standalone",
|
||||||
|
assetPrefix:
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? "https://georgew.b-cdn.net"
|
||||||
|
: undefined,
|
||||||
|
images: {
|
||||||
|
domains: ["georgew.b-cdn.net"],
|
||||||
|
loader: "default",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
3172
package-lock.json
generated
10
package.json
|
|
@ -9,20 +9,26 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/upgrade": "^4.1.18",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"mermaid": "^11.12.2",
|
||||||
"next": "16.1.4",
|
"next": "16.1.4",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.4",
|
"eslint-config-next": "16.1.4",
|
||||||
"tailwindcss": "^4",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
public/.well-known/nostr.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"names": {
|
||||||
|
"muninn": "44e345442475c960433feb762c9d3f70e4fdb71c2f873a3473358d40e2ae01c1"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
public/favicon.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#0a0a0a"/>
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y="52%"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
text-anchor="middle"
|
||||||
|
fill="#f97316"
|
||||||
|
font-family="system-ui, sans-serif"
|
||||||
|
font-weight="900"
|
||||||
|
font-size="22"
|
||||||
|
style="transform-origin: center; transform: scale(1, 1.2);"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 409 B |
BIN
public/forge/pixel-pals.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
1
public/forgejo.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="38.5 0.5 135 211"><style>.st3{fill:none;stroke:#d40000;stroke-width:15}</style><g transform="translate(6 6)"><path d="M58 168V70c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#f60;stroke-width:25"/><path d="M58 168v-30c0-27.6 22.4-50 50-50h20" style="fill:none;stroke:#d40000;stroke-width:25"/><circle cx="142" cy="20" r="18" style="fill:none;stroke:#f60;stroke-width:15"/><circle cx="142" cy="88" r="18" class="st3"/><circle cx="58" cy="180" r="18" class="st3"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
BIN
public/lab/audiobookshelf.jpg
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
public/lab/change-detection.jpg
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
public/lab/dashboard.jpg
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
public/lab/dino-tracker.jpg
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
public/lab/observatory.jpg
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
public/lab/paperless.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
public/lab/portainer.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/lab/surf-hub.jpg
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/lab/wikijs.jpg
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
public/lab/yamtrack.jpg
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/profile.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/projects/ayla/ayla-1.jpg
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
public/projects/ayla/ayla-2.jpg
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
public/projects/ayla/ayla-3.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
public/projects/ayla/ayla-4.jpg
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
public/projects/ayla/ayla-5.jpg
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
public/projects/choosa/choosa-1.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
public/projects/choosa/choosa-2.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/projects/choosa/choosa-3.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
public/projects/choosa/choosa-4.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/projects/choosa/choosa-5.jpg
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
public/projects/datasaur/datasaur-1.jpg
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
public/projects/datasaur/datasaur-2.jpg
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
public/projects/datasaur/datasaur-3.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
public/projects/datasaur/datasaur-4.jpg
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
public/projects/datasaur/datasaur-5.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
public/projects/datasaur/datasaur-6.jpg
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
public/projects/nutriveat/nutriveat-1.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
public/projects/nutriveat/nutriveat-2.jpg
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
public/projects/nutriveat/nutriveat-3.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/projects/nutriveat/nutriveat-4.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/projects/nutriveat/nutriveat-5.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
public/projects/nutriveat/nutriveat-6.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
public/projects/ratoong/ratoong-1.jpg
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
public/projects/ratoong/ratoong-2.jpg
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
public/projects/ratoong/ratoong-3.jpg
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
public/projects/ratoong/ratoong-4.jpg
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
public/projects/ratoong/ratoong-5.jpg
Normal file
|
After Width: | Height: | Size: 292 KiB |
81
types/index.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface CategoryCardProps {
|
||||||
|
href: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tech: string[];
|
||||||
|
hoverColor: string;
|
||||||
|
activeTechColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
backLink?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
maxWidth?: "5xl" | "6xl" | "7xl";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
slug: string;
|
||||||
|
category: "web" | "mobile" | "infrastructure";
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
role: string;
|
||||||
|
duration: string;
|
||||||
|
stack: string[];
|
||||||
|
metrics: string[];
|
||||||
|
description: string;
|
||||||
|
engineeringStory: string;
|
||||||
|
storyLabel?: string;
|
||||||
|
images: string[];
|
||||||
|
liveUrl?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
mermaidChart?: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabService {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
stack: string[];
|
||||||
|
visibility: "public" | "tailscale";
|
||||||
|
url?: string;
|
||||||
|
gitUrl?: string;
|
||||||
|
image: string;
|
||||||
|
uptimeId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgeProject {
|
||||||
|
id: string;
|
||||||
|
status: "Alpha" | "Beta" | "R&D";
|
||||||
|
engine: string;
|
||||||
|
description: string;
|
||||||
|
imagePath?: string;
|
||||||
|
highlights: string[];
|
||||||
|
externalLink?: string;
|
||||||
|
projectName: string;
|
||||||
|
isRecent: boolean;
|
||||||
|
currentVersion: string;
|
||||||
|
changelog: ChangelogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgeProjectMetadata {
|
||||||
|
id: string;
|
||||||
|
projectName: string;
|
||||||
|
repoName: string;
|
||||||
|
status: "Alpha" | "Beta" | "R&D";
|
||||||
|
engine: string;
|
||||||
|
imagePath?: string;
|
||||||
|
description: string;
|
||||||
|
highlights: string[];
|
||||||
|
externalLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
changes: string[];
|
||||||
|
}
|
||||||