(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 (
- 
-
- )
+ 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 (
+
+ );
+ }
+
+ function shrinkButton() {
+ return (
+
+ );
+ }
+
+ 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 (
+
+ {isVideo ? (
+
+ ) : (
+
+ )}
+
+ {enlargeLevel === 0 && expandButton()}
+ {enlargeLevel === 1 && (
+ <>
+ {shrinkButton()}
+ {expandButton()}
+ >
+ )}
+
+ );
+ }
+
+ function fullscreenOverlay() {
+ if (enlargeLevel !== 2) return null;
+
+ return createPortal(
+
+
+
+
+ {isVideo ? (
+
,
+ // portal target
+ document.body
+ );
+ }
+
+ function thumbnailStrip() {
+ if (totalMedia <= 1) return null;
+
+ return (
+
+
+ {images.map((src, i) => (
+
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 (
+
+ );
+ })}
+
+ );
+ }
+
+ 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 (
<>
-
- {galleryImage(images[active], `${title} screenshot ${active + 1}`)}
+ {inlineMedia()}
-
- {images.length > 1 && (
-
- {images.map((src, i) => (
-

{
- 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",
- }}
- />
- ))}
-
- )}
+ {thumbnailStrip()}
-
-
+ {fullscreenOverlay()}
>
);
}
\ No newline at end of file
diff --git a/data/content.ts b/data/content.ts
index db883fa..1cfaa2c 100644
--- a/data/content.ts
+++ b/data/content.ts
@@ -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",
diff --git a/package-lock.json b/package-lock.json
index 40e2421..31bf362 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/public/img/projects/fam/runthrough.mp4 b/public/img/projects/fam/runthrough.mp4
new file mode 100644
index 0000000..51e9f78
Binary files /dev/null and b/public/img/projects/fam/runthrough.mp4 differ