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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[] }
|
||||
|
||||
Reference in New Issue
Block a user