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()}
</>
);
}