181 lines
5.1 KiB
TypeScript
181 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { PROJECTS } from "@/data/content";
|
|
import { useStaggerReveal } from "@/hooks/useAnimations";
|
|
import React, { useState, useCallback, useRef } from "react";
|
|
|
|
import ProjectCard from "@/components/ProjectCard";
|
|
|
|
import {
|
|
Layout,
|
|
Responsive,
|
|
useContainerWidth,
|
|
horizontalCompactor,
|
|
DefaultBreakpoints,
|
|
} from "react-grid-layout";
|
|
|
|
import "react-grid-layout/css/styles.css";
|
|
import "react-resizable/css/styles.css";
|
|
|
|
export default function Projects() {
|
|
const rowHeight = 100;
|
|
const visible = useStaggerReveal(PROJECTS.length, 100);
|
|
const [selectedProject, setSelectedProject] = useState<number>(1);
|
|
const { width, containerRef, mounted } = useContainerWidth();
|
|
|
|
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(
|
|
() => buildLayouts(1, {})
|
|
);
|
|
|
|
const heightCache = useRef<Record<string, number>>({});
|
|
|
|
function buildLayouts(
|
|
activeId: number,
|
|
heightOverrides: Record<string, number>
|
|
): Record<DefaultBreakpoints, Layout> {
|
|
const isSelected = (id: number) => id === activeId;
|
|
|
|
function h(project: (typeof PROJECTS)[number]) {
|
|
const override = heightOverrides[project.id.toString()];
|
|
if (isSelected(project.id) && override != null) return override;
|
|
if(!isSelected(project.id)) return 1;
|
|
return project.images?.length ? 2 : 1;
|
|
}
|
|
|
|
const selectedProject = PROJECTS.find((p) => isSelected(p.id));
|
|
const selectedH = selectedProject ? h(selectedProject) : 1;
|
|
const nonSelected = PROJECTS.filter((p) => !isSelected(p.id));
|
|
|
|
function twoCol(project: (typeof PROJECTS)[number]) {
|
|
if (isSelected(project.id)) {
|
|
return { i: project.id.toString(), x: 0, y: 0, w: 2, h: h(project) };
|
|
}
|
|
const ni = nonSelected.indexOf(project);
|
|
return {
|
|
i: project.id.toString(),
|
|
x: ni % 2,
|
|
y: Math.floor(ni / 2) + selectedH,
|
|
w: 1,
|
|
h: 3,
|
|
};
|
|
}
|
|
|
|
function singleCol(project: (typeof PROJECTS)[number], colSpan: number) {
|
|
if (isSelected(project.id)) {
|
|
return { i: project.id.toString(), x: 0, y: 0, w: colSpan, h: h(project) };
|
|
}
|
|
const ni = nonSelected.indexOf(project);
|
|
return {
|
|
i: project.id.toString(),
|
|
x: 0,
|
|
y: ni + selectedH,
|
|
w: colSpan,
|
|
h: project.images?.length ? 4 : 2,
|
|
};
|
|
}
|
|
|
|
return {
|
|
lg: PROJECTS.map((p) => twoCol(p)),
|
|
md: PROJECTS.map((p) => twoCol(p)),
|
|
sm: PROJECTS.map((p) => twoCol(p)),
|
|
xs: PROJECTS.map((p) => singleCol(p, 4)),
|
|
xxs: PROJECTS.map((p) => singleCol(p, 4)),
|
|
};
|
|
}
|
|
|
|
const selectedRef = useRef(selectedProject);
|
|
selectedRef.current = selectedProject;
|
|
|
|
const pendingUpdate = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const updateHeight = useCallback(
|
|
(sourceId: number, contentRef: HTMLElement | null) => {
|
|
if (sourceId !== selectedRef.current) return;
|
|
|
|
if (pendingUpdate.current) clearTimeout(pendingUpdate.current);
|
|
pendingUpdate.current = setTimeout(() => {
|
|
const newH = contentRef
|
|
? Math.ceil(contentRef.getBoundingClientRect().height / rowHeight)
|
|
: 2;
|
|
|
|
const key = sourceId.toString();
|
|
if (heightCache.current[key] === newH) return;
|
|
heightCache.current[key] = newH;
|
|
setLayouts((prev) => {
|
|
const next = { ...prev };
|
|
for (const bp in next) {
|
|
next[bp as DefaultBreakpoints] = next[bp as DefaultBreakpoints].map(
|
|
(layout) =>
|
|
layout.i === key ? { ...layout, h: newH } : layout
|
|
);
|
|
}
|
|
return next;
|
|
});
|
|
}, 10);
|
|
},
|
|
[rowHeight]
|
|
);
|
|
|
|
function setSelectedHandler(projectId: number) {
|
|
const id = Number(projectId);
|
|
setSelectedProject(id);
|
|
heightCache.current = {};
|
|
setLayouts(buildLayouts(id, {}));
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: "48px 0", minWidth: 400 }}>
|
|
<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>
|
|
|
|
<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={horizontalCompactor}
|
|
rowHeight={rowHeight}
|
|
dragConfig={{ enabled: false }}
|
|
>
|
|
{PROJECTS.map((project, i) => (
|
|
<div key={project.id} style={{ pointerEvents: "none" }}>
|
|
<ProjectCard
|
|
project={project}
|
|
visible={visible.has(i)}
|
|
selected={selectedProject === project.id}
|
|
setSelected={setSelectedHandler}
|
|
updateHeight={updateHeight}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Responsive>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |