From c1de7856edb456ae7fc384a8bee3cd163869d1db Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 25 Jun 2026 08:31:58 +0900 Subject: [PATCH 1/4] feat(gallery): add HeritageGalleryPage Renders the full photo grid for a heritage site (no preview cap) and reuses the existing Lightbox for prev/next viewing. --- .../gallery/HeritageGalleryPage.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 client/src/app/features/top/components/heritage-detail/gallery/HeritageGalleryPage.tsx diff --git a/client/src/app/features/top/components/heritage-detail/gallery/HeritageGalleryPage.tsx b/client/src/app/features/top/components/heritage-detail/gallery/HeritageGalleryPage.tsx new file mode 100644 index 0000000..1c5a201 --- /dev/null +++ b/client/src/app/features/top/components/heritage-detail/gallery/HeritageGalleryPage.tsx @@ -0,0 +1,65 @@ +import type { WorldHeritageImageVm } from "../../../../../../domain/types.ts"; +import { Lightbox } from "../Lightbox.tsx"; + +export function HeritageGalleryPage({ + title, + images, + lightboxIndex, + onSelectImage, + onCloseLightbox, + onNavigateLightbox, + onBack, +}: { + title: string; + images: WorldHeritageImageVm[]; + lightboxIndex: number | null; + onSelectImage: (index: number) => void; + onCloseLightbox: () => void; + onNavigateLightbox: (index: number) => void; + onBack: () => void; +}) { + return ( +
+ + +

Gallery

+

{images.length} photos

+ +
+ {images.map((img, index) => ( + + ))} +
+ + +
+ ); +} From 57812779204850f9c155c1b304a97ddd0c7700f7 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 25 Jun 2026 08:32:12 +0900 Subject: [PATCH 2/4] feat(gallery): add HeritageGalleryContainer Fetches the heritage detail by :id and feeds full image list and Lightbox state to HeritageGalleryPage. --- .../heritage-gallery-container.test.tsx | 155 ++++++++++++++++++ .../containers/heritage-gallery-container.tsx | 42 +++++ 2 files changed, 197 insertions(+) create mode 100644 client/src/app/features/top/containers/__tests__/heritage-gallery-container.test.tsx create mode 100644 client/src/app/features/top/containers/heritage-gallery-container.tsx diff --git a/client/src/app/features/top/containers/__tests__/heritage-gallery-container.test.tsx b/client/src/app/features/top/containers/__tests__/heritage-gallery-container.test.tsx new file mode 100644 index 0000000..946c248 --- /dev/null +++ b/client/src/app/features/top/containers/__tests__/heritage-gallery-container.test.tsx @@ -0,0 +1,155 @@ +/** @jest-environment jsdom */ + +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { HeritageGalleryContainer } from "../heritage-gallery-container"; +import { useWorldHeritageDetail } from "../../hooks/use-world-heritage-detail"; +import type { WorldHeritageVm } from "../../../../../domain/types"; + +jest.mock("../../hooks/use-world-heritage-detail", () => ({ + useWorldHeritageDetail: jest.fn(), +})); + +const useWorldHeritageDetailMock = useWorldHeritageDetail as jest.MockedFunction< + (id: string | null | undefined) => { + item: WorldHeritageVm | null; + isLoading: boolean; + isError: boolean; + error: unknown; + reload: () => void; + } +>; + +const buildVm = (overrides: Partial = {}): WorldHeritageVm => ({ + id: 1, + officialName: "Official", + name: "Kyoto", + heritageNameJp: "京都", + country: "Japan", + countryNameJp: "日本", + region: "Asia", + stateParty: "Japan", + category: "Cultural", + criteria: [], + yearInscribed: 2000, + areaHectares: null, + bufferZoneHectares: null, + isEndangered: false, + latitude: null, + longitude: null, + shortDescription: "dummy", + shortDescriptionJp: null, + unescoSiteUrl: null, + statePartyCodes: [], + statePartiesMeta: {}, + primaryStatePartyCode: null, + thumbnailUrl: null, + title: "Kyoto", + subtitle: "Japan · Asia", + displaySubName: null, + displayDescription: "dummy", + areaText: "—", + bufferText: "—", + criteriaText: "", + images: [ + { + id: 1, + url: "https://example.com/1.jpg", + alt: "Photo 1", + credit: null, + width: 800, + height: 600, + isPrimary: true, + }, + { + id: 2, + url: "https://example.com/2.jpg", + alt: "Photo 2", + credit: null, + width: 800, + height: 600, + isPrimary: false, + }, + ], + ...overrides, +}); + +const renderWithRoute = (initialEntry: string) => + render( + + + Index Page} /> + Detail Page} /> + } /> + + , + ); + +describe("HeritageGalleryContainer", () => { + beforeEach(() => { + jest.clearAllMocks(); + useWorldHeritageDetailMock.mockReturnValue({ + item: null, + isLoading: false, + isError: false, + error: null, + reload: jest.fn(), + }); + }); + + test("読み込み中は Spinner を表示する", () => { + useWorldHeritageDetailMock.mockReturnValue({ + item: null, + isLoading: true, + isError: false, + error: null, + reload: jest.fn(), + }); + + renderWithRoute("/heritages/1/gallery"); + expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument(); + }); + + test("エラー時は ErrorPanel を表示する", () => { + useWorldHeritageDetailMock.mockReturnValue({ + item: null, + isLoading: false, + isError: true, + error: new Error("boom"), + reload: jest.fn(), + }); + + renderWithRoute("/heritages/1/gallery"); + expect(screen.getByText("Failed to load this site.")).toBeInTheDocument(); + }); + + test("成功時には全件の画像を表示する", () => { + useWorldHeritageDetailMock.mockReturnValue({ + item: buildVm(), + isLoading: false, + isError: false, + error: null, + reload: jest.fn(), + }); + + renderWithRoute("/heritages/1/gallery"); + + expect(screen.getByText("2 photos")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /Open photo/ })).toHaveLength(2); + }); + + test("戻るボタンを押すと遺産詳細ページへ遷移する", () => { + useWorldHeritageDetailMock.mockReturnValue({ + item: buildVm(), + isLoading: false, + isError: false, + error: null, + reload: jest.fn(), + }); + + renderWithRoute("/heritages/1/gallery"); + + fireEvent.click(screen.getByRole("button", { name: /Kyoto/ })); + expect(screen.getByText("Detail Page")).toBeInTheDocument(); + }); +}); diff --git a/client/src/app/features/top/containers/heritage-gallery-container.tsx b/client/src/app/features/top/containers/heritage-gallery-container.tsx new file mode 100644 index 0000000..c2e703f --- /dev/null +++ b/client/src/app/features/top/containers/heritage-gallery-container.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useWorldHeritageDetail } from "../hooks/use-world-heritage-detail"; +import { HeritageGalleryPage } from "../components/heritage-detail/gallery/HeritageGalleryPage"; +import { Spinner } from "@shared/uis/Spinner.tsx"; +import { ErrorPanel } from "@shared/uis/ErrorPanel.tsx"; + +export function HeritageGalleryContainer() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [lightboxIndex, setLightboxIndex] = useState(null); + + useEffect(() => { + if (!id) navigate("/heritages", { replace: true }); + }, [id, navigate]); + + const { item, reload, isLoading, isError } = useWorldHeritageDetail(id); + + if (isLoading) return ; + + if (isError) { + return ( +
+ +
+ ); + } + + if (!item) return null; + + return ( + setLightboxIndex(null)} + onNavigateLightbox={setLightboxIndex} + onBack={() => navigate(`/heritages/${item.id}`)} + /> + ); +} From d197ba4920bfe5cb4a896249adc12feedbcfdaa2 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 25 Jun 2026 08:34:20 +0900 Subject: [PATCH 3/4] feat(routes): wire /heritages/:id/gallery into AppRoutes --- client/src/app/routes/AppRoutes.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/app/routes/AppRoutes.tsx b/client/src/app/routes/AppRoutes.tsx index 16522c1..3ee64c7 100644 --- a/client/src/app/routes/AppRoutes.tsx +++ b/client/src/app/routes/AppRoutes.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from "react-router-dom"; import TopPageContainer from "@features/top/containers/top-page-container.tsx"; import { WorldHeritageDetailContainer } from "@features/top/containers/world-heritage-detail-container.tsx"; import { CriteriaDetailContainer } from "@features/top/containers/criteria-detail-container.tsx"; +import { HeritageGalleryContainer } from "@features/top/containers/heritage-gallery-container.tsx"; import { SearchHeritageResultsContainer } from "@features/search/containers/search-heritage-result-container.tsx"; import { BreadcrumbProvider } from "@features/breadcrumbs/BreadCrumbProvider.tsx"; import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; @@ -16,6 +17,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> From 9f107cff1096f540b2c0ced65213527e4f8b7dce Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 25 Jun 2026 08:34:47 +0900 Subject: [PATCH 4/4] fix(gallery): wire up the See all button to /heritages/:id/gallery HeritageGallery's "See all" button rendered but did nothing because onOpenGallery was never passed from HeritageDetailLayout. It now navigates to the new full gallery page. Closes #403 --- .../top/components/heritage-detail/HeritageDetailLayout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx index b40150b..6cf8039 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx @@ -191,7 +191,11 @@ export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm }) {/* Left: Overview → Gallery */}
- + navigate(`/heritages/${item.id}/gallery`)} + />
{/* Right: Sidebar (PC only) */}