added project gallery behavior

This commit is contained in:
Hunter W.
2026-05-31 09:44:59 -04:00
parent 59e7589a29
commit 28a672c82b
8 changed files with 1053 additions and 640 deletions

View File

@@ -1,103 +1,161 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import type { Project } from "@/data/content";
import SimpleGallery from "./SimpleGallery";
type Props = {
project: Project;
visible: boolean;
selected: boolean;
setSelected: (selected: any) => void;
setExpandedHeight: (activeId: number) => void;
};
export default function ProjectCard({ project, visible }: Props) {
export default function ProjectCard({ project, visible, selected = false, setSelected, setExpandedHeight}: Props) {
const [hovered, setHovered] = useState(false);
return (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "block",
textDecoration: "none",
padding: 28,
background: hovered ? "var(--surface-hover)" : "var(--surface)",
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
borderRadius: 8,
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
transform: visible
? hovered
? "translateY(-3px)"
: "translateY(0)"
: "translateY(12px)",
opacity: visible ? 1 : 0,
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 12,
}}
>
const [expandedImage, setExpandedImage] = useState(false);
function projectTagsComponent(project: Project){
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
{project.tags.map((tag) => (
<span key={tag}
style={{
fontFamily: "var(--mono)",
fontSize: 11,
fontWeight: 500,
color: "var(--accent)",
padding: "3px 10px",
background: "var(--accent-bg)",
borderRadius: 4,
letterSpacing: "0.02em",
}}>
{tag}
</span>
))}
</div>);
}
function notSelectedComponent(){
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setSelected(project.id)}
style={{
height: "100%",
minWidth: "10em",
textDecoration: "none",
padding: 28,
overflow: "hidden",
background: hovered ? "var(--surface-hover)" : "var(--surface)",
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
borderRadius: 8,
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
transform: visible
? hovered
? "translateY(-3px)"
: "translateY(0)"
: "translateY(12px)",
opacity: visible ? 1 : 0,
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
pointerEvents: "auto",
cursor: "pointer"
}}>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 18,
fontWeight: 700,
color: hovered ? "var(--accent)" : "var(--fg)",
transition: "color 0.2s",
}}
>
{project.title}
</div>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
padding: "2px 8px",
background: "var(--bg)",
borderRadius: 4,
border: "1px solid var(--border)",
}}
>
{project.year}
style={{display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12,
}}>
<div
style={{fontFamily: "var(--mono)", fontSize: 18, fontWeight: 700, color: hovered ? "var(--accent)" : "var(--fg)", transition: "color 0.2s"}}>
{project.title}
</div>
<div style={{fontFamily: "var(--mono)", fontSize: 11, color: "var(--muted)", padding: "2px 8px", background: "var(--bg)", borderRadius: 4, border: "1px solid var(--border)"}}>
{project.year}
</div>
</div>
{projectTagsComponent(project)}
<p style={{fontFamily: "var(--sans)", fontSize: 14, lineHeight: 1.6, color: "var(--fg-secondary)", margin: "0 0 16px 0"}}>
{project.description}
</p>
{project.images && project.images.length > 0 && imgComponent()}
</div>
<p
);
}
function selectedComponent(){
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setSelected(project.id)}
style={{
fontFamily: "var(--sans)",
fontSize: 14,
lineHeight: 1.6,
color: "var(--fg-secondary)",
margin: "0 0 16px 0",
}}
>
{project.description}
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{project.tags.map((tag) => (
<span
key={tag}
style={{
fontFamily: "var(--mono)",
fontSize: 11,
fontWeight: 500,
color: "var(--accent)",
padding: "3px 10px",
background: "var(--accent-bg)",
borderRadius: 4,
letterSpacing: "0.02em",
}}
>
{tag}
</span>
))}
height: "100%",
minWidth: "10em",
overflow: "hidden",
textDecoration: "none",
padding: 28,
background: hovered ? "var(--surface-hover)" : "var(--surface)",
border: `2px solid var(--accent)`,
borderRadius: 8,
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
transform: visible
? "translateY(-3px)"
: "translateY(0)",
opacity: visible ? 1 : 0,
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
pointerEvents: "auto",
cursor: "pointer"
}}>
<div
style={{display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12
}}>
<div
style={{fontFamily: "var(--mono)", fontSize: 18, fontWeight: 700, color: hovered ? "var(--accent)" : "var(--fg)", transition: "color 0.2s"}}>
{project.title}
</div>
<div style={{fontFamily: "var(--mono)", fontSize: 11, color: "var(--muted)", padding: "2px 8px", background: "var(--bg)", borderRadius: 4, border: "1px solid var(--border)"}}>
{project.year}
</div>
</div>
{projectTagsComponent(project)}
<p style={{fontFamily: "var(--sans)", fontSize: 14, lineHeight: 1.6, color: "var(--fg-secondary)", margin: "0 0 16px 0"}}>
{project.description}
</p>
{project.images && project.images.length > 0 && (
<SimpleGallery images={project.images} title={project.title} expansionHandler={(height: number) => {
setExpandedHeight(height);
setExpandedImage(height > 0);
}} />
)}
</div>
</a>
);
);
}
function imgComponent(){
return (
<img src={project.images?.[0]} alt={`${project.title} screenshot`} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", maxHeight: 180}} />
);
}
useEffect(() => {
if (!selected) {
if (expandedImage) {
setExpandedHeight(0);
setExpandedImage(false);
}
}
});
if (selected) {
return selectedComponent();
} else {
return notSelectedComponent();
}
}

View File

@@ -2,13 +2,94 @@
import { PROJECTS } from "@/data/content";
import { useStaggerReveal } from "@/hooks/useAnimations";
import ProjectCard from "./ProjectCard";
import React, { useState, useCallback } from 'react';
import ProjectCard from "@/components/ProjectCard";
import { Layout, Responsive, useContainerWidth, horizontalCompactor, verticalCompactor, DefaultBreakpoints } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
export default function Projects() {
const visible = useStaggerReveal(PROJECTS.length, 100);
const rowHeight = 250;
const defaultExpandedHeightUnits = 2;
const visible = useStaggerReveal(PROJECTS.length, 100);
const [selectedProject, setSelectedProject] = useState<number>(1);
const { width, containerRef, mounted } = useContainerWidth();
const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits);
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(updateLayout(1));
function setExpandedHeight(height: number) {
const gridUnits = height === 0 ? defaultExpandedHeightUnits : Math.ceil(height / rowHeight);
setTopCellHeight(gridUnits);
setLayouts(updateLayout(selectedProject, gridUnits)); // pass both
}
function setSelectedHandler(projectId: number) {
const id = Number(projectId);
setSelectedProject(id);
setLayouts(updateLayout(id, topCellHeight)); // pass both
}
function updateLayout(activeId: number, cellHeight?: number) : Record<DefaultBreakpoints, Layout> {
const currentSelected = activeId ?? selectedProject;
const currentCellHeight = cellHeight ?? topCellHeight;
let isSelected = (projectId: number) => projectId === currentSelected;
return {
"sm": PROJECTS.map((project, i) => {
return {
i: project.id.toString(),
x: isSelected(project.id) ? 0 : (i % 2) + 1,
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
w: isSelected(project.id) ? 2 : 1,
h: project.images && project.images.length > 0 ? currentCellHeight : 1
};
}),
"xs": PROJECTS.map((project, i) => {
return {
i: project.id.toString(),
x: 0,
y: i,
w: 2,
h: project.images && project.images.length > 0 ? currentCellHeight : 1
};
}),
"xxs": PROJECTS.map((project, i) => {
return {
i: project.id.toString(),
x: 0,
y: i,
w: 2,
h: project.images && project.images.length > 0 ? currentCellHeight : 1
};
}),
"md": PROJECTS.map((project, i) => {
return {
i: project.id.toString(),
x: isSelected(project.id) ? 0 : (i % 2) + 1,
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
w: isSelected(project.id) ? 2 : 1,
h: project.images && project.images.length > 0 ? currentCellHeight : 1
};
}),
"lg": PROJECTS.map((project, i) => {
return {
i: project.id.toString(),
x: isSelected(project.id) ? 0 : (i % 2) + 1,
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
w: isSelected(project.id) ? 2 : 1,
h: project.images && project.images.length > 0 ? currentCellHeight : 1
};
}),
};
};
return (
<div style={{ padding: "48px 0" }}>
<div style={{ padding: "48px 0"}}>
<div
style={{
marginBottom: 8,
@@ -33,21 +114,33 @@ export default function Projects() {
>
{"Things I've built"}
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
gap: 16,
}}
>
<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) => (
<ProjectCard
key={project.id}
<div key={project.id} style={{pointerEvents: "none"}}>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
setExpandedHeight={setExpandedHeight}
/>
</div>
))}
</Responsive>)}
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect } from "react";
type Props = {
images: string[];
title: string;
expansionHandler: (height: number) => void;
};
export default function SimpleGallery({ images, title, expansionHandler }: Props) {
const [active, setActive] = useState(0);
const [enlarged, setEnlarged] = useState(false);
let timerRef: any | null = null;
if (images.length === 0) return null;
function galleryImage(src: string, alt: string) {
return (
<div><img
id="gallery-image"
src={images[active]}
alt={`${title} screenshot ${active + 1}`}
style={enlarged ? {
maxWidth: "100%",
maxHeight: "90vh",
borderRadius: 6,
objectFit: "contain",
boxShadow: "0 8px 40px rgba(0,0,0,0.6)",
transition: "all 0.3s ease",} : {
width: "100%",
borderRadius: 4,
objectFit: "cover",
maxHeight: 250,
display: "block",
transition: "all 0.8s ease",
zIndex: 1,
}}
/>
<button
onClick={() => {
setEnlarged(!enlarged);
clearTimeout(timerRef);
timerRef = setTimeout(() => {
const imgElement = document.getElementById("gallery-image") as HTMLImageElement | null;
imgElement?.height && expansionHandler( !enlarged ? imgElement.height + 70 : 0);
}, 200); // delay to allow for CSS transition
}}
title={enlarged ? "Shrink image" : "Enlarge image"}
style={
enlarged ? {
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",
fontSize: 18,
lineHeight: 1,
backdropFilter: "blur(4px)",
zIndex: 1001,
transition: "background 0.15s ease",
} : {
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",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(0,0,0,0.8)")}
onMouseLeave={e => (e.currentTarget.style.background = "rgba(0,0,0,0.55)")}>
{!enlarged && <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5V1H5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M13 9V13H9" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M1 1L5.5 5.5" stroke="white" strokeWidth="1.5" strokeLinecap="round"/>
<path d="M13 13L8.5 8.5" stroke="white" strokeWidth="1.5" strokeLinecap="round"/>
</svg>}
{enlarged && <svg viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg" width="60" height="60">
<line x1="15" y1="15" x2="45" y2="45" stroke="currentColor" strokeWidth="6" strokeLinecap="round"/>
<line x1="45" y1="15" x2="15" y2="45" stroke="currentColor" strokeWidth="6" strokeLinecap="round"/>
</svg>}
</button></div>
)
}
useEffect(() => {
return () => {
if (timerRef) {
clearTimeout(timerRef);
}
};
}, []);
return (
<>
<div style={{ marginBottom: 16 }}>
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
{galleryImage(images[active], `${title} screenshot ${active + 1}`)}
</div>
{images.length > 1 && (
<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
{images.map((src, i) => (
<img
key={i}
src={src}
alt={`${title} thumbnail ${i + 1}`}
onClick={() =>{
setActive(i)
clearTimeout(timerRef);
timerRef = setTimeout(() => {
const imgElement = document.getElementById("gallery-image") as HTMLImageElement | null;
imgElement?.height && expansionHandler(imgElement.height + 70);
}, 200); // delay to allow for CSS transition
}}
style={{
width: 48,
height: 36,
objectFit: "cover",
borderRadius: 3,
cursor: "pointer",
border: `2px solid ${i === active ? "var(--accent)" : "var(--border)"}`,
opacity: i === active ? 1 : 0.6,
transition: "all 0.15s ease",
pointerEvents: "auto",
}}
/>
))}
</div>
)}
</div>
</>
);
}

View File

@@ -16,6 +16,7 @@ export type Project = {
title: string;
description: string;
tags: string[];
images?: string[];
link: string;
year: string;
};
@@ -25,7 +26,7 @@ export const PROFILE = {
name: "Hunter W",
title: "Software Engineer",
email: "contact@hwilliams.dev",
bio: "I build user experiences before I ever write code. Experience with full-stack development across a variety of frameworks and languages including JS [Vue, React, Next.JS, Nuxt, Node.JS, Express], Python [Flask, FastAPI, Gunicorn], and more.",
bio: "I build user experiences before I write code. Experience with full-stack development across a variety of frameworks and languages including Vue, React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more.",
links: [
{ label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" },
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
@@ -35,7 +36,7 @@ export const PROFILE = {
label: "478-331-2258",
icon: "tel"
}, {
masked: false,
masked: true,
label: "contact@hwilliams.dev",
icon: "email"
}
@@ -53,6 +54,15 @@ export const SITE = {
export const PROJECTS: Project[] = [{
id: 1,
slug: "flowbased-agent-management",
title: "Flow-Based Agent Management platform",
description: "The Flow-based Agent Managemment (FAM) platform is a real-world example of an Agent support tool designed to streamline high-volume in-person Agent to Guest interactions in various scenarios.",
tags: ["Vue", "Node.JS", "Event-Management", "Real-Time", "Full-Stack"],
link: "private",
year: "2026",
images: [ "img/projects/fam/example-welcome.png", "img/projects/fam/example-step-in-flow.png"],
},{
id: 2,
slug: "clean-space",
title: "Clean Space",
description: "Clean Space is an architecture, proof of concept, and an in-development tool for Space Engineers and Space Engineers 2 server owners featuring client and server side .",

1176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-grid-layout" : "^2.2.3",
"next-themes": "^0.4.6"
},
"devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB