"use client"; import { PROJECTS, type Project } from "@/data/content"; import { useStaggerReveal, useElementWidth, usePrefersReducedMotion, } from "@/hooks/useAnimations"; import React, { useState, useRef, useEffect, useCallback } from "react"; import { flushSync } from "react-dom"; import ProjectCard from "@/components/ProjectCard"; const SMALL_BREAKPOINT = 768; const GAP = 16; /** Used for a card whose real height hasn't been measured yet. Only affects * column balance -- cards are in normal flow, so a bad guess can never overlap. */ const ESTIMATED_HEIGHT = 280; /** Greedy shortest-column packing. Columns are equal width, so a card's height * doesn't depend on which column holds it -- this is stable, not a feedback loop. */ function packColumns(projects: Project[], heights: Record): Project[][] { const columns: Project[][] = [[], []]; const columnHeights = [0, 0]; for (const project of projects) { const col = columnHeights[0] <= columnHeights[1] ? 0 : 1; columns[col].push(project); columnHeights[col] += heights[project.id] ?? ESTIMATED_HEIGHT; } return columns; } export default function Projects() { const visible = useStaggerReveal(PROJECTS.length, 100); const { width, ref: containerRef, mounted } = useElementWidth(); const isSmall = mounted && width > 0 && width < SMALL_BREAKPOINT; const [selectedProject, setSelectedProject] = useState(1); const [heights, setHeights] = useState>({}); const reducedMotion = usePrefersReducedMotion(); const [supportsViewTransition, setSupportsViewTransition] = useState(false); useEffect(() => { setSupportsViewTransition(typeof document.startViewTransition === "function"); }, []); const useTransitions = supportsViewTransition && !reducedMotion; const selectProject = useCallback( (id: number) => { if (!useTransitions) { setSelectedProject(id); return; } // flushSync is required: startViewTransition snapshots the DOM when the callback returns, so the state change has to be committed by then. document.startViewTransition(() => flushSync(() => setSelectedProject(id))); }, [useTransitions] ); const selected = PROJECTS.find((p) => p.id === selectedProject); const rest = PROJECTS.filter((p) => p.id !== selectedProject); const columns = packColumns(rest, heights); const indexOf = (project: Project) => PROJECTS.indexOf(project); /* ---- height measurement (cosmetic: it only decides column balance) ---- */ const observers = useRef(new Map()); const refCallbacks = useRef(new Map void>()); // Cached so each project keeps a stable ref identity across renders; ref re-fires when the node itself changes, which is what makes this survive the // selected/unselected subtree swap inside ProjectCard. const getCardRef = useCallback((id: number) => { let cb = refCallbacks.current.get(id); if (cb) return cb; cb = (node: HTMLDivElement | null) => { observers.current.get(id)?.disconnect(); observers.current.delete(id); if (!node) return; const observer = new ResizeObserver((entries) => { const height = entries[0]?.borderBoxSize?.[0]?.blockSize ?? entries[0]?.contentRect.height ?? 0; if (height <= 0) return; setHeights((prev) => Math.abs((prev[id] ?? 0) - height) < 1 ? prev : { ...prev, [id]: height } ); }); observer.observe(node); observers.current.set(id, observer); }; refCallbacks.current.set(id, cb); return cb; }, []); useEffect(() => { const active = observers.current; return () => { active.forEach((observer) => observer.disconnect()); active.clear(); }; }, []); const header = ( <>
{"# projects"}

{"Things I've built"}

); return (
{header} {isSmall ? ( /* ---- small screen: horizontal scroll row ---- */
{PROJECTS.map((project, i) => (
))}
) : ( /* ---- wide: selected card full width, the rest packed into two columns ---- */
{selected && (
)}
{columns.map((column, colIndex) => (
{column.map((project) => (
))}
))}
)}
); }