Build & Push Portfolio Image / build-push (push) Successful in 2m40s
- Add CI status badge in header (auto-syncs from Forgejo) - Add rock-svg-icon.svg as favicon for Chrome - Add SOPS encrypted secrets for Forgejo token - Add decrypt script for local dev
402 lines
19 KiB
TypeScript
402 lines
19 KiB
TypeScript
'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'
|
|
import { CIStatusIndicator } from './CIStatusIndicator'
|
|
|
|
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 stored = localStorage.getItem('theme')
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
const isDark = stored === 'dark' || (!stored && prefersDark)
|
|
|
|
if (isDark) {
|
|
document.documentElement.classList.add('dark')
|
|
} else {
|
|
document.documentElement.classList.remove('dark')
|
|
}
|
|
setDark(isDark)
|
|
}, [])
|
|
|
|
const toggleDark = () => {
|
|
const newDark = !dark
|
|
if (newDark) {
|
|
document.documentElement.classList.add('dark')
|
|
localStorage.setItem('theme', 'dark')
|
|
} else {
|
|
document.documentElement.classList.remove('dark')
|
|
localStorage.setItem('theme', 'light')
|
|
}
|
|
setDark(newDark)
|
|
}
|
|
|
|
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">
|
|
<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">{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">{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-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-lg p-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{Object.entries(t.header.skills).map(([key, category]) => (
|
|
<div key={key}>
|
|
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1">{(category as {title: string}).title}</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{(category as {items: string[]}).items.map((skill) => (
|
|
<span key={skill} className="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
|
|
{skill}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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>
|
|
{/* CI Status */}
|
|
<CIStatusIndicator />
|
|
</div>
|
|
</nav>
|
|
|
|
<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-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">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{Object.entries(t.header.skills).map(([key, category]) => (
|
|
<div key={key}>
|
|
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1">{(category as {title: string}).title}</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{(category as {items: string[]}).items.map((skill) => (
|
|
<span key={skill} className="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
|
|
{skill}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|