Files
Emil_Shanayev_Resume/components/Projects.tsx
T
EmilandClaude Sonnet 5 04c1cc5877
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Make the resume bilingual and part of the shared site chrome
Adds a Russian version (data/resume.ru.json, default language) with an RU/EN
switch stored under the key shared with the front page and the portfolio.
The top bar becomes the site-wide one (brand, Resume, Portfolio, Gitea,
Contact, language, theme) with a second row of anchors for this page's
sections, and the fonts move to Unbounded and Onest so the three pages share
one type system. Portfolio links now point at /portfolio/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
2026-09-20 23:48:38 +03:00

132 lines
5.8 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { Bi, count, monthYear } from '@/lib/i18n'
import { planSpans, type Span } from '@/lib/plan'
import type { Portfolio, Project } from '@/lib/types'
import Cover from './Cover'
import styles from './projects.module.css'
// 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, loading: 'lazy', onError: () => setBroken(true) })}
</>
)
}
function Item({ project, span }: Readonly<{ project: Project; span: Span }>) {
const title = project.title ?? project.name
const links: { label: React.ReactNode; href: string }[] = [
{ label: <Bi ru="Репозиторий" en="Repository" />, href: project.url },
...(project.homepage ? [{ label: <Bi ru="Сайт" en="Live site" />, href: project.homepage }] : []),
...(project.links ?? []).map((link) => ({ label: link.label, href: link.href })),
]
return (
<article className={styles.item} data-span={span}>
<a className={styles.plate} href={project.url} target="_blank" rel="noreferrer" aria-label={title}>
<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])
const profile = profileUrl.replace('https://', '')
const total = portfolio.count ? count(portfolio.count, ['проект', 'проекта', 'проектов'], ['project', 'projects']) : null
return (
<section className={styles.section} id="projects">
<div className={styles.shell}>
<div className={styles.head}>
<h2><Bi ru="Избранные проекты" en="Selected work" /></h2>
{status === 'live' && (
<p className={styles.note}>
<Bi
ru={`Данные читаются из Gitea, обновлено ${monthYear(generatedAt, 'ru')}`}
en={`Read live from Gitea, updated ${monthYear(generatedAt, 'en')}`}
/>
</p>
)}
</div>
{status === 'loading' && (
<div className={styles.skeleton} aria-hidden="true">
<span /><span /><span />
</div>
)}
{status === 'offline' && (
<p className={styles.notice}>
<Bi
ru={<>Список проектов сейчас не загрузился. Все репозитории лежат на <a href={profileUrl}>{profile}</a>.</>}
en={<>The project list could not be loaded right now. All repositories are on <a href={profileUrl}>{profile}</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}>
<span>
<span className={styles.moreTitle}><Bi ru="Есть ещё в портфолио" en="There is more in the portfolio" /></span>
<span className={styles.moreText}>
{total ? (
<Bi ru={`Всего ${total.ru}, включая эти.`} en={`${total.en} in total, including the ones above.`} />
) : (
<Bi ru="Полный список проектов, включая эти." en="The full list of projects, including the ones above." />
)}
</span>
</span>
<span className={styles.moreButton}><Bi ru="Открыть портфолио" en="Open the portfolio" /></span>
</a>
)}
</div>
</section>
)
}