Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,11 @@ export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm })
{/* Left: Overview → Gallery */}
<div className="space-y-8" id="content">
<HeritageOverViewSection item={item} />
<HeritageGallery images={item.images} onSelectImage={setLightboxIndex} />
<HeritageGallery
images={item.images}
onSelectImage={setLightboxIndex}
onOpenGallery={() => navigate(`/heritages/${item.id}/gallery`)}
/>
</div>

{/* Right: Sidebar (PC only) */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto max-w-7xl px-4 py-12">
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
← {title}
</button>

<h1 className="mt-2 text-2xl font-semibold">Gallery</h1>
<p className="mt-1 text-sm text-zinc-600">{images.length} photos</p>

<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{images.map((img, index) => (
<button
key={img.id}
type="button"
onClick={() => onSelectImage(index)}
className="group text-left"
aria-label={img.alt ? `Open photo: ${img.alt}` : "Open photo"}
title={img.alt ?? "Open photo"}
>
<figure className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<img
src={img.url}
alt={img.alt ?? ""}
referrerPolicy="no-referrer"
className="aspect-[4/3] w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
<figcaption className="px-2 py-1.5">
<span className="block truncate text-[11px] font-medium text-zinc-500">
{img.credit ? `© ${img.credit}` : " "}
</span>
</figcaption>
</figure>
</button>
))}
</div>

<Lightbox
images={images}
index={lightboxIndex}
onClose={onCloseLightbox}
onNavigate={onNavigateLightbox}
/>
</main>
);
}
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/heritages" element={<div>Index Page</div>} />
<Route path="/heritages/:id" element={<div>Detail Page</div>} />
<Route path="/heritages/:id/gallery" element={<HeritageGalleryContainer />} />
</Routes>
</MemoryRouter>,
);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);

useEffect(() => {
if (!id) navigate("/heritages", { replace: true });
}, [id, navigate]);

const { item, reload, isLoading, isError } = useWorldHeritageDetail(id);

if (isLoading) return <Spinner />;

if (isError) {
return (
<div className="mx-auto max-w-2xl px-4 py-12">
<ErrorPanel message="Failed to load this site." onRetry={reload} />
</div>
);
}

if (!item) return null;

return (
<HeritageGalleryPage
title={item.name}
images={item.images}
lightboxIndex={lightboxIndex}
onSelectImage={setLightboxIndex}
onCloseLightbox={() => setLightboxIndex(null)}
onNavigateLightbox={setLightboxIndex}
onBack={() => navigate(`/heritages/${item.id}`)}
/>
);
}
2 changes: 2 additions & 0 deletions client/src/app/routes/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,6 +17,7 @@ export function AppRoutes() {
<Route path="/heritages" element={<TopPageContainer />} />
<Route path="/heritages/results" element={<SearchHeritageResultsContainer />} />
<Route path="/heritages/criteria/:code" element={<CriteriaDetailContainer />} />
<Route path="/heritages/:id/gallery" element={<HeritageGalleryContainer />} />
<Route path="/heritages/:id" element={<WorldHeritageDetailContainer />} />
<Route path="*" element={<Navigate to="/heritages" replace />} />
</Routes>
Expand Down
Loading