Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/components/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,17 @@ export default function ProjectCard({
fontFamily: "'JetBrains Mono', monospace",
fontSize: '11px',
color: 'var(--dim)',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexWrap: 'wrap',
}}
>
{featured ? `FEATURED · ${project.num}` : project.num}
<span>{featured ? `FEATURED · ${project.num}` : project.num}</span>
<span style={{ opacity: 0.5 }}>·</span>
<span style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}>
shipped {project.shippedAt}
</span>
</div>
{featured && live && (
<div
Expand Down
224 changes: 201 additions & 23 deletions src/components/ProjectsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { PROJECTS } from '@/lib/projects-static';
import generated from '@/lib/projects.generated.json';
import ProjectCard from '@/components/ProjectCard';
import ProjectFilters, { type Filter } from '@/components/ProjectFilters';

interface GeneratedRepo {
language: string | null;
pushedAt: string;
}

function relativeShipped(iso: string): string {
const then = new Date(iso).getTime();
const now = Date.now();
const days = Math.max(0, Math.round((now - then) / 86400000));
if (days <= 1) return 'today';
if (days < 7) return `${days}d ago`;
if (days < 30) return `${Math.round(days / 7)}w ago`;
if (days < 365) return `${Math.round(days / 30)}mo ago`;
return `${Math.round(days / 365)}y ago`;
}

export default function ProjectsSection() {
const [filter, setFilter] = useState<Filter>('all');
const sectionRef = useRef<HTMLElement>(null);
Expand All @@ -13,6 +31,21 @@ export default function ProjectsSection() {
(p) => filter === 'all' || p.tags.includes(filter as 'fe' | 'be' | 'ds' | 'oss'),
);

const stats = useMemo(() => {
const repos = generated.repos as GeneratedRepo[];
const languages = new Set(repos.map((r) => r.language).filter(Boolean));
const latest = repos.reduce(
(acc, r) => (r.pushedAt > acc ? r.pushedAt : acc),
'0',
);
return {
shipped: PROJECTS.length,
repos: repos.length,
languages: languages.size,
lastShipped: latest === '0' ? '—' : relativeShipped(latest),
};
}, []);

// IntersectionObserver to trigger .reveal → .reveal.in
useEffect(() => {
const section = sectionRef.current;
Expand All @@ -38,6 +71,13 @@ export default function ProjectsSection() {
return () => observer.disconnect();
}, [filter]);

const statTiles: { value: string | number; label: string }[] = [
{ value: stats.shipped, label: 'Projects shipped' },
{ value: stats.repos, label: 'Active repos' },
{ value: stats.languages, label: 'Languages' },
{ value: stats.lastShipped, label: 'Last shipped' },
];

return (
<>
{/* Responsive collapse styles for bento spans */}
Expand All @@ -47,9 +87,12 @@ export default function ProjectsSection() {
grid-column: span 2 !important;
grid-row: span 1 !important;
}
#projects .stats-strip {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 640px) {
#projects { padding: 60px 0 !important; }
#projects { padding: 72px 0 !important; }
#projects .bento {
grid-template-columns: repeat(2, 1fr) !important;
grid-auto-rows: 180px !important;
Expand All @@ -63,14 +106,22 @@ export default function ProjectsSection() {
align-items: stretch !important;
margin-bottom: 28px !important;
}
#projects .projects-foot {
flex-direction: column !important;
align-items: stretch !important;
gap: 12px !important;
}
#projects .projects-lede {
font-size: 15px !important;
}
}
`}</style>

<section
id="projects"
className="projects"
ref={sectionRef}
style={{ padding: '100px 0' }}
style={{ padding: '140px 0' }}
>
<div className="wrap">
{/* Section head */}
Expand All @@ -81,33 +132,112 @@ export default function ProjectsSection() {
<span>{filtered.length} entries</span>
</div>

{/* Projects head: title + filters */}
<div
className="projects-head reveal"
style={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: '24px',
marginBottom: '48px',
flexWrap: 'wrap',
}}
>
{/* Title block: title + lede */}
<div className="reveal" style={{ marginBottom: '40px', maxWidth: '780px' }}>
<h2
className="projects-title"
style={{
fontFamily: "'Space Grotesk', sans-serif",
fontWeight: 700,
fontSize: 'clamp(2.4rem, 5vw, 4.4rem)',
fontSize: 'clamp(2.6rem, 5.4vw, 4.8rem)',
letterSpacing: '-0.04em',
lineHeight: 0.95,
margin: 0,
margin: '0 0 20px',
color: 'var(--text)',
}}
>
Things I&apos;ve shipped{' '}
<span style={{ color: 'var(--magenta)' }}>recently.</span>
</h2>
<p
className="projects-lede"
style={{
fontSize: '17px',
lineHeight: 1.55,
color: 'var(--muted)',
margin: 0,
maxWidth: '640px',
}}
>
A working portfolio of production deploys, side projects, and open-source
experiments — the things I&apos;ve actually pushed to{' '}
<span style={{ color: 'var(--text)' }}>main</span>. Updated automatically
from GitHub, curated by hand.
</p>
</div>

{/* Stats strip */}
<div
className="stats-strip reveal"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '12px',
marginBottom: '48px',
}}
>
{statTiles.map((tile) => (
<div
key={tile.label}
style={{
background: 'rgba(255,255,255,.03)',
border: '1px solid var(--line)',
borderRadius: '12px',
padding: '16px 18px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<div
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '24px',
color: 'var(--text)',
fontWeight: 600,
letterSpacing: '-0.02em',
}}
>
{tile.value}
</div>
<div
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '10px',
color: 'var(--dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}
>
{tile.label}
</div>
</div>
))}
</div>

{/* Filters row */}
<div
className="projects-head reveal"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '24px',
marginBottom: '32px',
flexWrap: 'wrap',
}}
>
<div
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '12px',
color: 'var(--dim)',
textTransform: 'uppercase',
letterSpacing: '0.12em',
}}
>
{'// filter by stack'}
</div>
<ProjectFilters value={filter} onChange={setFilter} />
</div>

Expand All @@ -117,17 +247,65 @@ export default function ProjectsSection() {
style={{
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gap: '18px',
gridAutoRows: '200px',
gap: '20px',
gridAutoRows: '220px',
}}
>
{filtered.map((project) => (
<ProjectCard
key={project.num}
project={project}
/>
<ProjectCard key={project.num} project={project} />
))}
</div>

{/* Bottom CTA strip */}
<div
className="projects-foot reveal"
style={{
marginTop: '40px',
paddingTop: '24px',
borderTop: '1px solid var(--line)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
}}
>
<div
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '12px',
color: 'var(--dim)',
}}
>
{`// showing ${filtered.length} of ${PROJECTS.length} — full archive available`}
</div>
<Link
href="/projects"
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '13px',
color: 'var(--text)',
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
border: '1px solid var(--line)',
borderRadius: '999px',
transition: 'border-color .25s, color .25s, background .25s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--magenta)';
e.currentTarget.style.color = 'var(--magenta)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--line)';
e.currentTarget.style.color = 'var(--text)';
}}
>
View all projects
<span aria-hidden>→</span>
</Link>
</div>
</div>
</section>
</>
Expand Down
10 changes: 10 additions & 0 deletions src/lib/projects-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface BentoProject {
preview: Preview;
span: Span;
href: string;
shippedAt: string;
shippedAtISO: string;
featured?: boolean;
live?: {
stats: { value: string; label: string; up?: boolean }[];
Expand Down Expand Up @@ -224,6 +226,12 @@ const CURATION: Curation[] = [
},
];

function formatShipped(iso: string): string {
const d = new Date(iso);
const month = d.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
return `${month} ${d.getUTCFullYear()}`;
}

export const PROJECTS: BentoProject[] = CURATION.map((c) => {
const r = repo(c.fullName);
return {
Expand All @@ -235,6 +243,8 @@ export const PROJECTS: BentoProject[] = CURATION.map((c) => {
preview: c.preview,
span: c.span,
href: r.homepageUrl ?? r.url,
shippedAt: formatShipped(r.pushedAt),
shippedAtISO: r.pushedAt,
featured: c.featured,
live: c.live,
};
Expand Down
Loading