added video for FAM and video support to gallery
This commit is contained in:
@@ -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);
|
||||
}} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
40
package-lock.json
generated
@@ -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": {
|
||||
|
||||
BIN
public/img/projects/fam/runthrough.mp4
Normal file
BIN
public/img/projects/fam/runthrough.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user