Replaces the static portfolio with a resume site that reads /data/resume.json: the text comes from data/resume.json in this repo, and the projects are the public Gitea repos tagged `resume`. Merges in the content of the hh.ru resume (backend/DevOps skills, experience, languages) without personal details. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
213 lines
9.4 KiB
TypeScript
213 lines
9.4 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import type { Live, Project, Resume } from '@/lib/types'
|
||
|
||
// Deployed on shanaty.ru the build points at the same-origin file; elsewhere (dev, GitHub Pages) it uses the public URL.
|
||
const DATA_URL = process.env.NEXT_PUBLIC_DATA_URL ?? 'https://shanaty.ru/data/resume.json'
|
||
const GITEA_PROFILE = 'https://git.shanaty.ru/Emil_Shanaty'
|
||
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
const monthYear = (iso?: string) => (iso ? new Date(iso).toLocaleDateString('en', { month: 'short', year: 'numeric' }) : '')
|
||
|
||
const ArrowIcon = () => (
|
||
<svg className="link-arrow" viewBox="0 0 16 16" aria-hidden="true">
|
||
<path d="M3 13 13 3M6 3h7v7" />
|
||
</svg>
|
||
)
|
||
|
||
type Status = 'loading' | 'live' | 'offline'
|
||
|
||
export default function Site({ initial }: Readonly<{ initial: Resume }>) {
|
||
const [resume, setResume] = useState<Resume>(initial)
|
||
const [projects, setProjects] = useState<Project[]>([])
|
||
const [generatedAt, setGeneratedAt] = useState<string>()
|
||
const [status, setStatus] = useState<Status>('loading')
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
fetch(DATA_URL, { cache: 'no-store', signal: controller.signal })
|
||
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
||
.then((data: Live) => {
|
||
if (!data?.resume || !Array.isArray(data.projects)) throw new Error('unexpected data')
|
||
setResume(data.resume)
|
||
setProjects(data.projects)
|
||
setGeneratedAt(data.generated_at)
|
||
setStatus('live')
|
||
})
|
||
.catch((error: Error) => {
|
||
if (error.name !== 'AbortError') setStatus('offline')
|
||
})
|
||
return () => controller.abort()
|
||
}, [])
|
||
|
||
// Once the list has loaded and is empty (no repo carries the `resume` topic yet), the section is hidden entirely.
|
||
const showProjects = status !== 'live' || projects.length > 0
|
||
const primary = resume.contacts[0]
|
||
const github = resume.contacts.find((contact) => contact.label === 'GitHub')
|
||
|
||
return (
|
||
<main>
|
||
<div className="ambient ambient-one" aria-hidden="true" />
|
||
<div className="ambient ambient-two" aria-hidden="true" />
|
||
<header className="nav shell">
|
||
<a className="mark" href="#top" aria-label="Home">ES<span>/26</span></a>
|
||
<nav aria-label="Main navigation">
|
||
<a href="#skills">Skills</a>
|
||
<a href="#experience">Experience</a>
|
||
{showProjects && <a href="#projects">Projects</a>}
|
||
<a href="#contact">Contact</a>
|
||
</nav>
|
||
</header>
|
||
|
||
<section className="hero shell" id="top">
|
||
<div className="hero-kicker"><span className="pulse" /> {resume.availability}</div>
|
||
<div className="hero-orbit" aria-hidden="true"><span>AI</span><i /></div>
|
||
<p className="eyebrow">{resume.eyebrow}</p>
|
||
<h1>{resume.name}</h1>
|
||
<h2>{resume.role}</h2>
|
||
<p className="hero-copy">{resume.bio}</p>
|
||
<div className="hero-actions">
|
||
{showProjects && <a className="button primary" href="#projects">Selected work</a>}
|
||
<a className={showProjects ? 'button' : 'button primary'} href={primary.href}>Get in touch <ArrowIcon /></a>
|
||
{github && <a className="button" href={github.href} target="_blank" rel="noreferrer">GitHub <ArrowIcon /></a>}
|
||
</div>
|
||
<div className="hero-facts">
|
||
{status === 'live' && projects.length > 0 && <span><b>{pad(projects.length)}</b> projects, live from Gitea</span>}
|
||
<span><b>{pad(resume.skills.length)}</b> areas of practice</span>
|
||
</div>
|
||
{status === 'live' && projects.length > 0 && <div className="hero-index" aria-hidden="true">01—{pad(projects.length)}</div>}
|
||
</section>
|
||
|
||
<section className="capabilities shell" id="skills">
|
||
<div className="section-heading">
|
||
<p>Skills / stack</p>
|
||
<span>Where I work</span>
|
||
</div>
|
||
<div className="skill-grid">
|
||
{resume.skills.map((skill, index) => (
|
||
<article className="skill-card" key={skill.title}>
|
||
<span className="cap-number">{pad(index + 1)}</span>
|
||
<h3>{skill.title}</h3>
|
||
<p>{skill.text}</p>
|
||
<div className="tag-list">{skill.stack.map((tag) => <span key={tag}>{tag}</span>)}</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="about shell" id="experience">
|
||
<div className="section-heading">
|
||
<p>Experience</p>
|
||
<span>Building, running, leading</span>
|
||
</div>
|
||
<div className="exp-list">
|
||
{resume.experience.map((entry) => (
|
||
<div className="exp-item" key={entry.title}>
|
||
<span>{entry.meta}</span>
|
||
<div>
|
||
<h3>{entry.title}</h3>
|
||
<ul>{entry.points.map((point) => <li key={point}>{point}</li>)}</ul>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{showProjects && (
|
||
<section className="work shell" id="projects">
|
||
<div className="section-heading">
|
||
<p>Selected work</p>
|
||
<span>
|
||
{status === 'live' && `Live from Gitea · updated ${monthYear(generatedAt)}`}
|
||
{status === 'loading' && 'Loading from Gitea…'}
|
||
{status === 'offline' && 'Live list unavailable'}
|
||
</span>
|
||
</div>
|
||
{status === 'loading' && (
|
||
<p className="status-note">
|
||
Loading projects…
|
||
<noscript> The project list is loaded live from git.shanaty.ru and needs JavaScript. Repositories: <a href={GITEA_PROFILE}>{GITEA_PROFILE.replace('https://', '')}</a>.</noscript>
|
||
</p>
|
||
)}
|
||
{status === 'offline' && (
|
||
<p className="status-note">
|
||
The live project list could not be loaded right now. Browse the repositories directly at{' '}
|
||
<a href={GITEA_PROFILE}>{GITEA_PROFILE.replace('https://', '')}</a>
|
||
{github && <> or <a href={github.href}>github.com/{github.value}</a></>}.
|
||
</p>
|
||
)}
|
||
<div className="project-list">
|
||
{projects.map((project, index) => (
|
||
<article className="project" key={project.name}>
|
||
<div className="project-meta">
|
||
<span>{pad(index + 1)}</span>
|
||
<p>{project.kind ?? project.language ?? 'Repository'}</p>
|
||
</div>
|
||
<div className="project-body">
|
||
<h3>{project.title ?? project.name}</h3>
|
||
{project.description && <p>{project.description}</p>}
|
||
{project.highlights && project.highlights.length > 0 && (
|
||
<ul>{project.highlights.map((highlight) => <li key={highlight}>{highlight}</li>)}</ul>
|
||
)}
|
||
{project.tech.length > 0 && <div className="tag-list">{project.tech.map((tag) => <span key={tag}>{tag}</span>)}</div>}
|
||
{project.updated && <p className="project-updated">Updated {monthYear(project.updated)}</p>}
|
||
</div>
|
||
<div className="project-links">
|
||
<a href={project.url} target="_blank" rel="noreferrer">Repository <ArrowIcon /></a>
|
||
{project.homepage && <a href={project.homepage} target="_blank" rel="noreferrer">Live site <ArrowIcon /></a>}
|
||
{project.links?.map((link) => (
|
||
<a href={link.href} target="_blank" rel="noreferrer" key={link.href}>{link.label} <ArrowIcon /></a>
|
||
))}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<section className="about shell" id="education">
|
||
<div className="section-heading">
|
||
<p>Education & more</p>
|
||
<span>Learning by building</span>
|
||
</div>
|
||
<div className="about-grid">
|
||
<div className="education-list">
|
||
{resume.education.map((entry) => (
|
||
<div className="education" key={entry.institution}>
|
||
<span>{entry.period}</span>
|
||
<div><h3>{entry.institution}</h3><p>{entry.program}</p></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="side-info">
|
||
<h3 className="mini-heading">Languages</h3>
|
||
<ul className="lang-list">
|
||
{resume.languages.map((language) => (
|
||
<li key={language.name}><span>{language.name}</span><b>{language.level}</b></li>
|
||
))}
|
||
</ul>
|
||
<h3 className="mini-heading">Interests</h3>
|
||
<div className="tag-list">{resume.interests.map((interest) => <span key={interest}>{interest}</span>)}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<footer className="footer shell" id="contact">
|
||
<p className="eyebrow">Have a difficult technical problem?</p>
|
||
<h2>Let’s build something<br /><em>that actually works.</em></h2>
|
||
<div className="contact-grid">
|
||
{resume.contacts.map((contact) => (
|
||
<a href={contact.href} target={contact.href.startsWith('http') ? '_blank' : undefined} rel="noreferrer" key={contact.value}>
|
||
<span>{contact.label}</span>
|
||
<strong>{contact.value}{contact.note && <em className="contact-note"> · {contact.note}</em>}</strong>
|
||
<ArrowIcon />
|
||
</a>
|
||
))}
|
||
</div>
|
||
<div className="footer-bottom"><span>© 2026 Emil Shanayev</span><span>Content and project list are read live from Gitea</span></div>
|
||
</footer>
|
||
</main>
|
||
)
|
||
}
|