Files
hwilliams-dev/components/SimpleGallery.tsx
2026-06-04 01:54:38 -04:00

346 lines
9.7 KiB
TypeScript

import Image from "next/image";
import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import '@/styles/gallery.css';
type Props = {
images: string[];
videos?: string[];
title: string;
};
// 0 = inline default, 1 = inline enlarged, 2 = fullscreen overlay
type EnlargeLevel = 0 | 1 | 2;
export default function SimpleGallery({
images,
videos = [],
title
}: Props) {
const [active, setActive] = useState(0);
const [enlargeLevel, setEnlargeLevel] = useState<EnlargeLevel>(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const totalMedia = images.length + videos.length;
const isVideo = active >= images.length;
const videoIndex = active - images.length;
const currentSrc = isVideo ? videos[videoIndex] : images[active];
if (totalMedia === 0) return null;
function handleEnlarge() {
if (enlargeLevel === 0) {
setEnlargeLevel(1);
} else if (enlargeLevel === 1) {
setEnlargeLevel(2);
}
}
function handleShrink() {
if (enlargeLevel === 2) {
setEnlargeLevel(1);
} else if (enlargeLevel === 1) {
setEnlargeLevel(0);
}
}
function handleFullscreenClose() {
setEnlargeLevel(1);
}
function handleThumbnailClick(index: number) {
setActive(index);
}
function expandButton() {
return (
<button
className="gallery-action-button"
style={{
top: 8,
right: 8
}}
onClick={handleEnlarge}
title="Enlarge image"
>
<svg className="action-button-svg" stroke="current" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5V1H5" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M13 9V13H9" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M1 1L5.5 5.5" strokeWidth="1.5" strokeLinecap="round" />
<path d="M13 13L8.5 8.5" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
);
}
function shrinkButton() {
return (
<button
className="gallery-action-button"
style={{
bottom: 8,
right: 8
}}
onClick={handleShrink}
title="Shrink image"
>
<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: 8 }}>
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
{inlineMedia()}
</div>
{thumbnailStrip()}
</div>
{fullscreenOverlay()}
</>
);
}