Files
EpicNext-Cms/src/components/public/photo-lightbox.tsx
T
2026-07-11 20:52:56 +02:00

165 lines
5.1 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
/**
* Client-side photo gallery for the public Photos page. Renders the same
* card grid as before, but each tile is now a button that opens the photo in
* a full-screen lightbox overlay. The overlay is a fixed, full-viewport layer
* toggled by state — closes on backdrop click, on the ✕ button, or with Esc.
*
* Photos are pre-shaped on the server (caption strings already translated) so
* this component carries no prisma/i18n dependency.
*/
export type LightboxPhoto = {
id: string;
url: string;
alt: string;
date: string;
user: string;
};
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
// Index of the photo shown in the lightbox, or null when closed.
const [openIndex, setOpenIndex] = useState<number | null>(null);
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
const active = openIndex !== null ? photos[openIndex] : null;
const close = useCallback(() => setOpenIndex(null), []);
// Esc-to-close + lock background scroll while the overlay is open.
useEffect(() => {
if (active == null) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [active, close]);
return (
<>
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p, i) => (
<button
key={p.id}
type="button"
onClick={() => setOpenIndex(i)}
className="card hover"
style={{
padding: 0,
overflow: "hidden",
border: 0,
background: "var(--color-surface)",
cursor: "zoom-in",
textAlign: "left",
font: "inherit",
color: "inherit",
display: "block",
width: "100%",
}}
aria-label={p.alt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.url} alt={p.alt} style={{ width: "100%", height: "auto", display: "block" }} />
<div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.date}
</p>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.user}
</p>
</div>
</button>
))}
</div>
{active ? (
<div
onClick={close}
role="dialog"
aria-modal="true"
aria-label={active.alt}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(8px)",
cursor: "zoom-out",
}}
>
<button
type="button"
onClick={close}
aria-label="Close"
style={{
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
✕
</button>
{/* Stop propagation so clicking the image itself doesn't close. */}
<figure
onClick={(e) => e.stopPropagation()}
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</div>
) : null}
</>
);
}