Fleshed out the lab page and projects
136
app/lab/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -126,15 +126,17 @@ export default function Home() {
|
|||
</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="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]"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{/* 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
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
|
@ -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
|
|
@ -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/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 |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||