Changed the behavior of the 'more' bar into something less annoying, and re-did the entire project grid component chain.

This commit is contained in:
Hunter
2026-08-11 20:22:54 -04:00
parent 9840ecfcf2
commit 65bd3ec705
11 changed files with 709 additions and 580 deletions

View File

@@ -1,100 +1,114 @@
"use client";
import { PROJECTS } from "@/data/content";
import { useStaggerReveal } from "@/hooks/useAnimations";
import React, { useState, useCallback, useRef, useEffect } from "react";
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";
import {
Layout,
Responsive,
useContainerWidth,
DefaultBreakpoints,
verticalCompactor,
} from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
const ROW_HEIGHT = 100;
const SMALL_BREAKPOINT = 768;
const GAP = 16;
function buildLayoutsFromHeights(activeId: number, heights: Record<string, number>): Record<DefaultBreakpoints, Layout> {
const selected = PROJECTS.find((p) => p.id === activeId);
const rest = PROJECTS.filter((p) => p.id !== activeId);
/** 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;
const h = (id: number, fallback: number) =>
heights[id.toString()] ?? fallback;
/** 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];
function twoCols(): Layout {
let items: Layout = [];
const selH = selected ? h(selected.id, 2) : 0;
if (selected) {
items = items.concat({
i: selected.id.toString(),
x: 0,
y: 0,
w: 2,
h: selH,
});
}
const colY = [selH, selH];
for (const p of rest) {
const pH = h(p.id, 3);
const col = colY[0] <= colY[1] ? 0 : 1;
items = items.concat({ i: p.id.toString(), x: col, y: colY[col], w: 1, h: pH });
colY[col] += pH;
}
return items;
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 {
lg: twoCols(),
md: twoCols(),
sm: twoCols(),
// small screens get the horizontal scroll row
xs: twoCols(),
xxs: twoCols(),
};
return columns;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function Projects() {
const visible = useStaggerReveal(PROJECTS.length, 100);
const { width, containerRef, mounted } = useContainerWidth();
const { width, ref: containerRef, mounted } = useElementWidth<HTMLDivElement>();
const isSmall = mounted && width > 0 && width < SMALL_BREAKPOINT;
const [selectedProject, setSelectedProject] = useState<number>(1);
const selectedRef = useRef(selectedProject);
selectedRef.current = selectedProject;
const [heights, setHeights] = useState<Record<number, number>>({});
const reducedMotion = usePrefersReducedMotion();
const [supportsViewTransition, setSupportsViewTransition] = useState(false);
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(() => buildLayoutsFromHeights(1, {}));
const measuredHeights = useRef<Record<string, number>>({});
const updateHeight = useCallback((projectId: number, heightPx: number) => {
const key = projectId.toString();
const rowH = Math.max(1, Math.ceil(heightPx / ROW_HEIGHT));
useEffect(() => {
setSupportsViewTransition(typeof document.startViewTransition === "function");
}, []);
if (measuredHeights.current[key] === rowH) return;
measuredHeights.current[key] = rowH;
const useTransitions = supportsViewTransition && !reducedMotion;
setLayouts(buildLayoutsFromHeights(selectedRef.current, measuredHeights.current));
}, []);
const selectProject = useCallback(
(id: number) => {
if (!useTransitions) {
setSelectedProject(id);
return;
}
function setSelectedHandler(projectId: number) {
setSelectedProject(projectId);
measuredHeights.current = {};
setLayouts(buildLayoutsFromHeights(projectId, {}));
}
// 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 = (
<>
@@ -125,18 +139,22 @@ const updateHeight = useCallback((projectId: number, heightPx: number) => {
</>
);
/* ---- small screen: horizontal scroll row ---- */
if (isSmall) {
return (
<div style={{ padding: "48px 0", minWidth: 0 }}>
{header}
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"}
ref={containerRef}
className="project-grid"
style={{
display: "flex",
overflowX: "auto",
gap: 16,
gap: GAP,
paddingBottom: 12,
scrollSnapType: "x mandatory",
WebkitOverflowScrolling: "touch",
@@ -151,54 +169,64 @@ const updateHeight = useCallback((projectId: number, heightPx: number) => {
flexShrink: 0,
scrollSnapAlign: "start",
transition: "max-width 0.3s ease",
}}>
}}
>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
updateHeight={updateHeight}
setSelected={selectProject}
/>
</div>
))}
</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>
)}
/* ---- normal: grid layout ---- */
return (
<div style={{ padding: "48px 0", minWidth: 400 }}>
{header}
<div ref={containerRef}>
{mounted && (
<Responsive
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
width={width}
compactor={verticalCompactor}
rowHeight={ROW_HEIGHT}
dragConfig={{ enabled: false }}
resizeConfig={{ enabled: false }}
>
{PROJECTS.map((project, i) => (
<div style={{ display: "flex", gap: GAP, alignItems: "flex-start" }}>
{columns.map((column, colIndex) => (
<div
id={`project-${project.id}`}
key={project.id}>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
updateHeight={updateHeight}
/>
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>
))}
</Responsive>
)}
</div>
</div>
</div>
)}
</div>
);
}
}