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
132 lines
5.8 KiB
TypeScript
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>
|
|
)
|
|
}
|