204 lines
5.6 KiB
TypeScript
204 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { PROJECTS } from "@/data/content";
|
|
import { useStaggerReveal } from "@/hooks/useAnimations";
|
|
import React, { useState, useCallback, useRef, useEffect } from "react";
|
|
|
|
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;
|
|
|
|
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);
|
|
|
|
const h = (id: number, fallback: number) =>
|
|
heights[id.toString()] ?? fallback;
|
|
|
|
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;
|
|
}
|
|
|
|
return {
|
|
lg: twoCols(),
|
|
md: twoCols(),
|
|
sm: twoCols(),
|
|
|
|
// small screens get the horizontal scroll row
|
|
xs: twoCols(),
|
|
xxs: twoCols(),
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export default function Projects() {
|
|
const visible = useStaggerReveal(PROJECTS.length, 100);
|
|
const { width, containerRef, mounted } = useContainerWidth();
|
|
const isSmall = mounted && width > 0 && width < SMALL_BREAKPOINT;
|
|
|
|
const [selectedProject, setSelectedProject] = useState<number>(1);
|
|
const selectedRef = useRef(selectedProject);
|
|
selectedRef.current = selectedProject;
|
|
|
|
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));
|
|
|
|
if (measuredHeights.current[key] === rowH) return;
|
|
measuredHeights.current[key] = rowH;
|
|
|
|
setLayouts(buildLayoutsFromHeights(selectedRef.current, measuredHeights.current));
|
|
}, []);
|
|
|
|
function setSelectedHandler(projectId: number) {
|
|
setSelectedProject(projectId);
|
|
measuredHeights.current = {};
|
|
setLayouts(buildLayoutsFromHeights(projectId, {}));
|
|
}
|
|
|
|
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>
|
|
</>
|
|
);
|
|
|
|
/* ---- small screen: horizontal scroll row ---- */
|
|
if (isSmall) {
|
|
return (
|
|
<div style={{ padding: "48px 0", minWidth: 0 }}>
|
|
{header}
|
|
<div
|
|
className={"project-grid"}
|
|
ref={containerRef}
|
|
style={{
|
|
display: "flex",
|
|
overflowX: "auto",
|
|
gap: 16,
|
|
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={setSelectedHandler}
|
|
updateHeight={updateHeight}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</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
|
|
id={`project-${project.id}`}
|
|
key={project.id}>
|
|
<ProjectCard
|
|
project={project}
|
|
visible={visible.has(i)}
|
|
selected={selectedProject === project.id}
|
|
setSelected={setSelectedHandler}
|
|
updateHeight={updateHeight}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Responsive>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |