Refactored footer, and added page load animations
This commit is contained in:
parent
02c12b6e15
commit
0d3b304d9a
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ShieldCheck, Zap, Globe, Cpu, Terminal } from "lucide-react";
|
||||
import { ShieldCheck, Zap, Cpu, Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
|
||||
const CAPABILITIES = [
|
||||
{
|
||||
|
|
@ -41,13 +43,13 @@ const CAPABILITIES = [
|
|||
|
||||
export default function InfrastructurePage() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-6 py-16 font-mono">
|
||||
{/* Tightened Header */}
|
||||
<header className="mb-16">
|
||||
<PageLayout backLink="/" maxWidth="5xl">
|
||||
{/* 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 Operationss
|
||||
Infrastructure and Operations
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-neutral-500 max-w-xl leading-relaxed text-sm">
|
||||
|
|
@ -57,14 +59,15 @@ export default function InfrastructurePage() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tightened Specification List */}
|
||||
<div className="space-y-6 mb-32">
|
||||
{/* 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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05, duration: 0.4 }}
|
||||
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">
|
||||
|
|
@ -95,11 +98,11 @@ export default function InfrastructurePage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Corrected Alignment Featured Section */}
|
||||
<section className="bg-neutral-900/40 border border-neutral-800 p-8 md:p-12 rounded-3xl">
|
||||
{/* 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">
|
||||
<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">
|
||||
|
|
@ -131,6 +134,6 @@ export default function InfrastructurePage() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
302
app/page.tsx
302
app/page.tsx
|
|
@ -1,19 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { Globe, Smartphone, Server, Gamepad2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import MonitorCard from "@/components/MonitorCard";
|
||||
import Image from "next/image";
|
||||
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() {
|
||||
const [isHoveringMonitors, setIsHoveringMonitors] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<header className="mb-12">
|
||||
<PageLayout maxWidth="7xl">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col gap-12"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<motion.header variants={itemVariants}>
|
||||
<h1 className="text-4xl font-bold tracking-tight">George W.</h1>
|
||||
<p className="text-neutral-400 mt-2">
|
||||
Senior Full Stack Engineer & Tech Lead
|
||||
|
|
@ -32,14 +59,15 @@ export default function Home() {
|
|||
LinkedIn
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</motion.header>
|
||||
|
||||
{/* Main Bento Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-6">
|
||||
{/* Top Row Left: The Architect */}
|
||||
<div className="md:col-span-4 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex flex-col md:flex-row gap-8 min-h-[300px] overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-[0.03] pointer-events-none"></div>
|
||||
|
||||
{/* Description and tags */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="md:col-span-4 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex flex-col md:flex-row gap-8 min-h-[300px] overflow-hidden relative"
|
||||
>
|
||||
<div className="flex-[1.5] flex flex-col justify-between relative z-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-4 tracking-tight">
|
||||
|
|
@ -76,142 +104,74 @@ export default function Home() {
|
|||
|
||||
<div className="hidden md:block w-px bg-neutral-800/50 self-stretch" />
|
||||
|
||||
{/* Technical details */}
|
||||
<div className="flex-1 flex flex-col justify-around py-2 relative z-10">
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h4 className="text-[12px] font-mono text-blue-500 uppercase tracking-[0.2em] mb-2">
|
||||
Leadership
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-300 leading-tight">
|
||||
Tech Lead & Scrum Master. Orchestrating sprint cycles,
|
||||
system design, and cross-functional team growth.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-[12px] font-mono text-purple-500 uppercase tracking-[0.2em] mb-2">
|
||||
Integrity
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-300 leading-tight">
|
||||
Experienced in{" "}
|
||||
<span className="italic">High-Stakes Environments</span>{" "}
|
||||
(Medical/Regulatory), QMS, and Cyber Essentials.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="text-[12px] font-mono text-green-500 uppercase tracking-[0.2em] mb-2">
|
||||
Infrastructure
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-300 leading-tight">
|
||||
Kubernetes, GCP, and automated CI/CD pipelines.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<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 */}
|
||||
<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 transition-all duration-300 min-h-[180px]"
|
||||
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>
|
||||
|
||||
{/* Middle Row: Web Systems */}
|
||||
<Link href="/projects/web" className="group md:col-span-2">
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-blue-500/30"
|
||||
>
|
||||
<div>
|
||||
<Globe className="text-blue-400 w-6 h-6 mb-4" />
|
||||
<h3 className="font-bold text-xl mb-2">Web Systems</h3>
|
||||
<p className="text-sm text-neutral-500 leading-relaxed">
|
||||
Architecting distributed platforms with a focus on
|
||||
high-availability and containerized deployment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{["Next.js", "Python", "Node.js", "Caddy", "PostgreSQL"].map(
|
||||
(tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase group-hover:text-blue-400 group-hover:border-blue-500/20 transition-all"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
|
||||
{/* Middle Row: Mobile Apps */}
|
||||
<Link href="/projects/mobile" className="group md:col-span-2">
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-purple-500/30"
|
||||
>
|
||||
<div>
|
||||
<Smartphone className="text-purple-400 w-6 h-6 mb-4" />
|
||||
<h3 className="font-bold text-xl mb-2">Mobile Apps</h3>
|
||||
<p className="text-sm text-neutral-500 leading-relaxed">
|
||||
Building fluid, cross-platform experiences using reactive
|
||||
state and native hardware integration.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{["Android", "iOS", "Flutter", "Riverpod", "Stores"].map(
|
||||
(tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase group-hover:text-purple-400 group-hover:border-purple-500/20 transition-all"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* Middle Row: DevOps */}
|
||||
<Link href="/infrastructure" className="group md:col-span-2">
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
className="p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors hover:border-green-500/30"
|
||||
>
|
||||
<div>
|
||||
<Server className="text-green-400 w-6 h-6 mb-4" />
|
||||
<h3 className="font-bold text-xl mb-2">Infrastructure</h3>
|
||||
<p className="text-sm text-neutral-500 leading-relaxed">
|
||||
Architecting resilient cloud environments with automated IaC,
|
||||
multi-region orchestration, and high-integrity security
|
||||
protocols.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{["Kubernetes", "Terraform", "GCP", "CI/CD", "Security"].map(
|
||||
(tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="text-[9px] font-mono text-neutral-600 border border-neutral-800 px-2 py-1 rounded-md uppercase group-hover:text-green-400 group-hover:border-green-500/20 transition-all"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
<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 */}
|
||||
<div className="md:col-span-6 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex items-center gap-6 group hover:border-orange-500/30 transition-colors cursor-pointer">
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="md:col-span-6 p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 flex items-center gap-6 group hover:border-orange-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="p-4 bg-orange-500/10 rounded-2xl border border-orange-500/20 group-hover:border-orange-500/40 transition-colors">
|
||||
<Gamepad2 className="text-orange-500 w-8 h-8" />
|
||||
</div>
|
||||
|
|
@ -221,30 +181,68 @@ export default function Home() {
|
|||
Indie Game Dev & Creative Prototypes
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="mt-12 pt-8 border-t border-neutral-900 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] text-neutral-600 font-mono tracking-wider uppercase">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<p>Pipeline Status</p>
|
||||
<img
|
||||
src="https://ci.georgew.dev/api/badges/11/status.svg"
|
||||
alt="Build Status"
|
||||
className="h-3 grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" />
|
||||
<p>Engine: Next.js 15 (Standalone)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-neutral-900/50 px-3 py-1 rounded-full border border-neutral-800/50">
|
||||
<div className="w-1 h-1 rounded-full bg-blue-500" />
|
||||
<p className="text-neutral-400">
|
||||
Deploy: {process.env.NEXT_PUBLIC_APP_VERSION || "v1.0.0-dev"}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</motion.div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalFocus({
|
||||
label,
|
||||
color,
|
||||
text,
|
||||
}: {
|
||||
label: string;
|
||||
color: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h4
|
||||
className={`text-[12px] font-mono ${color} uppercase tracking-[0.2em] mb-2`}
|
||||
>
|
||||
{label}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-300 leading-tight">{text}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryCard({
|
||||
href,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
tech,
|
||||
hoverColor,
|
||||
activeTechColor,
|
||||
}: CategoryCardProps) {
|
||||
return (
|
||||
<Link href={href} className="group md:col-span-2">
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5 }}
|
||||
className={`p-6 rounded-3xl bg-neutral-900 border border-neutral-800 h-full flex flex-col justify-between min-h-[260px] relative transition-colors ${hoverColor}`}
|
||||
>
|
||||
<div>
|
||||
{icon}
|
||||
<h3 className="font-bold text-xl mb-2">{title}</h3>
|
||||
<p className="text-sm text-neutral-500 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-6">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Github,
|
||||
ShieldCheck,
|
||||
Cpu,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { 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,
|
||||
|
|
@ -26,30 +40,31 @@ export default function ProjectDetail({
|
|||
const { category, slug } = use(params);
|
||||
const project = PROJECT_REGISTRY.find((p) => p.slug === slug);
|
||||
|
||||
if (!project) return <div>Project Not Found</div>;
|
||||
|
||||
if (!project)
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="p-24 text-white font-mono">Project Log Not Found.</div>
|
||||
<PageLayout backLink={`/projects/${category}`}>
|
||||
<div className="font-mono text-white">Project Log Not Found.</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a0a0a] text-white p-6 md:p-12 lg:p-24">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Navigation */}
|
||||
<Link
|
||||
href={`/projects/${category}`}
|
||||
className="flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-[10px] uppercase tracking-widest"
|
||||
<PageLayout
|
||||
backLink={`/projects/${category}`}
|
||||
backLabel={`Back to ${category}`}
|
||||
maxWidth="6xl"
|
||||
>
|
||||
<ArrowLeft size={12} /> Back to {category}
|
||||
</Link>
|
||||
|
||||
{/* Header Section */}
|
||||
<header className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
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>
|
||||
|
|
@ -64,89 +79,66 @@ export default function ProjectDetail({
|
|||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
className="flex items-center gap-2 bg-white text-black px-6 py-3 rounded-full font-bold text-sm hover:bg-neutral-200 transition-all"
|
||||
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-all"
|
||||
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>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Stats Sidebar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="bg-neutral-900/30 border border-neutral-800 p-8 rounded-3xl h-fit"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<ShieldCheck className="text-blue-500" />
|
||||
<div>
|
||||
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
|
||||
My Role
|
||||
</p>
|
||||
<p className="text-sm font-semibold">{project.role}</p>
|
||||
<div 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>
|
||||
<div className="flex items-center gap-4">
|
||||
<Cpu className="text-purple-500" />
|
||||
<div>
|
||||
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
|
||||
Stack
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{project.stack.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Users className="text-green-500" />
|
||||
<div>
|
||||
<p className="text-[10px] text-neutral-500 uppercase font-mono tracking-tighter">
|
||||
Impact
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{project.metrics.join(" • ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</header>
|
||||
</motion.header>
|
||||
|
||||
<section className="mb-20">
|
||||
{/* Media Showcase */}
|
||||
<motion.section variants={sectionVariants} className="mb-20">
|
||||
{project.category === "mobile" ? (
|
||||
<MobileStack images={project.images} />
|
||||
) : (
|
||||
<>
|
||||
{/* Display on large screens */}
|
||||
<div className="hidden lg:block">
|
||||
<ProjectShowcase images={project.images} />
|
||||
</div>
|
||||
{/* Display on small screens */}
|
||||
<div className="block lg:hidden">
|
||||
<ImageCarousel images={project.images} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-[10px] font-mono text-neutral-600 uppercase tracking-[0.2em]">
|
||||
<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>
|
||||
</section>
|
||||
</motion.section>
|
||||
|
||||
{/* Mermaid */}
|
||||
{/* System Architecture */}
|
||||
{project.mermaidChart && (
|
||||
<section className="mb-16">
|
||||
<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]">
|
||||
|
|
@ -154,15 +146,17 @@ export default function ProjectDetail({
|
|||
</h3>
|
||||
<div className="h-px flex-1 bg-neutral-900" />
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12">
|
||||
<Mermaid chart={project.mermaidChart} />
|
||||
</div>
|
||||
</section>
|
||||
</motion.section>
|
||||
)}
|
||||
|
||||
{/* Engineering Narrative */}
|
||||
<section className="w-full pb-20 mt-12">
|
||||
<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"}
|
||||
|
|
@ -172,12 +166,34 @@ export default function ProjectDetail({
|
|||
The Engineering Story
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-invert prose-neutral max-w-none text-left">
|
||||
<div className="prose prose-invert prose-neutral max-w-none">
|
||||
<ReactMarkdown>{project.engineeringStory}</ReactMarkdown>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { Globe, Smartphone, ArrowLeft, Server } from "lucide-react";
|
||||
|
||||
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: {
|
||||
|
|
@ -26,6 +28,27 @@ const CATEGORY_META = {
|
|||
},
|
||||
};
|
||||
|
||||
// 1. Cast the container variants
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
// 2. Cast the item variants
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeOut", // TypeScript now knows this is a valid Easing string
|
||||
},
|
||||
},
|
||||
};
|
||||
export default function CategoryPage({
|
||||
params,
|
||||
}: {
|
||||
|
|
@ -33,78 +56,59 @@ export default function CategoryPage({
|
|||
}) {
|
||||
const resolvedParams = use(params);
|
||||
const category = resolvedParams.category;
|
||||
|
||||
const meta = CATEGORY_META[category as keyof typeof CATEGORY_META];
|
||||
|
||||
const filteredProjects = PROJECT_REGISTRY.filter(
|
||||
(p) => p.category === category,
|
||||
);
|
||||
|
||||
if (!meta) {
|
||||
return (
|
||||
<div className="p-24 text-white font-mono">
|
||||
<h1 className="text-2xl mb-4">404: Category Not Found</h1>
|
||||
<Link href="/" className="text-blue-400 underline mt-4 block">
|
||||
Return Home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!meta) return <PageLayout backLink="/">Sector not found.</PageLayout>;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a0a0a] text-white p-8 md:p-24">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-neutral-500 hover:text-white transition-colors mb-12 font-mono text-xs"
|
||||
>
|
||||
<ArrowLeft size={14} /> BACK TO DASHBOARD
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-5xl mx-auto"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
{meta.icon}
|
||||
<h1 className="text-5xl font-bold">{meta.title}</h1>
|
||||
</div>
|
||||
<p className="text-xl text-neutral-400 max-w-2xl mb-16">
|
||||
<PageLayout backLink="/" maxWidth="5xl">
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{filteredProjects.map((project, index) => (
|
||||
{/* 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
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{
|
||||
delay: index * 0.1,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="group p-8 rounded-3xl bg-neutral-900/50 border border-neutral-800 hover:border-neutral-700 transition-colors"
|
||||
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-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-2">
|
||||
<h3 className="text-2xl font-bold mb-2 group-hover:text-blue-400 transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="text-neutral-500">{project.description}</p>
|
||||
<p className="text-neutral-500 text-sm max-w-xl">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.stack.map((s) => (
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
{project.stack.map((tech) => (
|
||||
<span
|
||||
key={s}
|
||||
className="text-[10px] font-mono bg-neutral-800 text-neutral-400 px-3 py-1 rounded-full uppercase"
|
||||
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"
|
||||
>
|
||||
{s}
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -112,8 +116,7 @@ export default function CategoryPage({
|
|||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
27
components/Footer.tsx
Normal file
27
components/Footer.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
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>
|
||||
<img
|
||||
src="https://ci.georgew.dev/api/badges/11/status.svg"
|
||||
alt="Build Status"
|
||||
className="h-3 grayscale opacity-50 hover:opacity-100 hover:grayscale-0 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block w-[1px] h-3 bg-neutral-800" />
|
||||
<p>Engine: Next.js 15 (Standalone)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-neutral-900/50 px-3 py-1 rounded-full border border-neutral-800/50">
|
||||
<div className="w-1 h-1 rounded-full bg-blue-500" />
|
||||
<p className="text-neutral-400">
|
||||
Deploy: {process.env.NEXT_PUBLIC_APP_VERSION || "v1.0.0-dev"}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import mermaid from "mermaid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
|
||||
export default function Mermaid({ chart }: { chart: string }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "dark",
|
||||
securityLevel: "loose",
|
||||
fontFamily: "monospace",
|
||||
});
|
||||
|
||||
// Render the chart once. Use a timeout to ensure DOM is stable.
|
||||
const renderChart = async () => {
|
||||
try {
|
||||
await mermaid.contentLoaded();
|
||||
setIsRendered(true);
|
||||
} catch (err) {
|
||||
console.error("Mermaid render failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
renderChart();
|
||||
}, [chart]); // Only re-run if the chart string itself changes
|
||||
|
||||
return (
|
||||
<div className="relative max-w-4xl mx-auto group">
|
||||
<motion.div
|
||||
layout
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isExpanded ? "auto" : "400px",
|
||||
}}
|
||||
transition={{ duration: 0.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={`relative bg-neutral-900/20 rounded-3xl border border-neutral-800/50 overflow-hidden transition-colors duration-500 ${
|
||||
!isExpanded
|
||||
? "hover:border-neutral-700 cursor-pointer"
|
||||
: "cursor-default"
|
||||
}`}
|
||||
onClick={() => !isExpanded && setIsExpanded(true)}
|
||||
>
|
||||
{/* Legend */}
|
||||
<div className="absolute top-6 right-8 flex flex-col gap-2 z-20 bg-black/40 backdrop-blur-md p-3 rounded-xl border border-white/5 pointer-events-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
|
||||
Traffic Flow
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
|
||||
Service Node
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Area */}
|
||||
<div
|
||||
className={`p-4 md:p-12 transition-opacity duration-500 ${isRendered ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<div className="mermaid flex justify-center">{chart}</div>
|
||||
</div>
|
||||
|
||||
{/* Fade Overlay */}
|
||||
<AnimatePresence>
|
||||
{!isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/90 to-transparent pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className={`absolute -bottom-5 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-5 py-2.5 rounded-full text-[10px] font-mono uppercase tracking-[0.2em] transition-all border shadow-xl
|
||||
${
|
||||
isExpanded
|
||||
? "bg-neutral-800 border-neutral-700 text-white"
|
||||
: "bg-neutral-900 border-neutral-800 text-neutral-500 group-hover:bg-neutral-800 group-hover:border-neutral-600 group-hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<Minimize2 size={12} /> Collapse Logic
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Maximize2 size={12} /> Expand Architecture
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
"use client";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import mermaid from "mermaid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
|
||||
export default function Mermaid({ chart }: { chart: string }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [needsExpansion, setNeedsExpansion] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: "dark",
|
||||
securityLevel: "loose",
|
||||
fontFamily: "monospace",
|
||||
});
|
||||
mermaid.contentLoaded();
|
||||
|
||||
if (contentRef.current) {
|
||||
const height = contentRef.current.scrollHeight;
|
||||
setNeedsExpansion(height > 400);
|
||||
}
|
||||
}, [chart]);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-4xl mx-auto group">
|
||||
<motion.div
|
||||
initial={false}
|
||||
onClick={() => needsExpansion && setIsExpanded(!isExpanded)}
|
||||
animate={{ height: isExpanded || !needsExpansion ? "auto" : "400px" }}
|
||||
className={`relative bg-neutral-900/20 rounded-3xl border border-neutral-800/50 p-4 md:p-12 overflow-hidden transition-colors duration-500
|
||||
${needsExpansion && !isExpanded ? "cursor-pointer hover:border-neutral-700" : "cursor-default"}`}
|
||||
>
|
||||
{/* Legend */}
|
||||
<div className="absolute top-6 right-8 flex flex-col gap-2 z-20 bg-black/40 backdrop-blur-md p-3 rounded-xl border border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
|
||||
Traffic Flow
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-[9px] font-mono text-neutral-400 uppercase tracking-wider">
|
||||
Service Node
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={contentRef} className="mermaid flex justify-center">
|
||||
{chart}
|
||||
</div>
|
||||
|
||||
{/* The "Fade to Darkness" Overlay (when expansion is needed) */}
|
||||
<AnimatePresence>
|
||||
{needsExpansion && !isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/80 to-transparent pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Expand/Collapse Button (when expansion is needed) */}
|
||||
{needsExpansion && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className={`absolute -bottom-5 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 px-5 py-2.5 rounded-full text-[10px] font-mono uppercase tracking-[0.2em] transition-all border shadow-xl
|
||||
${
|
||||
isExpanded
|
||||
? "bg-neutral-800 border-neutral-700 text-white"
|
||||
: "bg-neutral-900 border-neutral-800 text-neutral-500 group-hover:bg-neutral-800 group-hover:border-neutral-600 group-hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
{" "}
|
||||
<Minimize2 size={12} /> Collapse Logic{" "}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
<Maximize2 size={12} /> Expand Architecture{" "}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { MobileFrame } from "./MobileFrame";
|
||||
|
||||
export default function MobileStack({ images }: { images: string[] }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const DRAG_THRESHOLD = -150;
|
||||
|
||||
const getRelativeIndex = (index: number) => {
|
||||
const len = images.length;
|
||||
return (index - currentIndex + len) % len;
|
||||
};
|
||||
|
||||
const next = () => setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
|
||||
return (
|
||||
<div className="relative h-[750px] w-full flex flex-col items-center py-20 overflow-hidden group">
|
||||
<div className="relative h-[650px] w-full flex justify-center items-center">
|
||||
<AnimatePresence initial={false}>
|
||||
{images.map((img, index) => {
|
||||
const relIndex = getRelativeIndex(index);
|
||||
const isTop = relIndex === 0;
|
||||
|
||||
const xOffset = relIndex * 90;
|
||||
|
||||
if (relIndex > 5) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={img}
|
||||
style={{ zIndex: images.length - relIndex }}
|
||||
initial={{ opacity: 0, x: 400 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: isTop ? 0 : xOffset,
|
||||
scale: isTop ? 1 : 0.96,
|
||||
filter: isTop ? "brightness(1)" : "brightness(0.4)",
|
||||
pointerEvents: isTop ? "auto" : "all",
|
||||
}}
|
||||
exit={{
|
||||
x: -1000,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.4, ease: "easeIn" },
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
whileHover={
|
||||
!isTop ? { scale: 0.98, filter: "brightness(0.6)" } : {}
|
||||
}
|
||||
drag={isTop ? "x" : false}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.8}
|
||||
onDrag={(_, info) => {
|
||||
if (isTop && info.offset.x < DRAG_THRESHOLD) {
|
||||
next();
|
||||
}
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
// Backup check for quick flicks
|
||||
if (isTop && info.offset.x < -100) {
|
||||
next();
|
||||
}
|
||||
}}
|
||||
onClick={() => !isTop && setCurrentIndex(index)}
|
||||
className="absolute"
|
||||
>
|
||||
<div
|
||||
className={`${isTop ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"}`}
|
||||
>
|
||||
<MobileFrame>
|
||||
<img
|
||||
src={img}
|
||||
alt="App Screenshot"
|
||||
draggable="false"
|
||||
className="w-full h-full object-cover select-none"
|
||||
/>
|
||||
</MobileFrame>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Button */}
|
||||
<div className="absolute -bottom-12 z-[100]">
|
||||
<button
|
||||
onClick={next}
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-full bg-black/40 backdrop-blur-xl border border-white/5 text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 group-hover:translate-y-[-20px]"
|
||||
>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.2em]">
|
||||
Next Screen
|
||||
</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
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" },
|
||||
|
|
@ -20,39 +19,13 @@ const MONITORS = [
|
|||
{ id: 4, name: "Watchtower" },
|
||||
];
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
|
||||
const [page, setPage] = useState(0);
|
||||
const totalPages = Math.ceil(MONITORS.length / ITEMS_PER_PAGE);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (isHovered && totalPages > 1) {
|
||||
interval = setInterval(() => {
|
||||
setPage((prev) => (prev + 1) % totalPages);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
if (!isHovered) {
|
||||
setPage(0);
|
||||
}
|
||||
};
|
||||
}, [isHovered, totalPages]);
|
||||
|
||||
const currentMonitors = MONITORS.slice(
|
||||
page * ITEMS_PER_PAGE,
|
||||
(page + 1) * ITEMS_PER_PAGE,
|
||||
);
|
||||
|
||||
export default function MonitorCard({ isHovered }: { isHovered: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{/* Default View */}
|
||||
<div className="flex min-h-[250px] flex-col justify-center w-full group-hover:opacity-0 group-hover:pointer-events-none transition-all duration-300">
|
||||
{/* Header Section */}
|
||||
<div className="relative w-full h-full min-h-[180px] flex flex-col justify-center">
|
||||
{/* DEFAULT VIEW: Always rendered, opacity controlled by parent hover */}
|
||||
<div
|
||||
className={`transition-opacity duration-300 ${isHovered ? "opacity-0" : "opacity-100"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
|
@ -61,12 +34,9 @@ export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
|
|||
Hetzner Node-01
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<p className="text-xs text-neutral-500 font-mono">SYS_STATUS:</p>
|
||||
<span className="text-[10px] bg-green-500/10 text-green-500 border border-green-500/20 px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -87,33 +57,65 @@ export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover View */}
|
||||
<div className="absolute inset-0 p-5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col bg-neutral-900/95 backdrop-blur-sm">
|
||||
<div className="flex justify-between items-center mb-2 px-1">
|
||||
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em]">
|
||||
{/* REGISTRY VIEW: Only "Active" when hovered */}
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<h4 className="text-[10px] font-mono text-neutral-500 uppercase tracking-[0.2em] mb-3">
|
||||
Service Registry
|
||||
</h4>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* We pass isHovered here to start/stop the timer inside a separate lifecycle */}
|
||||
<RegistrySlider isHovered={isHovered} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex-1 relative min-h-0 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={page}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="grid grid-cols-1 gap-2"
|
||||
key={page} // Key change: this triggers the animation on page change
|
||||
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"
|
||||
>
|
||||
{currentMonitors.map((m) => (
|
||||
{currentItems.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between bg-neutral-800/30 p-1.5 px-3 rounded-lg border border-neutral-700/30"
|
||||
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-[12px] font-medium text-neutral-300 truncate mr-2">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate mr-2">
|
||||
{m.name}
|
||||
</span>
|
||||
<div className="flex gap-1 shrink-0 scale-90 origin-right">
|
||||
<div className="flex gap-1 shrink-0 scale-75 origin-right">
|
||||
<img
|
||||
src={`https://status.georgew.dev/api/badge/${m.id}/status`}
|
||||
className="h-5"
|
||||
|
|
@ -130,7 +132,5 @@ export default function MonitorRegistry({ isHovered }: { isHovered: boolean }) {
|
|||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
50
components/PageLayout.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Project } from "@/types/project";
|
||||
import { Project } from "@/types/index";
|
||||
|
||||
export const PROJECT_REGISTRY: Project[] = [
|
||||
{
|
||||
|
|
|
|||
37
types/index.ts
Normal file
37
types/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue