Files
Emil_Shanayev_Resume/components/Projects.tsx
T
EmilandClaude Sonnet 5 7471d65f54
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
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
2026-09-20 22:58:37 +03:00

122 lines
5.1 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { planSpans, type Span } from '@/lib/plan'
import type { Portfolio, Project } from '@/lib/types'
import Cover from './Cover'
import styles from './projects.module.css'
const monthYear = (iso?: string) => (iso ? new Date(iso).toLocaleDateString('en', { month: 'short', year: 'numeric' }) : '')
// Aspect ratio of a plate for each span on desktop, used to decide whether a demo image can be cropped to fill it.
const PLATE_RATIO: Record<Span, number> = { 2: 4 / 3, 3: 3 / 2, 4: 16 / 9, 6: 16 / 10 }
function Plate({ project, span }: Readonly<{ project: Project; span: Span }>) {
const [broken, setBroken] = useState(false)
const demo = project.demo
const title = project.title ?? project.name
if (!demo || broken) return <Cover name={project.name} />
const drift = Math.abs(Math.log(demo.width / demo.height / PLATE_RATIO[span]))
const fits = drift < Math.log(1.7)
const image = (className: string, extra: React.ImgHTMLAttributes<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img className={className} src={demo.url} width={demo.width} height={demo.height} decoding="async" {...extra} />
)
return (
<>
{/* An image that does not match the plate is shown whole, over a blurred copy of itself. */}
{!fits && image(styles.backdrop, { alt: '', 'aria-hidden': true, loading: 'lazy' })}
{image(fits ? styles.cover : styles.contain, { alt: `${title}, demo`, loading: 'lazy', onError: () => setBroken(true) })}
</>
)
}
function Item({ project, span }: Readonly<{ project: Project; span: Span }>) {
const title = project.title ?? project.name
const links = [
{ label: 'Repository', href: project.url },
...(project.homepage ? [{ label: 'Live site', href: project.homepage }] : []),
...(project.links ?? []),
]
return (
<article className={styles.item} data-span={span}>
<a className={styles.plate} href={project.url} target="_blank" rel="noreferrer" aria-label={`${title}: open the repository`}>
<Plate project={project} span={span} />
</a>
<div className={styles.caption}>
<h3>{title}</h3>
{(project.kind ?? project.language) && <p className={styles.kind}>{project.kind ?? project.language}</p>}
{project.description && <p className={styles.description}>{project.description}</p>}
{project.highlights && project.highlights.length > 0 && (
<ul className={styles.points}>{project.highlights.map((point) => <li key={point}>{point}</li>)}</ul>
)}
<div className={styles.foot}>
{project.tech.length > 0 && <ul className={styles.tags}>{project.tech.map((tag) => <li key={tag}>{tag}</li>)}</ul>}
<p className={styles.links}>
{links.map((link) => (
<a key={link.href} href={link.href} target="_blank" rel="noreferrer">{link.label}</a>
))}
</p>
</div>
</div>
</article>
)
}
type Props = {
projects: Project[]
status: 'loading' | 'live' | 'offline'
generatedAt?: string
profileUrl: string
portfolio: Portfolio
}
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">
<div className={styles.shell}>
<div className={styles.head}>
<h2>Selected work</h2>
{status === 'live' && <p className={styles.note}>Read live from Gitea, updated {monthYear(generatedAt)}</p>}
</div>
{status === 'loading' && (
<div className={styles.skeleton} aria-hidden="true">
<span /><span /><span />
</div>
)}
{status === 'loading' && (
<noscript>
<p className={styles.notice}>The project list is loaded live and needs JavaScript. See <a href={profileUrl}>{profileUrl.replace('https://', '')}</a>.</p>
</noscript>
)}
{status === 'offline' && (
<p className={styles.notice}>
The project list could not be loaded right now. All repositories are on <a href={profileUrl}>{profileUrl.replace('https://', '')}</a>.
</p>
)}
<div className={styles.grid} data-count={projects.length}>
{projects.map((project, index) => (
<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>
)
}