diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index 72276d2..dd160eb 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -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 (
{project.tags.map((tag) => ( @@ -123,12 +123,11 @@ export default function ProjectCard({ project, visible, selected = false, setSel

{project.description}

- + {project.images && project.images.length > 0 && ( - { + { setExpandedHeight(height); - setExpandedImage(height > 0); - + setExpandedImage(height > 0); }} /> )} diff --git a/components/Projects.tsx b/components/Projects.tsx index 792df8c..4f1f207 100644 --- a/components/Projects.tsx +++ b/components/Projects.tsx @@ -23,6 +23,7 @@ export default function Projects() { const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits); const [layouts, setLayouts] = useState>(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 { const currentSelected = activeId ?? selectedProject; const currentCellHeight = cellHeight ?? topCellHeight; + let isSelected = (projectId: number) => projectId === currentSelected; return { "sm": PROJECTS.map((project, i) => { diff --git a/components/SimpleGallery.tsx b/components/SimpleGallery.tsx index 5f99a03..dc962bb 100644 --- a/components/SimpleGallery.tsx +++ b/components/SimpleGallery.tsx @@ -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(0); + const timerRef = useRef | null>(null); + const videoRef = useRef(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 ( -
{`${title} -
- ) + 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 ? ( +
+ ); + } + + 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) => ( + {`${title} 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) => ( - {`${title}{ - 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