Resume site with live content from Gitea
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

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>
This commit is contained in:
Emil
2026-09-20 20:55:05 +03:00
co-authored by Claude Sonnet 5
parent b001ddf6b5
commit 32c57a5ada
10 changed files with 719 additions and 249 deletions
+40
View File
@@ -102,3 +102,43 @@ a:focus-visible { outline:2px solid var(--acid); outline-offset:5px; }
.tool-grid { grid-template-columns:1fr; }.tool-grid a:nth-child(n) { border-right:0; padding:24px 0; }
.education { grid-template-columns:1fr; gap:20px; padding-bottom:70px; }.contact-grid a { grid-template-columns:80px 1fr 20px; }.contact-grid strong { font-size:12px; overflow-wrap:anywhere; }.footer-bottom { gap:20px; }.footer-bottom span:last-child { text-align:right; }
}
/* Resume site: skills, experience, live project list, side info. */
.skill-grid { display:grid; grid-template-columns:repeat(3,1fr); }
.skill-card { padding:34px 28px 34px 0; border-right:1px solid var(--line); border-bottom:1px solid var(--line); transition:background .25s; }
.skill-card:nth-child(3n+2), .skill-card:nth-child(3n) { padding-left:28px; }
.skill-card:nth-child(3n) { border-right:0; }
.skill-card:hover { background:linear-gradient(135deg,rgba(199,255,70,.09),transparent 68%); }
.skill-card h3 { font-size:22px; margin:30px 0 12px; letter-spacing:-.035em; }
.skill-card p { margin:0; color:#b0bab2; font-size:14px; line-height:1.65; }
.exp-item { display:grid; grid-template-columns:200px 1fr; gap:34px; padding:34px 0; border-bottom:1px solid var(--line); }
.exp-item > span { font:300 11px/1.6 'DM Mono',monospace; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }
.exp-item h3 { margin:0 0 16px; font-size:22px; letter-spacing:-.03em; }
.exp-item ul { margin:0; padding:0; list-style:none; display:grid; gap:9px; color:#b0bab2; line-height:1.6; }
.exp-item li::before { content:'—'; color:var(--muted); margin-right:10px; }
.status-note { margin:0; padding:44px 0; border-bottom:1px solid var(--line); color:var(--muted); font:400 12px/1.8 'DM Mono',monospace; }
.status-note a { color:var(--acid); border-bottom:1px solid rgba(199,255,70,.4); }
.status-note b { color:var(--ink); font-weight:500; }
.project-updated { margin:20px 0 0; color:var(--muted); font:300 10px 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.05em; }
.about-grid { display:grid; grid-template-columns:1.25fr 1fr; gap:64px; padding-bottom:70px; }
.about-grid .education-list { padding-bottom:0; }
.side-info { padding-top:18px; }
.mini-heading { margin:0 0 14px; padding-bottom:10px; border-bottom:1px solid rgba(243,246,240,.35); font:500 11px 'DM Mono',monospace; text-transform:uppercase; letter-spacing:.11em; }
.side-info .mini-heading:not(:first-child) { margin-top:40px; }
.lang-list { list-style:none; margin:0; padding:0; }
.lang-list li { display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--line); }
.lang-list b { font:400 11px 'DM Mono',monospace; color:var(--acid); font-weight:400; }
.side-info .tag-list { margin-top:0; }
.contact-note { color:#92958a; font-style:normal; font-size:12px; }
@media (max-width:980px) {
.skill-grid { grid-template-columns:repeat(2,1fr); }
.skill-card:nth-child(n) { padding:34px 24px 34px 0; border-right:1px solid var(--line); }
.skill-card:nth-child(2n) { padding-left:24px; border-right:0; }
.about-grid { grid-template-columns:1fr; gap:40px; }
}
@media (max-width:760px) {
.skill-grid { grid-template-columns:1fr; }
.skill-card:nth-child(n) { padding:30px 0; border-right:0; }
.exp-item { grid-template-columns:1fr; gap:14px; padding:28px 0; }
.about-grid { padding-bottom:50px; }
}
+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Emil Shanayev — AI Systems & Scientific Software',
description: 'Portfolio of Emil Shanayev: AI-native systems, scientific software, graphics and infrastructure.',
title: 'Emil Shanayev — Resume',
description: 'Resume of Emil Shanayev: backend, DevOps, AI systems, scientific software and game technology.',
authors: [{ name: 'Emil Shanayev' }],
}
+4 -126
View File
@@ -1,129 +1,7 @@
import { resume } from '@/data/resume'
const ArrowIcon = () => (
<svg className="link-arrow" viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 13 13 3M6 3h7v7" />
</svg>
)
import resume from '@/data/resume.json'
import type { Resume } from '@/lib/types'
import Site from './site'
export default function Home() {
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="#work">Work</a>
<a href="#profile">Profile</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">
<a className="button primary" href="#work">Selected work</a>
<a className="button" href={resume.contacts[0].href} target="_blank" rel="noreferrer">GitHub <ArrowIcon /></a>
</div>
<div className="hero-facts">
<span><b>05</b> featured systems</span>
<span><b>∞</b> curiosity for hard problems</span>
</div>
<div className="hero-index" aria-hidden="true">01—05</div>
</section>
<section className="capabilities shell" id="profile">
<div className="section-heading">
<p>Profile / capabilities</p>
<span>Three intersecting domains</span>
</div>
<div className="cap-grid">
{resume.capabilities.map((item) => (
<article className="cap-card" key={item.index}>
<span className="cap-number">{item.index}</span>
<h3>{item.title}</h3>
<p>{item.text}</p>
<div className="tag-list">{item.stack.map((tag) => <span key={tag}>{tag}</span>)}</div>
</article>
))}
</div>
</section>
<section className="work shell" id="work">
<div className="section-heading">
<p>Selected work</p>
<span>Built systems, not placeholder concepts</span>
</div>
<div className="project-list">
{resume.projects.map((project) => (
<article className="project" key={project.number}>
<div className="project-meta">
<span>{project.number}</span>
<p>{project.kind}</p>
</div>
<div className="project-body">
<h3>{project.name}</h3>
<p>{project.description}</p>
<ul>{project.highlights.map((highlight) => <li key={highlight}>{highlight}</li>)}</ul>
<div className="tag-list">{project.tech.map((tag) => <span key={tag}>{tag}</span>)}</div>
</div>
<div className="project-links">
<a href={project.href} target="_blank" rel="noreferrer">Repository <ArrowIcon /></a>
{'secondaryHref' in project && project.secondaryHref && (
<a href={project.secondaryHref} target="_blank" rel="noreferrer">{'secondaryLabel' in project ? project.secondaryLabel : 'Related project'} <ArrowIcon /></a>
)}
</div>
</article>
))}
</div>
</section>
<section className="tools shell">
<div className="section-heading">
<p>Tools & experiments</p>
<span>Smaller utilities from active exploration</span>
</div>
<div className="tool-grid">
{resume.tools.map((tool) => (
<a href={tool.href} target="_blank" rel="noreferrer" key={tool.name}>
<div><strong>{tool.name}</strong><p>{tool.note}</p></div><ArrowIcon />
</a>
))}
</div>
</section>
<section className="about shell">
<div className="section-heading">
<p>Education</p><span>Learning by building</span>
</div>
<div className="education-list">
{resume.education.map((education) => (
<div className="education" key={education.institution}>
<span>{education.period}</span>
<div><h3>{education.institution}</h3><p>{education.program}</p></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.label}>
<span>{contact.label}</span><strong>{contact.value}</strong><ArrowIcon />
</a>
))}
</div>
<div className="footer-bottom"><span>© 2026 Emil Shanayev</span><span>Designed as a static Next.js portfolio</span></div>
</footer>
</main>
)
return <Site initial={resume as unknown as Resume} />
}
+212
View File
@@ -0,0 +1,212 @@
'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>
)
}