From 7471d65f54b1de1821fb623ea6eadca91d8388d0 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 20 Sep 2026 22:58:37 +0300 Subject: [PATCH] Add a prominent link to the full portfolio 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 Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9 --- README.md | 3 +++ app/globals.css | 4 ++- components/Hero.tsx | 11 ++++++--- components/Projects.tsx | 17 +++++++++++-- components/Site.tsx | 12 ++++++--- components/TopBar.tsx | 8 +++--- components/hero.module.css | 24 +++++++++++++++++- components/projects.module.css | 45 ++++++++++++++++++++++++++++++++++ components/topbar.module.css | 4 +-- lib/types.ts | 5 +++- 10 files changed, 115 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d10b0dd..9e645f8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/globals.css b/app/globals.css index 3af2eef..8165273 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/Hero.tsx b/components/Hero.tsx index 6e959ec..e94c462 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -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) { +export default function Hero({ resume, portfolio, mailHref, showProjects }: Readonly) { const hero = useRef(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

{resume.bio}

+ + {status === 'live' && projects.length > 0 && ( + + + There is more in the portfolio + + {portfolio.count ? `${portfolio.count} projects in total, including the ones above.` : 'The full list of projects, including the ones above.'} + + + {portfolio.label ?? 'Open the portfolio'} + + )} ) diff --git a/components/Site.tsx b/components/Site.tsx index dd159a2..fc3bd38 100644 --- a/components/Site.tsx +++ b/components/Site.tsx @@ -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(initial) const [projects, setProjects] = useState([]) + const [portfolio, setPortfolio] = useState(PORTFOLIO_FALLBACK) const [generatedAt, setGeneratedAt] = useState() const [status, setStatus] = useState('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 ( <> - +
- - {showProjects && } + + {showProjects && } diff --git a/components/TopBar.tsx b/components/TopBar.tsx index 57c832d..4b0839c 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -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) { +export default function TopBar({ name, portfolio, showProjects }: Readonly) { const scrolled = useScrolled() return (
@@ -17,9 +18,10 @@ export default function TopBar({ name, mailHref, showProjects }: Readonly Skills Experience Education + Contact - Email me + {portfolio.label ?? 'Portfolio'}
) diff --git a/components/hero.module.css b/components/hero.module.css index b47f5fd..0035180 100644 --- a/components/hero.module.css +++ b/components/hero.module.css @@ -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; + } +} diff --git a/components/projects.module.css b/components/projects.module.css index 1e40148..5fc906c 100644 --- a/components/projects.module.css +++ b/components/projects.module.css @@ -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; +} diff --git a/components/topbar.module.css b/components/topbar.module.css index 0c8fd81..ffab1d9 100644 --- a/components/topbar.module.css +++ b/components/topbar.module.css @@ -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); } diff --git a/lib/types.ts b/lib/types.ts index 3f663ae..c5cb928 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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[] }