added video for FAM and video support to gallery

This commit is contained in:
Hunter W.
2026-05-31 23:34:21 -04:00
parent dc5cd50b57
commit d979ac7f9b
6 changed files with 436 additions and 155 deletions

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import type { Project } from "@/data/content";
import SimpleGallery from "./SimpleGallery";
type Props = {
project: Project;
visible: boolean;
@@ -14,9 +15,8 @@ type Props = {
export default function ProjectCard({ project, visible, selected = false, setSelected, setExpandedHeight}: Props) {
const [hovered, setHovered] = useState(false);
const [expandedImage, setExpandedImage] = useState(false);
const [currentExpandedHeight, setCurrentExpandedHeight] = useState(0);
function projectTagsComponent(project: Project){
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
{project.tags.map((tag) => (
@@ -123,12 +123,11 @@ export default function ProjectCard({ project, visible, selected = false, setSel
<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) => {
<SimpleGallery images={project.images} videos={project.videos} title={project.title} expansionHandler={(height: number) => {
setExpandedHeight(height);
setExpandedImage(height > 0);
setExpandedImage(height > 0);
}} />
)}

View File

@@ -23,6 +23,7 @@ export default function Projects() {
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);
@@ -35,9 +36,11 @@ export default function Projects() {
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) => {

View File

@@ -1,155 +1,410 @@
import { useState, useEffect } from "react";
import Image from "next/image";
import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
type Props = {
images: string[];
videos?: string[];
title: string;
expansionHandler: (height: number) => void;
};
export default function SimpleGallery({ images, title, expansionHandler }: Props) {
// 0 = inline default, 1 = inline enlarged, 2 = fullscreen overlay
type EnlargeLevel = 0 | 1 | 2;
export default function SimpleGallery({
images,
videos = [],
title,
expansionHandler
}: Props) {
const [active, setActive] = useState(0);
const [enlarged, setEnlarged] = useState(false);
const [enlargeLevel, setEnlargeLevel] = useState<EnlargeLevel>(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
let timerRef: any | null = null;
if (images.length === 0) return null;
const totalMedia = images.length + videos.length;
const isVideo = active >= images.length;
const videoIndex = active - images.length;
const currentSrc = isVideo ? videos[videoIndex] : images[active];
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>
)
if (totalMedia === 0) return null;
function scheduleHeightUpdate(enlarged: boolean) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (!enlarged) {
expansionHandler(0);
return;
}
const el =
document.getElementById("gallery-main-image") ??
document.getElementById("gallery-main-video");
if (el){
let h = el.getBoundingClientRect().height;
let adjAmount = h + 150;
expansionHandler(adjAmount);
}
}, 200);
}
function handleEnlarge() {
if (enlargeLevel === 0) {
setEnlargeLevel(1);
scheduleHeightUpdate(true);
} else if (enlargeLevel === 1) {
setEnlargeLevel(2);
}
}
useEffect(() => {
return () => {
if (timerRef) {
clearTimeout(timerRef);
function handleShrink() {
if (enlargeLevel === 2) {
setEnlargeLevel(1);
} else if (enlargeLevel === 1) {
setEnlargeLevel(0);
scheduleHeightUpdate(false);
}
}
function handleFullscreenClose() {
setEnlargeLevel(1);
}
function handleThumbnailClick(index: number) {
setActive(index);
if (enlargeLevel === 1) {
scheduleHeightUpdate(true);
} else if (enlargeLevel === 2) {
// stay fullscreen with new media
}
}
function expandButton() {
return (
<button
onClick={handleEnlarge}
title="Enlarge image"
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",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.55)")
}
>
<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>
</button>
);
}
function shrinkButton() {
return (
<button
onClick={handleShrink}
title="Shrink image"
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",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "rgba(0,0,0,0.55)")
}
>
<svg viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg" width="14" height="14">
<line x1="15" y1="15" x2="45" y2="45" stroke="white" strokeWidth="6" strokeLinecap="round" />
<line x1="45" y1="15" x2="15" y2="45" stroke="white" strokeWidth="6" strokeLinecap="round" />
</svg>
</button>
);
}
function inlineMedia() {
const enlarged = enlargeLevel === 1;
const imageStyle: React.CSSProperties = enlarged ? {
maxWidth: "100%",
maxHeight: "90vh",
height: "auto",
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,
};
const videoStyle: React.CSSProperties = enlarged ? {
maxWidth: "100%",
maxHeight: "90vh",
objectFit: "contain" as const,
borderRadius: 6,
boxShadow: "0 8px 40px rgba(0,0,0,0.6)",
transition: "all 0.3s ease",
} : {
width: "100%",
borderRadius: 4,
maxHeight: 250,
display: "block",
transition: "all 0.8s ease",
};
return (
<div>
{isVideo ? (
<video
id="gallery-main-video"
key={currentSrc}
src={`/${currentSrc}`}
width={800}
height={600}
controls
ref={videoRef}
style={videoStyle}
/>
) : (
<Image
id="gallery-main-image"
src={`/${currentSrc}`}
alt={`${title} screenshot ${active + 1}`}
width={800}
height={600}
style={imageStyle}
/>
)}
{enlargeLevel === 0 && expandButton()}
{enlargeLevel === 1 && (
<>
{shrinkButton()}
{expandButton()}
</>
)}
</div>
);
}
function fullscreenOverlay() {
if (enlargeLevel !== 2) return null;
return createPortal(
<div
onClick={handleFullscreenClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.85)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<button
onClick={handleFullscreenClose}
title="Close"
style={{
position: "fixed",
top: 16,
right: 16,
width: 36,
height: 36,
borderRadius: "50%",
border: "none",
background: "rgba(255,255,255,0.15)",
color: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
backdropFilter: "blur(4px)",
zIndex: 10000,
transition: "background 0.15s ease",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "rgba(255,255,255,0.3)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "rgba(255,255,255,0.15)")
}
>
<svg viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg" width="18" height="18">
<line x1="15" y1="15" x2="45" y2="45" stroke="white" strokeWidth="6" strokeLinecap="round" />
<line x1="45" y1="15" x2="15" y2="45" stroke="white" strokeWidth="6" strokeLinecap="round" />
</svg>
</button>
{isVideo ? (
<video
key={`fs-${currentSrc}`}
src={`/${currentSrc}`}
controls
autoPlay
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: "95vw",
maxHeight: "90vh",
borderRadius: 8,
boxShadow: "0 8px 40px rgba(0,0,0,0.6)",
}}
/>
) : (
<Image
src={`/${currentSrc}`}
alt={`${title} fullscreen ${active + 1}`}
width={1600}
height={1200}
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: "95vw",
maxHeight: "90vh",
borderRadius: 8,
objectFit: "contain",
boxShadow: "0 8px 40px rgba(0,0,0,0.6)",
}}
/>
)}
</div>,
// portal target
document.body
);
}
function thumbnailStrip() {
if (totalMedia <= 1) return null;
return (
<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
{images.map((src, i) => (
<Image
key={`thumb-img-${i}`}
src={`/${src}`}
alt={`${title} thumbnail ${i + 1}`}
width={48}
height={36}
onClick={() => handleThumbnailClick(i)}
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",
}}
/>
))}
{videos.map((_, i) => {
const mediaIndex = images.length + i;
const isActive = mediaIndex === active;
return (
<button
key={`thumb-vid-${i}`}
onClick={() => handleThumbnailClick(mediaIndex)}
style={{
width: 48,
height: 36,
borderRadius: 3,
cursor: "pointer",
border: `2px solid ${isActive ? "var(--accent)" : "var(--border)"}`,
opacity: isActive ? 1 : 0.6,
transition: "all 0.15s ease",
pointerEvents: "auto",
background: "var(--surface, #1a1a1a)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3L13 8L5 13V3Z" fill="var(--accent, #888)" />
</svg>
</button>
);
})}
</div>
);
}
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
}, []);
// close fullscreen on Escape
useEffect(() => {
if (enlargeLevel === 0) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && enlargeLevel === 2) handleFullscreenClose();
if (e.key === "Escape" && enlargeLevel === 1) handleShrink();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enlargeLevel]);
return (
<>
<div style={{ marginBottom: 16 }}>
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
{galleryImage(images[active], `${title} screenshot ${active + 1}`)}
{inlineMedia()}
</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>
)}
{thumbnailStrip()}
</div>
{fullscreenOverlay()}
</>
);
}

View File

@@ -15,8 +15,10 @@ export type Project = {
slug: string;
title: string;
description: string;
details?: string;
tags: string[];
images?: string[];
videos?: string[];
link: string;
year: string;
};
@@ -32,10 +34,6 @@ export const PROFILE = {
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
] satisfies SocialLink[],
contactMethods: [{
masked: true,
label: "478-331-2258",
icon: "tel"
}, {
masked: true,
label: "contact@hwilliams.dev",
icon: "email"
@@ -57,10 +55,26 @@ export const PROJECTS: Project[] = [{
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.",
details: `The source for this project is private, but I am happy to
discuss the architecture and design decisions in detail during
an interview.
This application was deployed for a major convention in May 2026, where it achieved a pretty wild amount of success:
- Single day registration of over 10,000 guests with an average processing time of about 45 seconds.
- No single guest wait time greater than 10 minutes.
The previous year at the same convention with the same attendance levels experienced major wait times, and physical lines that extended around multiple floors of a large convention space. Some
attendees waited in line for hours.
The platform consists of a Vue 3 frontend and a configurable backend interface.
It features a dynamic UX emphasized flow-based interface that allows agents to navigate complex tasks efficiently, and with minimal training.
A real-world solution for event staff and volunteer management.
`,
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"],
videos: [ "img/projects/fam/runthrough.mp4" ]
},{
id: 2,
slug: "clean-space",

40
package-lock.json generated
View File

@@ -2510,9 +2510,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
"version": "2.10.33",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -2908,9 +2908,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.363",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz",
"integrity": "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==",
"version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
"integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
"dev": true,
"license": "ISC"
},
@@ -4509,10 +4509,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -5531,9 +5541,9 @@
}
},
"node_modules/react-draggable": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
@@ -6225,9 +6235,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {

Binary file not shown.