Skip to content

Commit 0d528ba

Browse files
authored
feat: enlarge gallery images in a Lightbox with prev/next navigation (#390)
* feat: add base Lightbox overlay shell Add a fullscreen overlay with a close button as the foundation for the gallery image enlargement view. Image rendering is added next. * feat: render selected image and credit in Lightbox Display the enlarged image with its alt text and optional credit inside the overlay added previously. * feat: wire gallery image click to open Lightbox Add lightboxImage state to HeritageDetailLayout and connect HeritageGallery's onSelectImage to it, rendering the Lightbox so clicking a gallery thumbnail enlarges the image. * feat: close Lightbox on Escape key or outside click Improve usability by letting users dismiss the enlarged image via keyboard or by clicking the overlay backdrop, not just the close button. * refactor: move Lightbox out of shared/uis into heritage-detail Lightbox is only used for enlarging gallery images on the heritage detail page, so it doesn't belong in the generic shared UI primitives directory. * feat: add prev/next navigation to Lightbox Switch HeritageGallery's onSelectImage to pass the clicked image's index instead of its data, and have Lightbox track the current index within the full image list. This lets users navigate to the next or previous photo with on-screen arrows (or Left/Right arrow keys) without closing the Lightbox.
1 parent 9599478 commit 0d528ba

3 files changed

Lines changed: 108 additions & 6 deletions

File tree

client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { HeritageHero } from "./HeritageHero";
66
import { HeritageOverViewSection } from "./HeritageOverviewSection";
77
import { HeritageSidebar } from "./HeritageSidebar";
88
import { HeritageGallery } from "./HeritageGallery";
9+
import { Lightbox } from "./Lightbox.tsx";
910
import { DetailHeritageMap } from "@features/top/components/heritage-detail/DetailHeritageMap.tsx";
1011
import { textType } from "@shared/styles/typography";
1112
import { useSetBreadcrumbLabel } from "@features/breadcrumbs/BreadCrumbHooks.ts";
@@ -97,6 +98,7 @@ function KeyExamInfo({ item }: { item: WorldHeritageDetailVm }) {
9798
export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm }) {
9899
const [search, setSearch] = useState<SearchValues>(DEFAULT_SEARCH);
99100
const [isSearchOpen, setIsSearchOpen] = useState(false);
101+
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
100102
const setLabel = useSetBreadcrumbLabel();
101103
const navigate = useNavigate();
102104
const text = useText();
@@ -189,7 +191,7 @@ export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm })
189191
{/* Left: Overview → Gallery */}
190192
<div className="space-y-8" id="content">
191193
<HeritageOverViewSection item={item} />
192-
<HeritageGallery images={item.images} />
194+
<HeritageGallery images={item.images} onSelectImage={setLightboxIndex} />
193195
</div>
194196

195197
{/* Right: Sidebar (PC only) */}
@@ -198,6 +200,13 @@ export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm })
198200
</div>
199201
</div>
200202
</main>
203+
204+
<Lightbox
205+
images={item.images}
206+
index={lightboxIndex}
207+
onClose={() => setLightboxIndex(null)}
208+
onNavigate={setLightboxIndex}
209+
/>
201210
</div>
202211
);
203212
}

client/src/app/features/top/components/heritage-detail/HeritageGallery.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function HeritageGallery({
1010
images: WorldHeritageImageVm[];
1111
previewCount?: number;
1212
onOpenGallery?: () => void;
13-
onSelectImage?: (img: Pick<WorldHeritageImageVm, "id" | "url" | "alt" | "credit">) => void;
13+
onSelectImage?: (index: number) => void;
1414
}) {
1515
if (!images?.length) return null;
1616

@@ -44,13 +44,11 @@ export function HeritageGallery({
4444
</div>
4545

4646
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
47-
{preview.map((img) => (
47+
{preview.map((img, index) => (
4848
<button
4949
key={img.id}
5050
type="button"
51-
onClick={() =>
52-
onSelectImage?.({ id: img.id, url: img.url, alt: img.alt, credit: img.credit })
53-
}
51+
onClick={() => onSelectImage?.(index)}
5452
className="group text-left"
5553
aria-label={img.alt ? `Open photo: ${img.alt}` : "Open photo"}
5654
title={img.alt ?? "Open photo"}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useEffect } from "react";
2+
import CloseIcon from "@mui/icons-material/Close";
3+
import NavigateBeforeIcon from "@mui/icons-material/NavigateBefore";
4+
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
5+
import IconButton from "@shared/uis/Icon-Button.tsx";
6+
import type { WorldHeritageImageVm } from "../../../../../domain/types.ts";
7+
8+
type LightboxImage = Pick<WorldHeritageImageVm, "id" | "url" | "alt" | "credit">;
9+
10+
export function Lightbox({
11+
images,
12+
index,
13+
onClose,
14+
onNavigate,
15+
}: {
16+
images: LightboxImage[];
17+
index: number | null;
18+
onClose: () => void;
19+
onNavigate: (index: number) => void;
20+
}) {
21+
const image = index !== null ? images[index] : null;
22+
const hasPrev = index !== null && index > 0;
23+
const hasNext = index !== null && index < images.length - 1;
24+
25+
useEffect(() => {
26+
if (!image) return;
27+
28+
const handleKeyDown = (e: KeyboardEvent) => {
29+
if (e.key === "Escape") onClose();
30+
if (e.key === "ArrowLeft" && hasPrev) onNavigate(index! - 1);
31+
if (e.key === "ArrowRight" && hasNext) onNavigate(index! + 1);
32+
};
33+
window.addEventListener("keydown", handleKeyDown);
34+
return () => window.removeEventListener("keydown", handleKeyDown);
35+
}, [image, index, hasPrev, hasNext, onClose, onNavigate]);
36+
37+
if (!image) return null;
38+
39+
return (
40+
<div
41+
role="dialog"
42+
aria-modal="true"
43+
onClick={onClose}
44+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
45+
>
46+
<IconButton
47+
onClick={onClose}
48+
aria-label="Close"
49+
className="!absolute !right-4 !top-4 !text-white"
50+
>
51+
<CloseIcon />
52+
</IconButton>
53+
54+
{hasPrev && (
55+
<IconButton
56+
onClick={(e) => {
57+
e.stopPropagation();
58+
onNavigate(index! - 1);
59+
}}
60+
aria-label="Previous photo"
61+
className="!absolute !left-4 !top-1/2 !-translate-y-1/2 !text-white"
62+
>
63+
<NavigateBeforeIcon />
64+
</IconButton>
65+
)}
66+
67+
{hasNext && (
68+
<IconButton
69+
onClick={(e) => {
70+
e.stopPropagation();
71+
onNavigate(index! + 1);
72+
}}
73+
aria-label="Next photo"
74+
className="!absolute !right-4 !top-1/2 !-translate-y-1/2 !text-white"
75+
>
76+
<NavigateNextIcon />
77+
</IconButton>
78+
)}
79+
80+
<figure className="max-h-full max-w-full" onClick={(e) => e.stopPropagation()}>
81+
<img
82+
src={image.url}
83+
alt={image.alt ?? ""}
84+
referrerPolicy="no-referrer"
85+
className="max-h-[85vh] max-w-full rounded-lg object-contain"
86+
/>
87+
{image.credit && (
88+
<figcaption className="mt-2 text-center text-sm text-zinc-300">
89+
© {image.credit}
90+
</figcaption>
91+
)}
92+
</figure>
93+
</div>
94+
);
95+
}

0 commit comments

Comments
 (0)