feat: portfolio redesign with i18n, RBC project showcase, CI/CD
- Add i18n support (EN/ZH) with language toggle - Redesign header with education dropdown, contact copy buttons - Add RBC project showcase with bullet points, skills highlighting - Create terraform-drift placeholder page - Add ProjectShowcase component with media toggle (image/video) - Update experience timeline with action-driven descriptions - Add Docker + Forgejo CI workflow for image builds - Update K8s manifests for riotpiao.com ingress - Add DESIGN.md documenting design system patterns
This commit is contained in:
@@ -1,54 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
|
||||
interface TimelineItem {
|
||||
company: string
|
||||
role: string
|
||||
period: string
|
||||
description: string
|
||||
impact: string
|
||||
skills: string[]
|
||||
color: string
|
||||
}
|
||||
const colors = [
|
||||
'from-green-500 to-green-600',
|
||||
'from-red-500 to-red-600',
|
||||
'from-orange-500 to-orange-600',
|
||||
'from-cyan-500 to-cyan-600',
|
||||
'from-blue-500 to-blue-600',
|
||||
]
|
||||
|
||||
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 skills = [
|
||||
['LangGraph', 'Temporal', 'LLM Ops', 'PyTorch', 'Kafka', 'Observability', 'Go-Flink'],
|
||||
['Golang', 'Terraform', 'IaC', 'OpenShift', 'Docker', 'Distributed Systems', 'Grafana'],
|
||||
['AWS', 'Java', 'gRPC', 'Distributed Systems', 'DynamoDB', 'CloudWatch'],
|
||||
['Machine Learning', 'Golang', 'Anomaly Detection', 'C++', 'CMake', 'Data Security'],
|
||||
['Django', 'React', 'Agile', 'Web Development', 'Sonar', 'ATC Systems'],
|
||||
]
|
||||
|
||||
const links = [
|
||||
null,
|
||||
'#project-rbc', // RBC - link to RBC project showcase
|
||||
null,
|
||||
null,
|
||||
'https://plan.navcanada.ca/account/login/?next=/',
|
||||
]
|
||||
|
||||
const linkTexts = [
|
||||
'',
|
||||
'Learn more →',
|
||||
'',
|
||||
'',
|
||||
'View Product →',
|
||||
]
|
||||
|
||||
const containerVariants = {
|
||||
@@ -71,6 +55,9 @@ const itemVariants = {
|
||||
}
|
||||
|
||||
export function ExperienceTimeline() {
|
||||
const { t } = useLanguage()
|
||||
const timeline = t.experience.items
|
||||
|
||||
return (
|
||||
<section className="max-w-4xl mx-auto px-6 py-20">
|
||||
<motion.div
|
||||
@@ -80,9 +67,9 @@ export function ExperienceTimeline() {
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-16"
|
||||
>
|
||||
<h2 className="text-4xl font-bold mb-4">Explore Experience</h2>
|
||||
<h2 className="text-4xl font-bold mb-4">{t.experience.title}</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.
|
||||
{t.experience.subtitle}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -105,7 +92,7 @@ export function ExperienceTimeline() {
|
||||
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`} />
|
||||
<div className={`absolute left-0 md:left-1/2 top-2 w-4 h-4 rounded-full bg-gradient-to-br ${colors[index]} 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'}>
|
||||
@@ -128,17 +115,24 @@ export function ExperienceTimeline() {
|
||||
{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) => (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{skills[index]?.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>
|
||||
|
||||
{links[index] && (
|
||||
<a
|
||||
href={links[index]!}
|
||||
target={links[index]!.startsWith('#') ? '_self' : '_blank'}
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-semibold text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{linkTexts[index] || 'View Product →'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
+317
-25
@@ -1,12 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Menu, X, Moon, Sun } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTerminal } from '@/lib/TerminalContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
|
||||
export default function Header() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dark, setDark] = useState(false)
|
||||
const [skillsOpen, setSkillsOpen] = useState(false)
|
||||
const [contactOpen, setContactOpen] = useState(false)
|
||||
const [educationOpen, setEducationOpen] = useState(false)
|
||||
const { isOpen: terminalOpen, setIsOpen: setTerminalOpen } = useTerminal()
|
||||
const { lang, setLang, t } = useLanguage()
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const handleCopy = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(label)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const toggleLang = () => {
|
||||
setLang(lang === 'en' ? 'zh' : 'en')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
@@ -22,42 +41,315 @@ export default function Header() {
|
||||
<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>
|
||||
<Image
|
||||
src="/avatar.jpeg"
|
||||
alt="Profile"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
<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 className="text-sm font-bold text-gray-900 dark:text-white">{t.header.title}</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">{t.header.subtitle}</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} />}
|
||||
<Link href="/" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">{t.header.nav.home}</Link>
|
||||
<button
|
||||
onClick={() => setTerminalOpen(!terminalOpen)}
|
||||
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{t.header.nav.askPoimen}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setSkillsOpen(!skillsOpen)}
|
||||
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{t.header.nav.skills}
|
||||
</button>
|
||||
{skillsOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-lg p-4 space-y-2">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400">{t.header.skillsPlaceholder}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setContactOpen(!contactOpen)}
|
||||
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{t.header.nav.contact}
|
||||
</button>
|
||||
{contactOpen && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1">
|
||||
{t.header.contact.portfolio}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy('https://portfolio.riotpiao.com', 'Portfolio')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'Portfolio' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="https://github.com/rockliang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 flex-1"
|
||||
>
|
||||
{t.header.contact.github}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('https://github.com/rockliang', 'GitHub')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'GitHub' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="https://github.com/rockliang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 flex-1"
|
||||
>
|
||||
{t.header.contact.privateGithub}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('https://github.com/rockliang', 'PrivateGithub')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'PrivateGithub' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/rockliang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 flex-1"
|
||||
>
|
||||
{t.header.contact.linkedin}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('https://www.linkedin.com/in/rockliang', 'LinkedIn')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'LinkedIn' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 flex-1"
|
||||
>
|
||||
{t.header.contact.email}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('[email protected]', 'Email')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'Email' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-l border-gray-300 dark:border-gray-700 pl-8 flex gap-6 items-center">
|
||||
<div className="text-xs">
|
||||
<div className="text-gray-600 dark:text-gray-400">{t.header.experience}</div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{t.header.experienceValue}</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setEducationOpen(!educationOpen)}
|
||||
className="text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
<div className="text-gray-600 dark:text-gray-400">{t.header.education}</div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{t.header.educationValue}</div>
|
||||
</button>
|
||||
{educationOpen && (
|
||||
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-lg p-4 space-y-4">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{t.header.educationDetails.bachelor.school}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{t.header.educationDetails.bachelor.degree}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">{t.header.educationDetails.bachelor.period}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{t.header.educationDetails.master.school}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{t.header.educationDetails.master.degree}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">{t.header.educationDetails.master.period}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={toggleDark} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded">
|
||||
{dark ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="px-3 py-1 text-sm font-semibold rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{lang === 'en' ? '中文' : 'EN'}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="md:hidden p-2"
|
||||
>
|
||||
{open ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
{/* Mobile Language Toggle */}
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="px-2 py-1 text-xs font-semibold rounded border border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
{lang === 'en' ? '中文' : 'EN'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="p-2"
|
||||
>
|
||||
{open ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</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 className="md:hidden border-t border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
||||
<Link href="/" className="block py-2 text-sm hover:text-blue-600">{t.header.nav.home}</Link>
|
||||
<button
|
||||
onClick={() => setTerminalOpen(!terminalOpen)}
|
||||
className="block py-2 text-sm hover:text-blue-600"
|
||||
>
|
||||
{t.header.nav.askPoimen}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSkillsOpen(!skillsOpen)}
|
||||
className="block py-2 text-sm hover:text-blue-600"
|
||||
>
|
||||
{t.header.nav.skills}
|
||||
</button>
|
||||
{skillsOpen && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-3 mt-2 space-y-2">
|
||||
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400">{t.header.skillsPlaceholder}</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setContactOpen(!contactOpen)}
|
||||
className="block py-2 text-sm hover:text-blue-600"
|
||||
>
|
||||
{t.header.nav.contact}
|
||||
</button>
|
||||
{contactOpen && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-3 mt-2 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-gray-300 dark:border-gray-700 pb-3">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1">
|
||||
{t.header.contact.portfolio}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy('https://portfolio.riotpiao.com', 'Portfolio')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'Portfolio' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="https://github.com/rockliang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 flex-1"
|
||||
>
|
||||
{t.header.contact.github}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('https://github.com/rockliang', 'GitHub')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'GitHub' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="https://github.com/rockliang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 flex-1"
|
||||
>
|
||||
{t.header.contact.privateGithub}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('https://github.com/rockliang', 'PrivateGithub')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'PrivateGithub' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/rockliang"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 flex-1"
|
||||
>
|
||||
{t.header.contact.linkedin}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('https://www.linkedin.com/in/rockliang', 'LinkedIn')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'LinkedIn' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 flex-1"
|
||||
>
|
||||
{t.header.contact.email}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleCopy('[email protected]', 'Email')}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
>
|
||||
{copied === 'Email' ? '✓' : t.header.contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-200 dark:border-gray-800 pt-4 space-y-4">
|
||||
<div className="text-xs">
|
||||
<div className="text-gray-600 dark:text-gray-400">{t.header.experience}</div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{t.header.experienceValue}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setEducationOpen(!educationOpen)}
|
||||
className="text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 w-full text-left"
|
||||
>
|
||||
<div className="text-gray-600 dark:text-gray-400">{t.header.education}</div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{t.header.educationValue}</div>
|
||||
</button>
|
||||
{educationOpen && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded p-3 mt-2 space-y-4">
|
||||
<div className="border-b border-gray-300 dark:border-gray-700 pb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm">{t.header.educationDetails.bachelor.school}</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{t.header.educationDetails.bachelor.degree}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">{t.header.educationDetails.bachelor.period}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm">{t.header.educationDetails.master.school}</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{t.header.educationDetails.master.degree}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">{t.header.educationDetails.master.period}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
+70
-56
@@ -2,78 +2,92 @@
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Code, Network, Zap, Sparkles, Github } from 'lucide-react'
|
||||
import { TypewriterText } from './TypewriterText'
|
||||
import Image from 'next/image'
|
||||
import { Code, Network, Zap, Sparkles, Shield, Cpu } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
|
||||
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 },
|
||||
]
|
||||
const roleIcons = [Network, Shield, Code, Sparkles, Zap, Cpu]
|
||||
|
||||
export function HeroBlobFlow() {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const roles = t.hero.roles
|
||||
|
||||
useEffect(() => {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark')
|
||||
setIsDark(isDarkMode)
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const darkMode = document.documentElement.classList.contains('dark')
|
||||
setIsDark(darkMode)
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % roles.length)
|
||||
}, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
}, [roles.length])
|
||||
|
||||
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 className="relative w-full max-w-5xl mx-auto">
|
||||
{/* Full hire-me image */}
|
||||
<div className="relative w-full">
|
||||
<Image
|
||||
src={isDark ? '/hire-me.png' : '/hire-me-light.png'}
|
||||
alt="You hire me, you get a bundle"
|
||||
width={1200}
|
||||
height={800}
|
||||
className="w-full h-auto"
|
||||
priority
|
||||
/>
|
||||
</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">
|
||||
|
||||
{/* Avatar + Rotating text - centered on image */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 flex flex-col items-center">
|
||||
{/* Avatar */}
|
||||
<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"
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = roles[index].icon
|
||||
return <Icon size={28} className="text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
})()}
|
||||
{roles[index].text}
|
||||
<Image
|
||||
src="/avatar.jpeg"
|
||||
alt="Profile"
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-48 h-48 rounded-full object-cover border-4 border-white dark:border-gray-950 shadow-xl"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Rotating role text */}
|
||||
<div className="mt-6 h-16 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="text-3xl font-bold text-gray-900 dark:text-white text-center flex items-center gap-3 justify-center bg-white/80 dark:bg-gray-900/80 rounded-xl py-3 px-6 backdrop-blur-sm"
|
||||
>
|
||||
{(() => {
|
||||
const Icon = roleIcons[index]
|
||||
return <Icon size={32} className="text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
})()}
|
||||
{roles[index]}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTerminal } from '@/lib/TerminalContext'
|
||||
|
||||
const commands: Record<string, string> = {
|
||||
help: `Available commands:
|
||||
@@ -46,10 +47,15 @@ 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 { isOpen, setIsOpen } = useTerminal()
|
||||
const [isMac, setIsMac] = useState(true)
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Detect if user is on Mac
|
||||
const isMacOS = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
|
||||
setIsMac(isMacOS)
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
@@ -59,7 +65,7 @@ export function InteractiveTerminal() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen])
|
||||
}, [isOpen, setIsOpen])
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
const trimmed = cmd.trim().toLowerCase()
|
||||
@@ -89,7 +95,7 @@ export function InteractiveTerminal() {
|
||||
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)
|
||||
Ask Poimen ({isMac ? 'Cmd' : 'Ctrl'}+K)
|
||||
</button>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -98,7 +104,7 @@ export function InteractiveTerminal() {
|
||||
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>
|
||||
<span className="text-gray-300 font-mono text-xs">Poimen (Agent Terminal)</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-white"
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { ExternalLink, MessageSquare } from 'lucide-react'
|
||||
import { useTerminal } from '@/lib/TerminalContext'
|
||||
|
||||
interface ProjectShowcaseProps {
|
||||
id?: string
|
||||
title: string
|
||||
description: string
|
||||
longDescription?: string
|
||||
stat: string
|
||||
highlight?: string
|
||||
status: 'live' | 'building' | 'planning' | 'completed'
|
||||
media?: {
|
||||
type: 'image' | 'video'
|
||||
url: string
|
||||
}
|
||||
videoUrl?: string
|
||||
articleUrl?: string
|
||||
watchVideoText?: string
|
||||
readArticleText?: string
|
||||
askPoimenText?: string
|
||||
bullets?: string[]
|
||||
deepDive?: {
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectShowcase({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
longDescription,
|
||||
stat,
|
||||
highlight,
|
||||
status,
|
||||
media,
|
||||
videoUrl,
|
||||
articleUrl,
|
||||
watchVideoText = 'Watch Video',
|
||||
readArticleText = 'Read Article',
|
||||
askPoimenText = 'Ask Poimen for technical details',
|
||||
bullets,
|
||||
deepDive,
|
||||
}: ProjectShowcaseProps) {
|
||||
const { setIsOpen } = useTerminal()
|
||||
|
||||
const statusColors = {
|
||||
live: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
||||
completed: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
||||
building: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
|
||||
planning: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
id={id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{/* Optional Media (Image or Video) */}
|
||||
{media && (
|
||||
<div className="aspect-video bg-gradient-to-br from-blue-500 to-purple-500 dark:from-blue-900 dark:to-purple-900 flex items-center justify-center">
|
||||
{media.type === 'video' ? (
|
||||
(() => {
|
||||
const youtubeMatch = media.url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/)
|
||||
if (youtubeMatch) {
|
||||
return (
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${youtubeMatch[1]}`}
|
||||
title={title}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()
|
||||
) : (
|
||||
<img src={media.url} alt={title} className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||
{description}
|
||||
</p>
|
||||
{longDescription && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{longDescription}
|
||||
</p>
|
||||
)}
|
||||
{bullets && bullets.length > 0 && (
|
||||
<ul className="mt-3 space-y-1">
|
||||
{bullets.map((bullet, i) => {
|
||||
// Highlight skills: parentheses + keywords
|
||||
const skillKeywords = ['Terraform', 'Golang', 'K8s', 'Slack', 'Grafana', 'IaC', 'Docker', 'OpenShift', 'Temporal', 'Kafka', 'gRPC', 'AWS', 'Java', 'Python', 'Go', 'C++']
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`(\\([^)]+\\)|${skillKeywords.map(escapeRegex).join('|')})`, 'g')
|
||||
const parts = bullet.split(pattern).filter(Boolean)
|
||||
return (
|
||||
<li key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
|
||||
<span className="text-blue-500">•</span>
|
||||
<span>
|
||||
{parts.map((part, j) =>
|
||||
(part.startsWith('(') && part.endsWith(')')) || skillKeywords.includes(part) ? (
|
||||
<span key={j} className="text-blue-600 dark:text-blue-400 font-medium">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={j}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{deepDive && (
|
||||
<a
|
||||
href={deepDive.url}
|
||||
className="mt-3 inline-block text-sm font-semibold text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{deepDive.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-4 px-3 py-1 rounded-full text-xs font-semibold whitespace-nowrap ${
|
||||
statusColors[status]
|
||||
}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stat */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 rounded px-4 py-3 mb-4 space-y-1">
|
||||
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
📊 {stat}
|
||||
</p>
|
||||
{highlight && (
|
||||
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
📊 {highlight}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex gap-3">
|
||||
{videoUrl && videoUrl !== '#' && !videoUrl.includes('youtube.com') && !videoUrl.includes('youtu.be') && (
|
||||
<a
|
||||
href={videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-semibold transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>{watchVideoText}</span>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
)}
|
||||
{articleUrl && articleUrl !== '#' && (
|
||||
<a
|
||||
href={articleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 border-2 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 px-4 py-2 rounded-lg font-semibold transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>{readArticleText}</span>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ask Poimen */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="mt-4 w-full text-sm text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
{askPoimenText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user