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:
emil28092005
2026-04-30 04:15:07 +03:00
co-authored by Claude Sonnet 4.6
parent a6ed78c2ca
commit 727996503e
21 changed files with 1392 additions and 445 deletions
+58
View File
@@ -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 &quot;Open to DevOps and Backend opportunities.&quot;</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>
)
}
+74
View File
@@ -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">&gt;</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>
)
}
+49
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+27
View File
@@ -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>
)
}
+19
View File
@@ -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>
)
}
+45
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}