'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 = { 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 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) => ( // eslint-disable-next-line @next/next/no-img-element ) 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 (

{title}

{(project.kind ?? project.language) &&

{project.kind ?? project.language}

} {project.description &&

{project.description}

} {project.highlights && project.highlights.length > 0 && (
    {project.highlights.map((point) =>
  • {point}
  • )}
)}
{project.tech.length > 0 &&
    {project.tech.map((tag) =>
  • {tag}
  • )}
}

{links.map((link) => ( {link.label} ))}

) } type Props = { projects: Project[] status: 'loading' | 'live' | 'offline' generatedAt?: string profileUrl: string portfolio: Portfolio } export default function Projects({ projects, status, generatedAt, profileUrl, portfolio }: Readonly) { const spans = useMemo(() => planSpans(projects.length), [projects.length]) return (

Selected work

{status === 'live' &&

Read live from Gitea, updated {monthYear(generatedAt)}

}
{status === 'loading' && ( )} {status === 'loading' && ( )} {status === 'offline' && (

The project list could not be loaded right now. All repositories are on {profileUrl.replace('https://', '')}.

)}
{projects.map((project, index) => ( ))}
{status === 'live' && projects.length > 0 && ( There is more in the portfolio {portfolio.count ? `${portfolio.count} projects in total, including the ones above.` : 'The full list of projects, including the ones above.'} {portfolio.label ?? 'Open the portfolio'} )}
) }