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 type { Project } from "@/data/content";
|
||||||
import SimpleGallery from "./SimpleGallery";
|
import SimpleGallery from "./SimpleGallery";
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
project: Project;
|
project: Project;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -14,9 +15,8 @@ type Props = {
|
|||||||
|
|
||||||
export default function ProjectCard({ project, visible, selected = false, setSelected, setExpandedHeight}: Props) {
|
export default function ProjectCard({ project, visible, selected = false, setSelected, setExpandedHeight}: Props) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
const [expandedImage, setExpandedImage] = useState(false);
|
const [expandedImage, setExpandedImage] = useState(false);
|
||||||
|
const [currentExpandedHeight, setCurrentExpandedHeight] = useState(0);
|
||||||
function projectTagsComponent(project: Project){
|
function projectTagsComponent(project: Project){
|
||||||
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
|
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
|
||||||
{project.tags.map((tag) => (
|
{project.tags.map((tag) => (
|
||||||
@@ -125,10 +125,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{project.images && project.images.length > 0 && (
|
{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);
|
setExpandedHeight(height);
|
||||||
setExpandedImage(height > 0);
|
setExpandedImage(height > 0);
|
||||||
|
|
||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default function Projects() {
|
|||||||
const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits);
|
const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits);
|
||||||
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(updateLayout(1));
|
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(updateLayout(1));
|
||||||
|
|
||||||
|
|
||||||
function setExpandedHeight(height: number) {
|
function setExpandedHeight(height: number) {
|
||||||
const gridUnits = height === 0 ? defaultExpandedHeightUnits : Math.ceil(height / rowHeight);
|
const gridUnits = height === 0 ? defaultExpandedHeightUnits : Math.ceil(height / rowHeight);
|
||||||
setTopCellHeight(gridUnits);
|
setTopCellHeight(gridUnits);
|
||||||
@@ -35,9 +36,11 @@ export default function Projects() {
|
|||||||
setLayouts(updateLayout(id, topCellHeight)); // pass both
|
setLayouts(updateLayout(id, topCellHeight)); // pass both
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function updateLayout(activeId: number, cellHeight?: number) : Record<DefaultBreakpoints, Layout> {
|
function updateLayout(activeId: number, cellHeight?: number) : Record<DefaultBreakpoints, Layout> {
|
||||||
const currentSelected = activeId ?? selectedProject;
|
const currentSelected = activeId ?? selectedProject;
|
||||||
const currentCellHeight = cellHeight ?? topCellHeight;
|
const currentCellHeight = cellHeight ?? topCellHeight;
|
||||||
|
|
||||||
let isSelected = (projectId: number) => projectId === currentSelected;
|
let isSelected = (projectId: number) => projectId === currentSelected;
|
||||||
return {
|
return {
|
||||||
"sm": PROJECTS.map((project, i) => {
|
"sm": PROJECTS.map((project, i) => {
|
||||||
|
|||||||
@@ -1,71 +1,91 @@
|
|||||||
import { useState, useEffect } from "react";
|
import Image from "next/image";
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
images: string[];
|
images: string[];
|
||||||
|
videos?: string[];
|
||||||
title: string;
|
title: string;
|
||||||
expansionHandler: (height: number) => void;
|
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 [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;
|
const totalMedia = images.length + videos.length;
|
||||||
if (images.length === 0) return null;
|
const isVideo = active >= images.length;
|
||||||
|
const videoIndex = active - images.length;
|
||||||
|
const currentSrc = isVideo ? videos[videoIndex] : images[active];
|
||||||
|
|
||||||
function galleryImage(src: string, alt: string) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
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
|
<button
|
||||||
onClick={() => {
|
onClick={handleEnlarge}
|
||||||
setEnlarged(!enlarged);
|
title="Enlarge image"
|
||||||
clearTimeout(timerRef);
|
style={{
|
||||||
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",
|
position: "absolute",
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
@@ -83,55 +103,235 @@ export default function SimpleGallery({ images, title, expansionHandler }: Props
|
|||||||
backdropFilter: "blur(4px)",
|
backdropFilter: "blur(4px)",
|
||||||
transition: "background 0.15s ease",
|
transition: "background 0.15s ease",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.background = "rgba(0,0,0,0.8)")}
|
onMouseEnter={(e) =>
|
||||||
onMouseLeave={e => (e.currentTarget.style.background = "rgba(0,0,0,0.55)")}>
|
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
|
||||||
|
}
|
||||||
{!enlarged && <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
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="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="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="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" />
|
<path d="M13 13L8.5 8.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
</svg>}
|
</svg>
|
||||||
{enlarged && <svg viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg" width="60" height="60">
|
</button>
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shrinkButton() {
|
||||||
useEffect(() => {
|
return (
|
||||||
return () => {
|
<button
|
||||||
if (timerRef) {
|
onClick={handleShrink}
|
||||||
clearTimeout(timerRef);
|
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 (
|
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 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: 16 }}>
|
{shrinkButton()}
|
||||||
|
{expandButton()}
|
||||||
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
|
</>
|
||||||
{galleryImage(images[active], `${title} screenshot ${active + 1}`)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{images.length > 1 && (
|
function fullscreenOverlay() {
|
||||||
<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
|
if (enlargeLevel !== 2) return null;
|
||||||
{images.map((src, i) => (
|
|
||||||
<img
|
return createPortal(
|
||||||
key={i}
|
<div
|
||||||
src={src}
|
onClick={handleFullscreenClose}
|
||||||
alt={`${title} thumbnail ${i + 1}`}
|
style={{
|
||||||
onClick={() =>{
|
position: "fixed",
|
||||||
setActive(i)
|
inset: 0,
|
||||||
clearTimeout(timerRef);
|
background: "rgba(0,0,0,0.85)",
|
||||||
timerRef = setTimeout(() => {
|
zIndex: 9999,
|
||||||
const imgElement = document.getElementById("gallery-image") as HTMLImageElement | null;
|
display: "flex",
|
||||||
imgElement?.height && expansionHandler(imgElement.height + 70);
|
alignItems: "center",
|
||||||
}, 200); // delay to allow for CSS transition
|
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={{
|
style={{
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 36,
|
height: 36,
|
||||||
@@ -145,11 +345,66 @@ export default function SimpleGallery({ images, title, expansionHandler }: Props
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{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%" }}>
|
||||||
|
{inlineMedia()}
|
||||||
|
</div>
|
||||||
|
{thumbnailStrip()}
|
||||||
|
</div>
|
||||||
|
{fullscreenOverlay()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,8 +15,10 @@ export type Project = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
details?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
videos?: string[];
|
||||||
link: string;
|
link: string;
|
||||||
year: string;
|
year: string;
|
||||||
};
|
};
|
||||||
@@ -32,10 +34,6 @@ export const PROFILE = {
|
|||||||
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
|
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
|
||||||
] satisfies SocialLink[],
|
] satisfies SocialLink[],
|
||||||
contactMethods: [{
|
contactMethods: [{
|
||||||
masked: true,
|
|
||||||
label: "478-331-2258",
|
|
||||||
icon: "tel"
|
|
||||||
}, {
|
|
||||||
masked: true,
|
masked: true,
|
||||||
label: "contact@hwilliams.dev",
|
label: "contact@hwilliams.dev",
|
||||||
icon: "email"
|
icon: "email"
|
||||||
@@ -57,10 +55,26 @@ export const PROJECTS: Project[] = [{
|
|||||||
slug: "flowbased-agent-management",
|
slug: "flowbased-agent-management",
|
||||||
title: "Flow-Based Agent Management platform",
|
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.",
|
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"],
|
tags: ["Vue", "Node.JS", "Event-Management", "Real-Time", "Full-Stack"],
|
||||||
link: "private",
|
link: "private",
|
||||||
year: "2026",
|
year: "2026",
|
||||||
images: [ "img/projects/fam/example-welcome.png", "img/projects/fam/example-step-in-flow.png"],
|
images: [ "img/projects/fam/example-welcome.png", "img/projects/fam/example-step-in-flow.png"],
|
||||||
|
videos: [ "img/projects/fam/runthrough.mp4" ]
|
||||||
},{
|
},{
|
||||||
id: 2,
|
id: 2,
|
||||||
slug: "clean-space",
|
slug: "clean-space",
|
||||||
|
|||||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -2510,9 +2510,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.32",
|
"version": "2.10.33",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||||
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
|
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.cjs"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
@@ -2908,9 +2908,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.363",
|
"version": "1.5.364",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
|
||||||
"integrity": "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==",
|
"integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -4509,10 +4509,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -5531,9 +5541,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-draggable": {
|
"node_modules/react-draggable": {
|
||||||
"version": "4.5.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
|
||||||
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
|
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -6225,9 +6235,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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