updated projects display; simplified for mobile
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PROJECTS } from "@/data/content";
|
||||
import { useStaggerReveal } from "@/hooks/useAnimations";
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
|
||||
import ProjectCard from "@/components/ProjectCard";
|
||||
|
||||
@@ -10,121 +10,94 @@ import {
|
||||
Layout,
|
||||
Responsive,
|
||||
useContainerWidth,
|
||||
horizontalCompactor,
|
||||
DefaultBreakpoints,
|
||||
verticalCompactor,
|
||||
} 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 ROW_HEIGHT = 100;
|
||||
const SMALL_BREAKPOINT = 768;
|
||||
|
||||
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(
|
||||
() => buildLayouts(1, {})
|
||||
);
|
||||
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 heightCache = useRef<Record<string, number>>({});
|
||||
const h = (id: number, fallback: number) =>
|
||||
heights[id.toString()] ?? fallback;
|
||||
|
||||
function buildLayouts(
|
||||
activeId: number,
|
||||
heightOverrides: Record<string, number>
|
||||
): Record<DefaultBreakpoints, Layout> {
|
||||
const isSelected = (id: number) => id === activeId;
|
||||
function twoCols(): Layout {
|
||||
let items: Layout = [];
|
||||
const selH = selected ? h(selected.id, 2) : 0;
|
||||
|
||||
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) };
|
||||
|
||||
if (selected) {
|
||||
items = items.concat({
|
||||
i: selected.id.toString(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 2,
|
||||
h: selH,
|
||||
});
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
const ni = nonSelected.indexOf(project);
|
||||
return {
|
||||
i: project.id.toString(),
|
||||
x: 0,
|
||||
y: ni + selectedH,
|
||||
w: colSpan,
|
||||
h: project.images?.length ? 4 : 2,
|
||||
};
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
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)),
|
||||
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 pendingUpdate = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
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));
|
||||
|
||||
const updateHeight = useCallback(
|
||||
(sourceId: number, contentRef: HTMLElement | null) => {
|
||||
if (sourceId !== selectedRef.current) return;
|
||||
if (measuredHeights.current[key] === rowH) return;
|
||||
measuredHeights.current[key] = rowH;
|
||||
|
||||
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]
|
||||
);
|
||||
setLayouts(buildLayoutsFromHeights(selectedRef.current, measuredHeights.current));
|
||||
}, []);
|
||||
|
||||
function setSelectedHandler(projectId: number) {
|
||||
const id = Number(projectId);
|
||||
setSelectedProject(id);
|
||||
heightCache.current = {};
|
||||
setLayouts(buildLayouts(id, {}));
|
||||
}
|
||||
function setSelectedHandler(projectId: number) {
|
||||
setSelectedProject(projectId);
|
||||
measuredHeights.current = {};
|
||||
setLayouts(buildLayoutsFromHeights(projectId, {}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "48px 0", minWidth: 400 }}>
|
||||
const header = (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
@@ -149,21 +122,70 @@ export default function Projects() {
|
||||
>
|
||||
{"Things I've built"}
|
||||
</h2>
|
||||
</>
|
||||
);
|
||||
|
||||
/* ---- small screen: horizontal scroll row ---- */
|
||||
if (isSmall) {
|
||||
return (
|
||||
<div style={{ padding: "48px 0", minWidth: 0 }}>
|
||||
{header}
|
||||
<div
|
||||
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={horizontalCompactor}
|
||||
rowHeight={rowHeight}
|
||||
compactor={verticalCompactor}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
dragConfig={{ enabled: false }}
|
||||
resizeConfig={{ enabled: false }}
|
||||
>
|
||||
{PROJECTS.map((project, i) => (
|
||||
<div key={project.id} style={{ pointerEvents: "none" }}>
|
||||
<div
|
||||
id={`project-${project.id}`}
|
||||
key={project.id}>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
visible={visible.has(i)}
|
||||
|
||||
Reference in New Issue
Block a user