233 lines
7.2 KiB
TypeScript
233 lines
7.2 KiB
TypeScript
"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<number, number>): 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<HTMLDivElement>();
|
|
const isSmall = mounted && width > 0 && width < SMALL_BREAKPOINT;
|
|
|
|
const [selectedProject, setSelectedProject] = useState<number>(1);
|
|
const [heights, setHeights] = useState<Record<number, number>>({});
|
|
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<number, ResizeObserver>());
|
|
const refCallbacks = useRef(new Map<number, (node: HTMLDivElement | null) => 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 = (
|
|
<>
|
|
<div
|
|
style={{
|
|
marginBottom: 8,
|
|
fontFamily: "var(--mono)",
|
|
fontSize: 11,
|
|
color: "var(--muted)",
|
|
letterSpacing: "0.08em",
|
|
textTransform: "uppercase",
|
|
}}
|
|
>
|
|
{"# projects"}
|
|
</div>
|
|
<h2
|
|
style={{
|
|
fontFamily: "var(--sans)",
|
|
fontSize: 28,
|
|
fontWeight: 700,
|
|
color: "var(--fg)",
|
|
margin: "0 0 32px 0",
|
|
letterSpacing: "-0.01em",
|
|
}}
|
|
>
|
|
{"Things I've built"}
|
|
</h2>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
data-vt={useTransitions ? "on" : "off"}
|
|
style={{ padding: "48px 0", minWidth: 0 }}
|
|
>
|
|
{header}
|
|
|
|
{isSmall ? (
|
|
/* ---- small screen: horizontal scroll row ---- */
|
|
<div
|
|
className="project-grid"
|
|
style={{
|
|
display: "flex",
|
|
overflowX: "auto",
|
|
gap: GAP,
|
|
paddingBottom: 12,
|
|
scrollSnapType: "x mandatory",
|
|
WebkitOverflowScrolling: "touch",
|
|
}}
|
|
>
|
|
{PROJECTS.map((project, i) => (
|
|
<div
|
|
key={project.id}
|
|
style={{
|
|
minWidth: 280,
|
|
maxWidth: selectedProject === project.id ? 500 : 280,
|
|
flexShrink: 0,
|
|
scrollSnapAlign: "start",
|
|
transition: "max-width 0.3s ease",
|
|
}}
|
|
>
|
|
<ProjectCard
|
|
project={project}
|
|
visible={visible.has(i)}
|
|
selected={selectedProject === project.id}
|
|
setSelected={selectProject}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
/* ---- wide: selected card full width, the rest packed into two columns ---- */
|
|
<div style={{ display: "flex", flexDirection: "column", gap: GAP }}>
|
|
{selected && (
|
|
<div style={{ viewTransitionName: `project-${selected.id}` }}>
|
|
<ProjectCard
|
|
project={selected}
|
|
visible={visible.has(indexOf(selected))}
|
|
selected
|
|
setSelected={selectProject}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "flex", gap: GAP, alignItems: "flex-start" }}>
|
|
{columns.map((column, colIndex) => (
|
|
<div
|
|
key={colIndex}
|
|
style={{
|
|
// min-width:0 is required -- flex items default to min-width:auto
|
|
// and would refuse to shrink below their content.
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: GAP,
|
|
}}
|
|
>
|
|
{column.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
ref={getCardRef(project.id)}
|
|
style={{ viewTransitionName: `project-${project.id}` }}
|
|
>
|
|
<ProjectCard
|
|
project={project}
|
|
visible={visible.has(indexOf(project))}
|
|
selected={false}
|
|
setSelected={selectProject}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|