New design: studio backdrop with a perspective floor and X/Z axis lines, an orientation gizmo that orbits on scroll, Bricolage Grotesque and Instrument Sans (bundled, no build-time network). Projects are laid out as plates by a row planner that keeps every row full for any count; a demo image from the repo root is shown when present, otherwise a generated voxel sculpture. Text and projects still come live from Gitea. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XtcZtnG4GQA1d6mW5PNrS9
109 lines
4.4 KiB
TypeScript
109 lines
4.4 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import { planSpans, type Span } from '@/lib/plan'
|
|
import type { 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
|
|
}
|
|
|
|
export default function Projects({ projects, status, generatedAt, profileUrl }: 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>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|