Add a prominent link to the full portfolio
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

The top bar's accent button becomes Portfolio (Email stays in the hero and
the contact band), the hero gets a Full portfolio button, and the project
list ends with a large block linking to it. The link points at Gitea's list
of repos with the portfolio topic; the sync supplies the count and the
resume file can override the target and label. Also eases the display
weight and tracking of the name in the dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
This commit is contained in:
Emil
2026-09-20 22:58:37 +03:00
co-authored by Claude Sonnet 5
parent 15d36d6437
commit 7471d65f54
10 changed files with 115 additions and 18 deletions
+3
View File
@@ -8,6 +8,7 @@ Live at **https://shanaty.ru/resume/**. A static Next.js site whose content is r
|---|---|
| Name, role, bio, skills, experience, education, languages, contacts | [`data/resume.json`](data/resume.json) in this repo (`main` branch) |
| Projects | Public repos of `Emil_Shanaty` on Gitea that carry the topic **`resume`** |
| The "full portfolio" link and its project count | Gitea's public list of repos with the topic `portfolio` (button in the top bar, in the hero and after the project list) |
| Project picture | Optional `demo.png`, `demo.jpg`, `demo.gif` or `demo.webp` in the root of that repo |
| Project title, kind, description, highlights, tech tags, links, order | Optional `resume-card.json` in the root of that repo |
@@ -38,6 +39,8 @@ The layout adapts to any number of projects: rows of two or three plates with a
## Editing the text
To point the portfolio button somewhere else (or rename it), add `"portfolio": { "href": "https://...", "label": "All work" }` to `data/resume.json`.
Edit `data/resume.json`, commit and **push to Gitea** (`git push gitea main`). No rebuild or deploy is needed.
## Development
+3 -1
View File
@@ -40,6 +40,7 @@
/* Light type on a dark ground looks heavier, so the display weight is eased off there */
--name-weight: 800;
--name-tracking: -0.048em;
/* Derived from the palette above, so they follow the theme */
--line: color-mix(in srgb, var(--ink) 14%, transparent);
@@ -97,7 +98,8 @@
--band-bg: #e8ebef;
--band-ink: #15181d;
--name-weight: 730;
--name-weight: 700;
--name-tracking: -0.036em;
}
* { box-sizing: border-box; }
+7 -4
View File
@@ -1,12 +1,12 @@
'use client'
import { useRef } from 'react'
import type { Resume } from '@/lib/types'
import type { Portfolio, Resume } from '@/lib/types'
import styles from './hero.module.css'
type Props = { resume: Resume; mailHref: string; showProjects: boolean }
type Props = { resume: Resume; portfolio: Portfolio; mailHref: string; showProjects: boolean }
export default function Hero({ resume, mailHref, showProjects }: Readonly<Props>) {
export default function Hero({ resume, portfolio, mailHref, showProjects }: Readonly<Props>) {
const hero = useRef<HTMLElement>(null)
// The floor sways a couple of degrees with the pointer, like nudging the viewport.
@@ -28,7 +28,10 @@ export default function Hero({ resume, mailHref, showProjects }: Readonly<Props>
<p className={styles.bio}>{resume.bio}</p>
<div className={styles.actions}>
{showProjects && <a className={`${styles.button} ${styles.primary}`} href="#projects">See projects</a>}
<a className={`${styles.button} ${showProjects ? styles.secondary : styles.primary}`} href={mailHref}>Email me</a>
<a className={`${styles.button} ${showProjects ? styles.secondary : styles.primary}`} href={portfolio.href} target="_blank" rel="noreferrer">
Full portfolio
</a>
<a className={styles.link} href={mailHref}>Email me</a>
</div>
</div>
<div className={styles.stage} aria-hidden="true">
+15 -2
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from 'react'
import { planSpans, type Span } from '@/lib/plan'
import type { Project } from '@/lib/types'
import type { Portfolio, Project } from '@/lib/types'
import Cover from './Cover'
import styles from './projects.module.css'
@@ -69,9 +69,10 @@ type Props = {
status: 'loading' | 'live' | 'offline'
generatedAt?: string
profileUrl: string
portfolio: Portfolio
}
export default function Projects({ projects, status, generatedAt, profileUrl }: Readonly<Props>) {
export default function Projects({ projects, status, generatedAt, profileUrl, portfolio }: Readonly<Props>) {
const spans = useMemo(() => planSpans(projects.length), [projects.length])
return (
<section className={styles.section} id="projects">
@@ -102,6 +103,18 @@ export default function Projects({ projects, status, generatedAt, profileUrl }:
<Item key={project.name} project={project} span={spans[index]} />
))}
</div>
{status === 'live' && projects.length > 0 && (
<a className={styles.more} href={portfolio.href} target="_blank" rel="noreferrer">
<span>
<span className={styles.moreTitle}>There is more in the portfolio</span>
<span className={styles.moreText}>
{portfolio.count ? `${portfolio.count} projects in total, including the ones above.` : 'The full list of projects, including the ones above.'}
</span>
</span>
<span className={styles.moreButton}>{portfolio.label ?? 'Open the portfolio'}</span>
</a>
)}
</div>
</section>
)
+8 -4
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import type { Live, Project, Resume } from '@/lib/types'
import type { Live, Portfolio, Project, Resume } from '@/lib/types'
import Gizmo from './Gizmo'
import Hero from './Hero'
import Projects from './Projects'
@@ -11,12 +11,15 @@ import TopBar from './TopBar'
// 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'
// Used until (or unless) the live data supplies the link, so the portfolio button is always there.
const PORTFOLIO_FALLBACK: Portfolio = { href: 'https://git.shanaty.ru/explore/repos?q=portfolio&topic=1' }
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 [portfolio, setPortfolio] = useState<Portfolio>(PORTFOLIO_FALLBACK)
const [generatedAt, setGeneratedAt] = useState<string>()
const [status, setStatus] = useState<Status>('loading')
@@ -28,6 +31,7 @@ export default function Site({ initial }: Readonly<{ initial: Resume }>) {
if (!data?.resume || !Array.isArray(data.projects)) throw new Error('unexpected data')
setResume(data.resume)
setProjects(data.projects)
if (data.portfolio?.href) setPortfolio(data.portfolio)
setGeneratedAt(data.generated_at)
setStatus('live')
})
@@ -43,11 +47,11 @@ export default function Site({ initial }: Readonly<{ initial: Resume }>) {
return (
<>
<TopBar name={resume.name} mailHref={mailHref} showProjects={showProjects} />
<TopBar name={resume.name} portfolio={portfolio} showProjects={showProjects} />
<Gizmo />
<main>
<Hero resume={resume} mailHref={mailHref} showProjects={showProjects} />
{showProjects && <Projects projects={projects} status={status} generatedAt={generatedAt} profileUrl={GITEA_PROFILE} />}
<Hero resume={resume} portfolio={portfolio} mailHref={mailHref} showProjects={showProjects} />
{showProjects && <Projects projects={projects} status={status} generatedAt={generatedAt} profileUrl={GITEA_PROFILE} portfolio={portfolio} />}
<Skills resume={resume} />
<Experience resume={resume} />
<Education resume={resume} />
+5 -3
View File
@@ -1,12 +1,13 @@
'use client'
import { useScrolled } from '@/lib/useScroll'
import type { Portfolio } from '@/lib/types'
import ThemeToggle from './ThemeToggle'
import styles from './topbar.module.css'
type Props = { name: string; mailHref: string; showProjects: boolean }
type Props = { name: string; portfolio: Portfolio; showProjects: boolean }
export default function TopBar({ name, mailHref, showProjects }: Readonly<Props>) {
export default function TopBar({ name, portfolio, showProjects }: Readonly<Props>) {
const scrolled = useScrolled()
return (
<header className={`${styles.bar} ${scrolled ? styles.solid : ''}`}>
@@ -17,9 +18,10 @@ export default function TopBar({ name, mailHref, showProjects }: Readonly<Props>
<a href="#skills">Skills</a>
<a href="#experience">Experience</a>
<a href="#education">Education</a>
<a href="#contact">Contact</a>
</nav>
<ThemeToggle />
<a className={styles.mail} href={mailHref}>Email me</a>
<a className={styles.cta} href={portfolio.href} target="_blank" rel="noreferrer">{portfolio.label ?? 'Portfolio'}</a>
</div>
</header>
)
+23 -1
View File
@@ -108,7 +108,7 @@
.name {
margin: 30px 0 0 -0.045em;
font: var(--name-weight) clamp(56px, 12.6vw, 204px) / 0.88 var(--font-head);
letter-spacing: -0.048em;
letter-spacing: var(--name-tracking);
}
.role {
@@ -189,3 +189,25 @@
.plane::before { top: 170px; }
.plane { background-size: 56px 56px; }
}
.link {
margin-left: 10px;
padding: 14px 0;
font: 600 16px/1.2 var(--font-body);
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 5px;
text-decoration-color: var(--ink-line);
transition: text-decoration-color 0.2s, color 0.2s;
}
.link:hover {
color: var(--z-text);
text-decoration-color: currentColor;
}
@media (max-width: 620px) {
.link {
margin-left: 0;
}
}
+45
View File
@@ -235,3 +235,48 @@
}
.item[data-span='6'] .plate { aspect-ratio: 16 / 10; }
}
/* The whole row is a link to the full portfolio; it closes the list. */
.more {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 24px 40px;
margin-top: clamp(56px, 7vw, 104px);
padding: clamp(28px, 4vw, 52px) 0;
border-top: 1px solid var(--ink);
border-bottom: 1px solid var(--line);
text-decoration: none;
}
.more > span:first-child {
display: flex;
flex-direction: column;
gap: 10px;
}
.moreTitle {
font: 700 clamp(28px, 3.8vw, 54px) / 1.05 var(--font-head);
letter-spacing: -0.035em;
}
.moreText {
max-width: 52ch;
color: var(--muted);
}
.moreButton {
flex: none;
padding: 16px 28px;
border-radius: var(--radius-control);
background: var(--ink);
color: var(--paper);
font: 600 17px/1.2 var(--font-body);
transition: background-color 0.2s, color 0.2s;
}
.more:hover .moreButton {
background: var(--z);
color: #fff;
}
+2 -2
View File
@@ -47,7 +47,7 @@
background-size: 100% 1.5px;
}
.mail {
.cta {
margin-left: -12px;
padding: 10px 18px;
border-radius: var(--radius-control);
@@ -59,7 +59,7 @@
transition: background-color 0.2s;
}
.mail:hover {
.cta:hover {
background: var(--z);
}
+4 -1
View File
@@ -40,5 +40,8 @@ export type Project = {
demo?: Demo
}
// Where "the full portfolio" lives: Gitea's public list of repos with the portfolio topic, and how many there are.
export type Portfolio = { href: string; count?: number; label?: string }
// Shape of /data/resume.json, written every minute by the server-side sync.
export type Live = { generated_at: string; resume: Resume; projects: Project[] }
export type Live = { generated_at: string; resume: Resume; portfolio?: Portfolio; projects: Project[] }