(chore) init commit and add tasks
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface AnimatedRolesProps {
|
||||
roles: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AnimatedRoles({ roles, className }: AnimatedRolesProps) {
|
||||
const animations = [
|
||||
{
|
||||
initial: { opacity: 0, x: -20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
},
|
||||
{
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
},
|
||||
{
|
||||
initial: { opacity: 0, x: 20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
},
|
||||
{
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{roles.map((role, index) => {
|
||||
const animation = animations[index % animations.length]
|
||||
return (
|
||||
<motion.span
|
||||
key={index}
|
||||
initial={animation.initial}
|
||||
animate={animation.animate}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay: 0.2 + index * 0.15,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className="inline-block"
|
||||
>
|
||||
{role}
|
||||
{index < roles.length - 1 && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 + index * 0.15 + 0.3 }}
|
||||
className="mx-2"
|
||||
>
|
||||
•
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface AvatarWithBlobProps {
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export function AvatarWithBlob({ roles }: AvatarWithBlobProps) {
|
||||
// Floating animation variants for each role blob
|
||||
const getBlobVariants = (index: number) => ({
|
||||
animate: {
|
||||
y: [0, -20, 0],
|
||||
x: [0, Math.cos((index * 2 * Math.PI) / roles.length) * 10, 0],
|
||||
rotate: [0, 5, -5, 0],
|
||||
transition: {
|
||||
duration: 4 + index * 0.5,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-md mx-auto aspect-square flex items-center justify-center">
|
||||
{/* Animated background blobs */}
|
||||
<div className="absolute inset-0">
|
||||
{/* Center circle glow */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
opacity: [0.3, 0.5, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
}}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-full blur-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Avatar center */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="relative z-10 w-32 h-32 mx-auto"
|
||||
>
|
||||
<div className="w-full h-full rounded-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-4xl border-4 border-white dark:border-gray-950 shadow-lg">
|
||||
RL
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Floating role blobs */}
|
||||
{roles.map((role, index) => {
|
||||
const angle = (index * 2 * Math.PI) / roles.length
|
||||
const radius = 120
|
||||
const x = Math.cos(angle) * radius
|
||||
const y = Math.sin(angle) * radius
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={role}
|
||||
variants={getBlobVariants(index)}
|
||||
animate="animate"
|
||||
className="absolute z-20"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
marginLeft: x,
|
||||
marginTop: y,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4 + index * 0.15 }}
|
||||
className="px-4 py-2 rounded-full bg-white dark:bg-gray-900 border-2 border-blue-400 dark:border-blue-600 shadow-lg whitespace-nowrap text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{role}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface TimelineItem {
|
||||
company: string
|
||||
role: string
|
||||
period: string
|
||||
description: string
|
||||
impact: string
|
||||
skills: string[]
|
||||
color: string
|
||||
}
|
||||
|
||||
const timeline: TimelineItem[] = [
|
||||
{
|
||||
company: 'RBC',
|
||||
role: 'Infrastructure Platform Architect',
|
||||
period: '2020 — Present',
|
||||
description: 'Leading multi-region infrastructure platform on Kubernetes. Building GitOps workflows with Terraform and Argo CD across 4 global regions.',
|
||||
impact: '40% CPU reduction, 99.2% uptime',
|
||||
skills: ['Kubernetes', 'Terraform', 'Argo CD', 'Go', 'AWS', 'Cilium', 'Prometheus', 'Grafana'],
|
||||
color: 'from-blue-500 to-blue-600',
|
||||
},
|
||||
{
|
||||
company: 'AWS',
|
||||
role: 'Senior Software Engineer (Step Functions)',
|
||||
period: '2019 — 2020',
|
||||
description: 'Designed and optimized distributed orchestration platform. Built fault-tolerant task scheduling across 57+ regions serving mission-critical workloads.',
|
||||
impact: '8 core components, 57+ regions',
|
||||
skills: ['AWS', 'Java', 'gRPC', 'Distributed Systems', 'DynamoDB', 'CloudWatch'],
|
||||
color: 'from-orange-500 to-orange-600',
|
||||
},
|
||||
{
|
||||
company: 'Homelab',
|
||||
role: 'DevOps / SRE / SDE',
|
||||
period: '2021 — Present',
|
||||
description: 'Production-grade bare-metal K8s cluster (Talos + Cilium). Built Temporal-based LLM inference pipeline with CPU-bound optimization and quantization.',
|
||||
impact: '60% latency cut, 75% memory saved',
|
||||
skills: ['LangGraph', 'Temporal', 'LLM Ops', 'PyTorch', 'Kafka', 'Observability'],
|
||||
color: 'from-green-500 to-green-600',
|
||||
},
|
||||
{
|
||||
company: 'Open Source',
|
||||
role: 'Maintainer — go-flink',
|
||||
period: '2022 — Present',
|
||||
description: 'Building distributed DataLakeHouse framework in Go. Fault-tolerant task scheduling, streaming semantics, and efficient data processing.',
|
||||
impact: 'Public repository, active development',
|
||||
skills: ['Go', 'Distributed Systems', 'Data Pipeline', 'Testing', 'Architecture'],
|
||||
color: 'from-purple-500 to-purple-600',
|
||||
},
|
||||
]
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.6, ease: 'easeOut' },
|
||||
},
|
||||
}
|
||||
|
||||
export function ExperienceTimeline() {
|
||||
return (
|
||||
<section className="max-w-4xl mx-auto px-6 py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-16"
|
||||
>
|
||||
<h2 className="text-4xl font-bold mb-4">Explore Experience</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
From AWS to RBC. Building systems at scale. Skills acquired along the journey.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-0 md:left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-blue-500 via-purple-500 to-green-500 md:transform md:-translate-x-1/2" />
|
||||
|
||||
{/* Timeline items */}
|
||||
<div className="space-y-12">
|
||||
{timeline.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.company}
|
||||
variants={itemVariants}
|
||||
className="relative pl-8 md:pl-0"
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div className={`absolute left-0 md:left-1/2 top-2 w-4 h-4 rounded-full bg-gradient-to-br ${item.color} transform md:-translate-x-1/2 -translate-x-1.5 border-4 border-white dark:border-gray-950 shadow-lg`} />
|
||||
|
||||
{/* Content card */}
|
||||
<div className={index % 2 === 0 ? 'md:w-1/2 md:pr-8' : 'md:w-1/2 md:ml-auto md:pl-8'}>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{item.company}
|
||||
</h3>
|
||||
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
{item.role}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
|
||||
{item.period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 rounded px-3 py-2 mb-4 text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
📊 {item.impact}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.skills.map((skill) => (
|
||||
<span key={skill} className="text-xs px-3 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { LiveIndicator } from './LiveIndicator'
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
stat?: string
|
||||
status?: 'live' | 'offline' | 'pending'
|
||||
}
|
||||
|
||||
export function FeatureCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
stat,
|
||||
status = 'live',
|
||||
}: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Link href={href}>
|
||||
<motion.div
|
||||
whileHover={{ y: -8, boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)' }}
|
||||
className="h-full border border-gray-200 dark:border-gray-700 rounded-lg p-6 hover:shadow-lg transition-shadow cursor-pointer bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<Icon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
<LiveIndicator status={status} />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{description}
|
||||
</p>
|
||||
{stat && (
|
||||
<div className="text-xs font-mono text-blue-600 dark:text-blue-400">
|
||||
{stat}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Menu, X, Moon, Sun } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function Header() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dark, setDark] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
setDark(isDark)
|
||||
}, [])
|
||||
|
||||
const toggleDark = () => {
|
||||
document.documentElement.classList.toggle('dark')
|
||||
setDark(!dark)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-gray-200 dark:border-gray-800 bg-white/95 dark:bg-gray-950/95 backdrop-blur">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex justify-between items-center">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
RL
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white">Rock Liang</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">Software Engineer Lead</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex gap-8 items-center">
|
||||
<Link href="/" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Home</Link>
|
||||
<Link href="/cluster" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Cluster</Link>
|
||||
<Link href="/infrastructure" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Infrastructure</Link>
|
||||
<Link href="/kafka" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Kafka</Link>
|
||||
<Link href="/cicd" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">CI/CD</Link>
|
||||
<Link href="/agent" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Agent</Link>
|
||||
<button onClick={toggleDark} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded">
|
||||
{dark ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="md:hidden p-2"
|
||||
>
|
||||
{open ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="md:hidden border-t border-gray-200 dark:border-gray-800 p-4 space-y-2">
|
||||
<Link href="/" className="block py-2 text-sm hover:text-blue-600">Home</Link>
|
||||
<Link href="/infrastructure" className="block py-2 text-sm hover:text-blue-600">Infrastructure</Link>
|
||||
<Link href="/kafka" className="block py-2 text-sm hover:text-blue-600">Kafka</Link>
|
||||
<Link href="/cicd" className="block py-2 text-sm hover:text-blue-600">CI/CD</Link>
|
||||
<Link href="/agent" className="block py-2 text-sm hover:text-blue-600">Agent</Link>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Code, Network, Zap, Sparkles, Github } from 'lucide-react'
|
||||
import { TypewriterText } from './TypewriterText'
|
||||
|
||||
const roles = [
|
||||
{ text: 'Software Engineer Lead', icon: Code },
|
||||
{ text: 'Infrastructure Architect', icon: Network },
|
||||
{ text: 'Distributed Systems Builder', icon: Zap },
|
||||
{ text: 'LLM Systems Optimizer', icon: Sparkles },
|
||||
{ text: 'Open Source Developer', icon: Github },
|
||||
]
|
||||
|
||||
export function HeroBlobFlow() {
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % roles.length)
|
||||
}, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-md mx-auto aspect-square flex flex-col items-center justify-center">
|
||||
{/* Background glow */}
|
||||
<div className="absolute inset-0">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
opacity: [0.25, 0.4, 0.25],
|
||||
}}
|
||||
transition={{
|
||||
duration: 6,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-full blur-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Avatar center */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="relative z-10 w-32 h-32"
|
||||
>
|
||||
<div className="w-full h-full rounded-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-4xl border-4 border-white dark:border-gray-950 shadow-xl">
|
||||
RL
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Typewriter text below avatar */}
|
||||
<TypewriterText />
|
||||
|
||||
{/* Rotating role text */}
|
||||
<div className="relative z-10 mt-12 px-6 w-full flex items-center justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
className="absolute text-2xl font-semibold text-gray-900 dark:text-white text-center px-6 flex items-center gap-3 justify-center"
|
||||
>
|
||||
{(() => {
|
||||
const Icon = roles[index].icon
|
||||
return <Icon size={28} className="text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
})()}
|
||||
{roles[index].text}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const commands: Record<string, string> = {
|
||||
help: `Available commands:
|
||||
ls - List services
|
||||
kubectl - Cluster info
|
||||
terraform - Infrastructure
|
||||
argocd - Deployments
|
||||
kafka - Message broker
|
||||
about - About me
|
||||
clear - Clear terminal`,
|
||||
ls: `storage/
|
||||
monitoring/
|
||||
cicd/
|
||||
sqs/
|
||||
temporal/
|
||||
iam/`,
|
||||
kubectl: `Nodes: 3 (talos-cp-1, talos-worker-1, talos-worker-2)
|
||||
Pods: 42 running
|
||||
Uptime: 99.2%
|
||||
Cluster Version: v1.36.1`,
|
||||
terraform: `Modules: 18 deployed
|
||||
Services: Kafka, PostgreSQL, MinIO, Grafana
|
||||
Storage: Longhorn (3-replica)
|
||||
Network: Cilium eBPF CNI`,
|
||||
argocd: `Applications: 12
|
||||
Synced: 11/12
|
||||
Last Sync: 2 min ago
|
||||
Health: Healthy`,
|
||||
kafka: `Brokers: 3 (KRaft)
|
||||
Replication Factor: 3
|
||||
Min ISR: 2
|
||||
Topics: 5 active
|
||||
Throughput: ~1K msgs/sec`,
|
||||
about: `Rock Liang - Senior Full-Stack Systems Engineer
|
||||
Experience: 5+ years (AWS, RBC, Homelab)
|
||||
Focus: Infrastructure × Backend × LLM Systems
|
||||
Tech: Go, Java, Python, C++ | Kubernetes, Terraform, gRPC
|
||||
Current: Building production homelab + LLM inference optimization
|
||||
Open Source: go-flink (distributed DataLakeHouse)`,
|
||||
}
|
||||
|
||||
export function InteractiveTerminal() {
|
||||
const [input, setInput] = useState('')
|
||||
const [history, setHistory] = useState<{ cmd: string; output: string }[]>([])
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen])
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
const trimmed = cmd.trim().toLowerCase()
|
||||
if (trimmed === 'clear') {
|
||||
setHistory([])
|
||||
setInput('')
|
||||
return
|
||||
}
|
||||
|
||||
const output = commands[trimmed] || `command not found: ${cmd}`
|
||||
setHistory([...history, { cmd, output }])
|
||||
setInput('')
|
||||
|
||||
setTimeout(() => {
|
||||
terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed bottom-6 right-6 z-40"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
>
|
||||
{!isOpen ? (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-mono text-sm shadow-lg"
|
||||
>
|
||||
Terminal (Cmd+K)
|
||||
</button>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gray-950 border border-gray-700 rounded-lg shadow-2xl w-96 h-96 flex flex-col"
|
||||
>
|
||||
<div className="bg-gray-900 border-b border-gray-700 px-4 py-3 flex justify-between items-center">
|
||||
<span className="text-gray-300 font-mono text-xs">ZSH_THEME="robbyrussell"</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-y-auto px-4 py-3 font-mono text-sm text-gray-200 space-y-2"
|
||||
>
|
||||
{history.length === 0 && (
|
||||
<div className="text-gray-500">type 'help' for commands</div>
|
||||
)}
|
||||
{history.map((entry, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-green-400"><span className="text-red-400">➜</span> % {entry.cmd}</div>
|
||||
<div className="text-gray-300 whitespace-pre-wrap text-xs mt-1">
|
||||
{entry.output}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center">
|
||||
<span className="text-red-400 font-mono text-sm">➜</span>
|
||||
<span className="text-green-400 font-mono text-sm ml-2">% </span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') handleCommand(input)
|
||||
}}
|
||||
placeholder=""
|
||||
className="flex-1 bg-transparent text-green-400 font-mono text-sm outline-none ml-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface LiveIndicatorProps {
|
||||
status: 'live' | 'offline' | 'pending'
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function LiveIndicator({ status, label }: LiveIndicatorProps) {
|
||||
const colors = {
|
||||
live: 'bg-green-500',
|
||||
offline: 'bg-red-500',
|
||||
pending: 'bg-yellow-500',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
live: 'Live',
|
||||
offline: 'Offline',
|
||||
pending: 'Pending',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={status === 'live' ? { scale: [1, 1.3, 1] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className={`w-2 h-2 rounded-full ${colors[status]}`}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{label || labels[status]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface ProgressiveTextProps {
|
||||
texts: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ProgressiveText({ texts, className }: ProgressiveTextProps) {
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: (i = 1) => ({
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.12, delayChildren: 0.04 * i },
|
||||
}),
|
||||
}
|
||||
|
||||
const child = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{texts.map((text, i) => (
|
||||
<motion.div key={i} variants={child} className="inline">
|
||||
{text}
|
||||
{i < texts.length - 1 && ' '}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const phrases = [
|
||||
'a Senior Systems engineer',
|
||||
'Rock Liang',
|
||||
'a Senior Full-stack builder',
|
||||
'a Senior Infrastructure architect',
|
||||
'a Senior Backend SDE',
|
||||
]
|
||||
|
||||
export function TypewriterText() {
|
||||
const [currentPhrase, setCurrentPhrase] = useState(0)
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const phrase = phrases[currentPhrase]
|
||||
const delay = isDeleting ? 50 : 100
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!isDeleting) {
|
||||
if (displayText.length < phrase.length) {
|
||||
setDisplayText(phrase.substring(0, displayText.length + 1))
|
||||
} else {
|
||||
setTimeout(() => setIsDeleting(true), 1500)
|
||||
}
|
||||
} else {
|
||||
if (displayText.length > 0) {
|
||||
setDisplayText(phrase.substring(0, displayText.length - 1))
|
||||
} else {
|
||||
setIsDeleting(false)
|
||||
setCurrentPhrase((prev) => (prev + 1) % phrases.length)
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [displayText, isDeleting, currentPhrase])
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mt-6 h-10 flex items-center justify-center">
|
||||
<span className="text-2xl font-semibold text-gray-800 dark:text-gray-200">
|
||||
I'm <span className="text-blue-600 dark:text-blue-400">{displayText}</span>
|
||||
<motion.span
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ duration: 0.6, repeat: Infinity }}
|
||||
className="inline-block w-0.5 h-7 ml-1 bg-blue-600 dark:bg-blue-400"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
hint?: string
|
||||
right?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Instrument panel chrome: hairline frame, corner ticks, eyebrow label. */
|
||||
export function Panel({ label, hint, right, children, className = '' }: Props) {
|
||||
return (
|
||||
<section className={`relative border border-wire bg-deck ${className}`}>
|
||||
<span className="pointer-events-none absolute -left-px -top-px h-2 w-2 border-l border-t border-wire-lit" />
|
||||
<span className="pointer-events-none absolute -right-px -top-px h-2 w-2 border-r border-t border-wire-lit" />
|
||||
<span className="pointer-events-none absolute -bottom-px -left-px h-2 w-2 border-b border-l border-wire-lit" />
|
||||
<span className="pointer-events-none absolute -bottom-px -right-px h-2 w-2 border-b border-r border-wire-lit" />
|
||||
|
||||
<header className="flex items-baseline justify-between gap-4 border-b border-wire px-4 py-2.5">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-data text-[11px] uppercase tracking-[0.18em] text-chalk">{label}</h2>
|
||||
{hint && <p className="font-data text-[10px] text-dim">{hint}</p>}
|
||||
</div>
|
||||
{right}
|
||||
</header>
|
||||
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const SURFACES = [
|
||||
{ code: 'B', href: '/cluster', label: 'Topology' },
|
||||
{ code: 'E', href: '/cluster/delivery', label: 'Delivery' },
|
||||
{ code: 'C', href: '/cluster/terminal', label: 'Terminal' },
|
||||
{ code: 'D', href: '/cluster/chat', label: 'Chat' },
|
||||
]
|
||||
|
||||
export function Rail() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Cluster surfaces"
|
||||
className="flex shrink-0 gap-px overflow-x-auto border-b border-wire bg-void md:w-16 md:flex-col md:overflow-visible md:border-b-0 md:border-r"
|
||||
>
|
||||
{SURFACES.map((s) => {
|
||||
const active = pathname === s.href
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
href={s.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={[
|
||||
'group flex flex-1 flex-row items-center gap-2 px-4 py-3 transition-colors md:flex-none md:flex-col md:gap-1 md:px-0 md:py-4',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-lamp',
|
||||
active ? 'bg-deck text-lamp' : 'text-dim hover:bg-riser hover:text-chalk',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="font-signage text-lg font-semibold leading-none">{s.code}</span>
|
||||
<span className="font-data text-[9px] uppercase tracking-[0.14em] md:[writing-mode:vertical-rl]">
|
||||
{s.label}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cluster, nodes } from '@/lib/clusterMock'
|
||||
import { SlotMeter } from './SlotMeter'
|
||||
|
||||
/** Fake liveness so the layout can be judged in motion. Replaced by SSE in Phase 2. */
|
||||
function useDrift() {
|
||||
const [age, setAge] = useState<number>(cluster.snapshotAge)
|
||||
const [slots, setSlots] = useState(3)
|
||||
|
||||
useEffect(() => {
|
||||
const tick = setInterval(() => setAge((a) => (a > 6 ? 0.4 : +(a + 0.9).toFixed(1))), 900)
|
||||
const churn = setInterval(() => setSlots(() => 1 + Math.floor(Math.random() * 5)), 3400)
|
||||
return () => {
|
||||
clearInterval(tick)
|
||||
clearInterval(churn)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { age, slots }
|
||||
}
|
||||
|
||||
export function Ribbon() {
|
||||
const { age, slots } = useDrift()
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-wire bg-void px-4 py-2.5 font-data text-[11px]">
|
||||
<span className="flex items-center gap-2 text-chalk">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-flux" />
|
||||
{cluster.distro}
|
||||
</span>
|
||||
<span className="text-dim">
|
||||
k8s <span className="text-chalk">{cluster.kubernetes}</span>
|
||||
</span>
|
||||
<span className="text-dim">
|
||||
nodes <span className="text-chalk">{nodes.length}</span>
|
||||
</span>
|
||||
<span className="text-dim">
|
||||
snapshot <span className="text-chalk">{age.toFixed(1)}s</span>
|
||||
</span>
|
||||
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<span className="uppercase tracking-[0.16em] text-dim">seq</span>
|
||||
<SlotMeter used={slots} />
|
||||
<span className="tabular-nums text-chalk">{slots}/8</span>
|
||||
</span>
|
||||
|
||||
<span className="border border-lamp/40 px-1.5 py-0.5 uppercase tracking-[0.16em] text-lamp">
|
||||
placeholder data
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { SEQUENCE_SLOTS, PUBLIC_SLOT_CAP } from '@/lib/clusterMock'
|
||||
|
||||
type Props = {
|
||||
used: number
|
||||
size?: 'sm' | 'lg'
|
||||
showCap?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The signature element. Eight discrete cells, one per vLLM sequence slot.
|
||||
* Cells past PUBLIC_SLOT_CAP are operator headroom and read as reserved.
|
||||
*/
|
||||
export function SlotMeter({ used, size = 'sm', showCap = false }: Props) {
|
||||
const cell = size === 'lg' ? 'h-6 w-3' : 'h-3 w-[7px]'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-[3px]" role="img" aria-label={`${used} of ${SEQUENCE_SLOTS} sequence slots in use`}>
|
||||
{Array.from({ length: SEQUENCE_SLOTS }, (_, i) => {
|
||||
const reserved = i >= PUBLIC_SLOT_CAP
|
||||
const active = i < used
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={[
|
||||
cell,
|
||||
'rounded-[1px] transition-colors duration-500',
|
||||
active ? 'bg-lamp' : reserved ? 'bg-wire' : 'bg-wire-lit/40',
|
||||
reserved && !active ? 'opacity-50' : '',
|
||||
].join(' ')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{showCap && (
|
||||
<span className="font-data text-[10px] uppercase tracking-widest text-dim">
|
||||
{used}/{PUBLIC_SLOT_CAP} public · {SEQUENCE_SLOTS} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user