Fleshed out the lab page and projects

This commit is contained in:
GeorgeWebberley 2026-02-02 18:55:07 +01:00
parent 1e7a1c8a5f
commit ab481053bf
15 changed files with 352 additions and 41 deletions

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

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

View file

@ -126,15 +126,17 @@ export default function Home() {
</motion.div>
{/* Top Row Right: The Service Registry */}
<motion.div
variants={itemVariants}
whileHover={{ y: -5 }}
onMouseEnter={() => setIsHoveringMonitors(true)}
onMouseLeave={() => setIsHoveringMonitors(false)}
className="group md:col-span-2 p-6 rounded-3xl bg-neutral-900 border border-neutral-800 flex flex-col justify-center relative overflow-hidden min-h-[180px]"
>
<MonitorCard isHovered={isHoveringMonitors} />
</motion.div>
<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

View file

@ -20,12 +20,42 @@ const MONITORS = [
{ id: 4, name: "Watchtower" },
];
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: Always rendered, opacity controlled by parent hover */}
{/* --- DEFAULT VIEW --- */}
<div
className={`transition-opacity duration-300 ${isHovered ? "opacity-0" : "opacity-100"}`}
className={`transition-opacity duration-300 ${
isHovered ? "opacity-0 pointer-events-none" : "opacity-100"
}`}
>
<div className="flex items-start justify-between">
<div>
@ -58,7 +88,7 @@ export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
</div>
</div>
{/* REGISTRY VIEW: Only "Active" when hovered */}
{/* --- REGISTRY VIEW --- */}
<div
className={`absolute inset-0 transition-all duration-300 flex flex-col ${
isHovered
@ -66,42 +96,61 @@ export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
: "opacity-0 translate-y-4 pointer-events-none"
}`}
>
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] mb-3">
Service Registry
</h4>
{/* 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">
{/* We pass isHovered here to start/stop the timer inside a separate lifecycle */}
<RegistrySlider isHovered={isHovered} />
<RegistrySlider page={page} />
</div>
</div>
</div>
);
}
function RegistrySlider({ isHovered }: { isHovered: boolean }) {
const [page, setPage] = useState(0);
const totalPages = Math.ceil(MONITORS.length / 6);
useEffect(() => {
if (!isHovered) {
const timeout = setTimeout(() => setPage(0), 300);
return () => clearTimeout(timeout);
}
const interval = setInterval(() => {
setPage((p) => (p + 1) % totalPages);
}, 4000);
return () => clearInterval(interval);
}, [isHovered, totalPages]);
const currentItems = MONITORS.slice(page * 6, (page + 1) * 6);
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} // Key change: this triggers the animation on page change
key={page}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
@ -119,11 +168,11 @@ function RegistrySlider({ isHovered }: { isHovered: boolean }) {
<div className="flex gap-1 shrink-0 scale-75 origin-right">
<Image
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
width={60} // Set a base width
height={20} // Set a base height
className="h-5 w-auto" // Keep your CSS for sizing
width={60}
height={20}
className="h-5 w-auto"
alt="System Status"
unoptimized // Essential for live status badges
unoptimized
/>
<Image
src={`https://status.georgew.dev/api/badge/${m.id}/avg-response/24`}

103
data/lab.ts Normal file
View file

@ -0,0 +1,103 @@
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/george/observatory",
image: "/lab/observatory.jpg",
uptimeId: 12,
},
{
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",
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",
url: "https://audio.georgew.dev",
image: "/lab/audiobookshelf.jpg",
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",
url: "https://anime.georgew.dev",
image: "/lab/yamtrack.jpg",
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",
url: "https://paperless.georgew.dev",
image: "/lab/paperless.jpg",
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",
url: "https://alerts.georgew.dev",
image: "/lab/change-detection.jpg",
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",
url: "https://portainer.georgew.dev",
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",
url: "https://wiki.georgew.dev",
image: "/lab/wikijs.jpg",
uptimeId: 5,
},
{
id: "dashboard",
name: "System Dashboard",
description:
"The central entry point for the GeorgeW ecosystem. A high-level overview providing unified access to all public and VPN-secured services.",
stack: ["Next.js", "Docker", "Reverse Proxy"],
visibility: "public",
url: "https://dash.georgew.dev",
gitUrl: "https://git.georgew.dev/george/homepage",
image: "/lab/dashboard.jpg",
},
];

View file

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

1
public/forgejo.svg Normal file
View file

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

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

BIN
public/lab/observatory.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
public/lab/paperless.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
public/lab/portainer.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

BIN
public/lab/wikijs.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
public/lab/yamtrack.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

View file

@ -35,3 +35,15 @@ export interface Project {
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;
}