updated projects display; simplified for mobile

This commit is contained in:
Hunter W.
2026-06-02 16:16:50 -04:00
parent 3f17cf9094
commit 39a056db25
6 changed files with 170 additions and 142 deletions

View File

@@ -143,10 +143,12 @@ body {
background: rgba(110, 231, 183, 0.25);
color: var(--fg);
}
.footer {
flex-direction: row;
height: 28;
}
@media (max-width: 640px) {
h1 {
font-size: 32px !important;
@@ -155,4 +157,5 @@ body {
flex-direction: column;
height: 36;
}
}
}

View File

@@ -10,7 +10,7 @@ type Props = {
visible: boolean;
selected: boolean;
setSelected: (selected: any) => void;
updateHeight: (activeId: number, contentRef: HTMLElement | null) => void;
updateHeight: (projectId: number, heightPx: number) => void;
};
export default function ProjectCard({ project, visible, selected = false, setSelected, updateHeight}: Props) {
@@ -45,9 +45,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setSelected(project.id)}
className="project-card"
style={{
// height: "100%",
minWidth: "10em",
width: "100%",
textDecoration: "none",
padding: 28,
overflow: "hidden",
@@ -86,12 +86,14 @@ export default function ProjectCard({ project, visible, selected = false, setSel
);
}
useEffect(() => {
useEffect(() => {
if (!contentRef.current) return;
const observer = new ResizeObserver(() => {
if (contentRef.current) {
updateHeight(project.id, contentRef.current);
const observer = new ResizeObserver((entries) => {
const height = entries[0]?.borderBoxSize?.[0]?.blockSize
?? entries[0]?.contentRect.height;
if (height > 0) {
updateHeight(project.id, height);
}
});
@@ -106,6 +108,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
ref={contentRef}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className="project-card"
style={{
minWidth: "10em",
overflow: "hidden",

View File

@@ -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)}

View File

@@ -1,6 +1,8 @@
import Image from "next/image";
import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import '@/styles/gallery.css';
type Props = {
images: string[];
@@ -32,7 +34,6 @@ export default function SimpleGallery({
function handleEnlarge() {
if (enlargeLevel === 0) {
setEnlargeLevel(1);
} else if (enlargeLevel === 1) {
setEnlargeLevel(2);
}
@@ -57,26 +58,13 @@ export default function SimpleGallery({
function expandButton() {
return (
<button
onClick={handleEnlarge}
title="Enlarge image"
className="gallery-action-button"
style={{
position: "absolute",
bottom: 8,
right: 8,
width: 28,
height: 28,
borderRadius: 4,
border: "none",
background: "rgba(0,0,0,0.55)",
color: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
backdropFilter: "blur(4px)",
transition: "background 0.15s ease",
}}
top: 8,
right: 8
}}
onClick={handleEnlarge}
title="Enlarge image"
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
}
@@ -97,27 +85,13 @@ export default function SimpleGallery({
function shrinkButton() {
return (
<button
onClick={handleShrink}
title="Shrink image"
className="gallery-action-button"
style={{
position: "absolute",
top: 8,
right: 8,
width: 28,
height: 28,
borderRadius: 4,
border: "none",
background: "rgba(0,0,0,0.55)",
color: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
backdropFilter: "blur(4px)",
zIndex: 1001,
transition: "background 0.15s ease",
bottom: 8,
right: 8
}}
onClick={handleShrink}
title="Shrink image"
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
}

26
styles/gallery.css Normal file
View File

@@ -0,0 +1,26 @@
@import '../app/globals.css';
.gallery-action-button {
position: absolute;
width: 28;
height: 28;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
background: var(--accent-bg);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
backdrop-filter: blur(4px);
z-index: 1001;
transition: background 0.15s ease, border-color 0.15s ease;
}
@media (max-width: 768px) {
.project-card {
height: 100%;
}
}

View File

@@ -17,7 +17,7 @@
{
"name": "next"
}
],
],
"paths": {
"@/*": ["./*"]
}