346 lines
9.7 KiB
TypeScript
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()}
|
|
</>
|
|
);
|
|
} |