feat: rewrite site as Next.js 14 portfolio with terminal aesthetic
Replaces Hugo site with a Next.js App Router (TypeScript) build. Tailwind CSS custom terminal palette, Framer Motion scroll reveals, JetBrains Mono font, scanline/vignette overlays, typing animation. Sections: Hero, Skills, Projects, Contact. Content driven from data/resume.ts. Deploy workflow updated for static export to gh-pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a6ed78c2ca
commit
727996503e
@@ -0,0 +1,58 @@
|
||||
import { resume } from '@/data/resume'
|
||||
import { SectionLabel } from './SectionLabel'
|
||||
import { Reveal } from './Reveal'
|
||||
|
||||
export function Contact() {
|
||||
return (
|
||||
<section id="contact" className="pb-16">
|
||||
<Reveal>
|
||||
<SectionLabel index="03" label="contact.sh" />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.05}>
|
||||
<p className="text-[12px] text-terminal-muted mb-8 pl-1">
|
||||
<span className="text-terminal-amber">$</span>
|
||||
<span className="ml-2">echo "Open to DevOps and Backend opportunities."</span>
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-px">
|
||||
{resume.contacts.map((c, i) => (
|
||||
<Reveal key={c.label} delay={i * 0.06}>
|
||||
<a
|
||||
href={c.href}
|
||||
target={c.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel="noreferrer"
|
||||
className="group flex items-center gap-0 border border-transparent hover:border-terminal-border bg-transparent hover:bg-terminal-card transition-all duration-150 px-4 py-3.5"
|
||||
>
|
||||
<span className="w-24 text-[10px] text-terminal-muted tracking-widest uppercase flex-shrink-0">
|
||||
{c.label}
|
||||
</span>
|
||||
<span className="text-terminal-border mx-4 group-hover:text-terminal-amber transition-colors">→</span>
|
||||
<span className="text-[12px] text-terminal-muted group-hover:text-terminal-text transition-colors font-mono">
|
||||
{c.prefix && (
|
||||
<span className="text-terminal-subtle">{c.prefix}</span>
|
||||
)}
|
||||
<span className="group-hover:text-terminal-amber transition-colors">
|
||||
{c.handle.replace(c.prefix ?? '', '')}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<Reveal delay={0.25}>
|
||||
<div className="mt-20 pt-8 border-t border-terminal-border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<span className="text-[10px] text-terminal-subtle tracking-widest">
|
||||
EMIL SHANAYEV · {new Date().getFullYear()}
|
||||
</span>
|
||||
<span className="text-[10px] text-terminal-subtle opacity-50 tracking-wide">
|
||||
built with Next.js · deployed to GitHub Pages
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { resume } from '@/data/resume'
|
||||
import { TypingText } from './TypingText'
|
||||
|
||||
export function Hero() {
|
||||
const { name, role, bio, education, contacts } = resume
|
||||
|
||||
return (
|
||||
<section className="pt-16 pb-24 sm:pt-20 sm:pb-32">
|
||||
|
||||
{/* Terminal chrome */}
|
||||
<div className="mb-10 inline-flex items-center gap-2 text-[10px] text-terminal-muted tracking-widest">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#28c840]" />
|
||||
<span className="ml-3 opacity-40">portfolio.sh</span>
|
||||
</div>
|
||||
|
||||
{/* Shell prompt line */}
|
||||
<p className="text-[11px] text-terminal-muted mb-8 tracking-wide">
|
||||
<span className="text-terminal-amber">visitor</span>
|
||||
<span className="opacity-40">@portfolio</span>
|
||||
<span className="opacity-40">:~$</span>
|
||||
<span className="ml-2 text-terminal-text">cat whoami.txt</span>
|
||||
</p>
|
||||
|
||||
{/* Name */}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold text-terminal-text tracking-tight leading-none mb-5">
|
||||
{name}
|
||||
</h1>
|
||||
|
||||
{/* Typing role */}
|
||||
<p className="text-base sm:text-lg text-terminal-muted mb-10 h-7">
|
||||
<span className="text-terminal-amber opacity-60 mr-2">></span>
|
||||
<TypingText phrases={role} speed={80} deleteSpeed={40} pauseDuration={2000} />
|
||||
</p>
|
||||
|
||||
{/* Bio */}
|
||||
<p className="text-sm text-terminal-muted leading-relaxed max-w-2xl mb-10 border-l-2 border-terminal-border pl-4">
|
||||
{bio}
|
||||
</p>
|
||||
|
||||
{/* Education inline */}
|
||||
<div className="mb-12 flex items-start gap-2 text-[11px]">
|
||||
<span className="text-terminal-amber opacity-50 mt-0.5">◆</span>
|
||||
<div>
|
||||
<span className="text-terminal-text">{education.institution}</span>
|
||||
<span className="text-terminal-muted mx-2">·</span>
|
||||
<span className="text-terminal-muted">student</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact links in hero */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{contacts.map((c) => (
|
||||
<a
|
||||
key={c.label}
|
||||
href={c.href}
|
||||
target={c.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel="noreferrer"
|
||||
className="group inline-flex items-center gap-2 px-4 py-2 text-[11px] text-terminal-muted border border-terminal-border hover:border-terminal-amber hover:text-terminal-text transition-all duration-200 tracking-wide"
|
||||
>
|
||||
<span className="text-terminal-amber opacity-0 group-hover:opacity-100 transition-opacity">▸</span>
|
||||
<span className="uppercase tracking-widest text-[10px]">{c.label}</span>
|
||||
<span className="opacity-40">/</span>
|
||||
<span>{c.handle}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mt-16 h-px w-full bg-gradient-to-r from-terminal-amber/20 via-terminal-border to-transparent" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { resume } from '@/data/resume'
|
||||
|
||||
const links = [
|
||||
{ label: 'skills', href: '#skills' },
|
||||
{ label: 'projects', href: '#projects' },
|
||||
{ label: 'contact', href: '#contact' },
|
||||
]
|
||||
|
||||
export function Nav() {
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > 40)
|
||||
window.addEventListener('scroll', handler, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? 'bg-terminal-bg/90 backdrop-blur-sm border-b border-terminal-border'
|
||||
: 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<nav className="max-w-3xl mx-auto px-5 sm:px-8 h-14 flex items-center justify-between">
|
||||
<span className="text-[11px] text-terminal-muted tracking-widest">
|
||||
<span className="text-terminal-amber">~/</span>
|
||||
{resume.handle}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="text-[11px] text-terminal-muted hover:text-terminal-text transition-colors tracking-wider"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { resume } from '@/data/resume'
|
||||
import { SectionLabel } from './SectionLabel'
|
||||
import { Reveal } from './Reveal'
|
||||
|
||||
const statusConfig = {
|
||||
deployed: { label: 'deployed', color: 'text-terminal-green' },
|
||||
shipped: { label: 'shipped', color: 'text-terminal-amber' },
|
||||
wip: { label: 'wip', color: 'text-terminal-muted' },
|
||||
} as const
|
||||
|
||||
export function Projects() {
|
||||
return (
|
||||
<section id="projects" className="pb-24">
|
||||
<Reveal>
|
||||
<SectionLabel index="02" label="projects.log" />
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-4">
|
||||
{resume.projects.map((project, i) => {
|
||||
const status = statusConfig[project.status]
|
||||
|
||||
return (
|
||||
<Reveal key={project.id} delay={i * 0.07}>
|
||||
<article className="group relative border border-terminal-border hover:border-terminal-amber/40 bg-terminal-card transition-all duration-200 p-5 sm:p-6">
|
||||
|
||||
{/* Top row */}
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-terminal-amber opacity-40 font-mono">
|
||||
[{project.id}]
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-terminal-text group-hover:text-white transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`text-[10px] tracking-widest ${status.color}`}>
|
||||
● {status.label}
|
||||
</span>
|
||||
{project.href && (
|
||||
<a
|
||||
href={project.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-terminal-muted hover:text-terminal-amber transition-colors tracking-wider"
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[12px] text-terminal-muted leading-relaxed mb-4 pl-9">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* Tech stack */}
|
||||
<div className="pl-9 flex flex-wrap gap-1.5">
|
||||
{project.tech.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="px-2 py-px text-[10px] text-terminal-subtle border border-terminal-border/60 tracking-wide"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Left accent line on hover */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-terminal-amber scale-y-0 group-hover:scale-y-100 transition-transform duration-200 origin-top" />
|
||||
</article>
|
||||
</Reveal>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { useRef } from 'react'
|
||||
|
||||
interface RevealProps {
|
||||
children: React.ReactNode
|
||||
delay?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Reveal({ children, delay = 0, className }: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={inView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.55, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface Props {
|
||||
index: string
|
||||
label: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function SectionLabel({ index, label, id }: Props) {
|
||||
return (
|
||||
<div id={id} className="flex items-center gap-4 mb-10">
|
||||
<span className="text-[10px] text-terminal-amber font-mono tracking-widest opacity-60">
|
||||
{index}
|
||||
</span>
|
||||
<span className="text-[11px] text-terminal-muted font-mono tracking-[0.22em] uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-terminal-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { resume } from '@/data/resume'
|
||||
import { SectionLabel } from './SectionLabel'
|
||||
import { Reveal } from './Reveal'
|
||||
|
||||
export function Skills() {
|
||||
return (
|
||||
<section id="skills" className="pb-24">
|
||||
<Reveal>
|
||||
<SectionLabel index="01" label="skills.list" id="skills" />
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-0">
|
||||
{resume.skills.map((group, i) => (
|
||||
<Reveal key={group.category} delay={i * 0.04}>
|
||||
<div className="group flex flex-col sm:flex-row sm:items-start gap-3 sm:gap-0 py-3.5 border-b border-terminal-border/50 hover:border-terminal-border transition-colors">
|
||||
|
||||
{/* Category label */}
|
||||
<div className="sm:w-36 flex-shrink-0 flex items-center gap-2">
|
||||
<span className="text-[9px] text-terminal-amber opacity-40 w-3 text-right">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="text-[10px] text-terminal-muted tracking-[0.18em] uppercase pl-2">
|
||||
{group.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
<div className="flex flex-wrap gap-2 sm:pl-4">
|
||||
{group.items.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="px-2.5 py-0.5 text-[11px] text-terminal-muted border border-terminal-border hover:border-terminal-amber/50 hover:text-terminal-text transition-all duration-150 cursor-default"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
phrases: readonly string[]
|
||||
speed?: number
|
||||
deleteSpeed?: number
|
||||
pauseDuration?: number
|
||||
}
|
||||
|
||||
export function TypingText({
|
||||
phrases,
|
||||
speed = 75,
|
||||
deleteSpeed = 38,
|
||||
pauseDuration = 2200,
|
||||
}: Props) {
|
||||
const [displayed, setDisplayed] = useState('')
|
||||
const [phraseIdx, setPhraseIdx] = useState(0)
|
||||
const [charIdx, setCharIdx] = useState(0)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [pausing, setPausing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (pausing) return
|
||||
const current = phrases[phraseIdx]
|
||||
|
||||
if (!deleting) {
|
||||
if (charIdx < current.length) {
|
||||
const t = setTimeout(() => setCharIdx((i) => i + 1), speed)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
if (phrases.length > 1) {
|
||||
const t = setTimeout(() => {
|
||||
setPausing(false)
|
||||
setDeleting(true)
|
||||
}, pauseDuration)
|
||||
setPausing(true)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (charIdx > 0) {
|
||||
const t = setTimeout(() => setCharIdx((i) => i - 1), deleteSpeed)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
|
||||
setDeleting(false)
|
||||
setPhraseIdx((i) => (i + 1) % phrases.length)
|
||||
}, [charIdx, deleting, pausing, phraseIdx, phrases, speed, deleteSpeed, pauseDuration])
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayed(phrases[phraseIdx].slice(0, charIdx))
|
||||
}, [charIdx, phraseIdx, phrases])
|
||||
|
||||
return (
|
||||
<span>
|
||||
{displayed}
|
||||
<span className="cursor-blink text-terminal-amber">█</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user