{options.map((option) => (
onChange(option.value)}
diff --git a/src/components/ui/LazyImage.tsx b/src/components/ui/LazyImage.tsx
new file mode 100644
index 0000000..7bdf71f
--- /dev/null
+++ b/src/components/ui/LazyImage.tsx
@@ -0,0 +1,68 @@
+import { useEffect, useRef, useState } from "react";
+
+export type LazyImageFit = "cover" | "contain";
+
+interface LazyImageProps {
+ src: string;
+ alt: string;
+ /** 바깥 박스(= 사진이 들어갈 자리)에 줄 클래스. 크기·위치는 여기서 정한다 */
+ className?: string;
+ /** 열자마자 보이는 자리라 지연 없이 바로 받아야 할 때 */
+ isEager?: boolean;
+ /**
+ * cover(기본): 자리를 꽉 채우고 넘치는 부분은 잘린다 — 카드·썸네일처럼 자리 비율이 정해진 곳.
+ * contain: 사진 비율을 지키고 자리 안에 다 들어온다 — 사진 자체가 콘텐츠인 전체 화면 뷰어.
+ */
+ fit?: LazyImageFit;
+}
+
+const FIT_CLASS_NAMES: Record = {
+ contain: "object-contain",
+ cover: "object-cover",
+};
+
+// 사진이 도착하기 전에는 회색 자리(Figma의 이미지 슬롯과 같은 thumbnail-placeholder)를 보여주고,
+// 도착하면 페이드인한다. 자리 크기는 바깥 박스가 들고 있어서 사진이 늦게 와도 레이아웃이 밀리지 않는다.
+// 받는 시점은 브라우저 기본 지연 로딩(loading="lazy")에 맡긴다 — IntersectionObserver를 직접 두면
+// 같은 일을 두 번 하게 되고, 스크롤 컨테이너가 중첩된 이 앱에서 기준을 또 관리해야 한다.
+function LazyImage({
+ src,
+ alt,
+ className = "",
+ isEager = false,
+ fit = "cover",
+}: LazyImageProps) {
+ const [isLoaded, setIsLoaded] = useState(false);
+ const imageRef = useRef(null);
+
+ // 캐시에 있는 사진은 React가 onLoad를 붙이기 전에 이미 완료돼서 이벤트가 안 온다.
+ // 마운트 시점에 complete를 한 번 확인해서 회색 자리에 멈춰 있지 않게 한다.
+ useEffect(() => {
+ if (imageRef.current?.complete) {
+ setIsLoaded(true);
+ }
+ }, []);
+
+ return (
+
+ setIsLoaded(true)}
+ ref={imageRef}
+ src={src}
+ />
+
+ );
+}
+
+export default LazyImage;
diff --git a/src/components/ui/PhotoGallery.tsx b/src/components/ui/PhotoGallery.tsx
index 1ca4f8a..8121a44 100644
--- a/src/components/ui/PhotoGallery.tsx
+++ b/src/components/ui/PhotoGallery.tsx
@@ -1,49 +1,124 @@
import { PageCounter } from "@wanteddev/wds";
import { IconChevronLeft, IconChevronRight } from "@wanteddev/wds-icon";
-import { type ReactNode, type UIEvent, useMemo, useRef, useState } from "react";
+import {
+ type ReactNode,
+ type UIEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
-interface PhotoGalleryProps {
+export interface PhotoGalleryPhoto {
+ src: string;
+ alt: string;
+}
+
+interface PhotoGalleryBaseProps {
idPrefix: string;
+ // 슬라이드 뒤에 깔리는 배경(아카이빙 뷰어의 흐린 사진). 스크롤 영역 밖이라 같이 넘어가지 않는다.
+ background?: ReactNode;
// 뒤로가기 버튼처럼 이미지 위에 얹히는 오버레이(화면마다 위치·존재 여부가 다르다 — 예: 행사
// 상세는 헤더 없이 이미지 위 오버레이 버튼, 공지 상세는 ScreenHeader를 따로 쓴다).
overlay?: ReactNode;
- photoCount: number;
showCounter: boolean;
slideClassName: string;
+ /** photos를 줄 때 각 사진 에 줄 클래스 — 기본은 슬라이드를 꽉 채운다 */
+ photoClassName?: string;
+ /** 갤러리 루트에 덧붙일 클래스 — 전체 화면을 채우는 뷰어처럼 바깥 배치가 다른 경우에 쓴다 */
+ className?: string;
+ /** 처음 보여줄 장(1부터). 누른 사진에서 열리는 뷰어가 쓴다 */
+ initialPage?: number;
+ onPageChange?: (page: number) => void;
+ /** 슬라이드 스냅 기준 — 기본은 start, 전체 화면 뷰어는 center */
+ snapAlign?: "start" | "center";
+ /** true면 세게 밀어도 여러 장을 건너뛰지 않고 다음 한 장에서 멈춘다(scroll-snap-stop: always) */
+ snapStop?: boolean;
+ /** 카운터 위치 — 기본은 오른쪽 아래 모서리 */
+ counterClassName?: string;
+ /** 어두운 사진 위에 올라가는 카운터(Figma Page Indicator/Counter의 Alternative=True) */
+ counterAlternative?: boolean;
}
-// 사진 여러 장을 가로 스크롤 스냅으로 넘기는 공용 갤러리 — 공지 상세·행사 상세가 공유한다.
-// 웹(hover 가능 기기)에서는 화살표 버튼이 호버 시 뜨고, 모바일(터치)에서는 스와이프로만
-// 넘긴다. Figma에는 없는 Stream 자체 인터랙션이라 화면 쪽 디자인이 아니라 여기서 새로 만들었다.
+// 실 이미지 API가 붙은 화면은 photos로 실제 사진을, 아직 장수만 아는 화면은 photoCount로
+// 회색 placeholder를 그린다.
+type PhotoGalleryProps = PhotoGalleryBaseProps &
+ (
+ | { photos: PhotoGalleryPhoto[]; photoCount?: never }
+ | { photoCount: number; photos?: never }
+ );
+
+const SNAP_ALIGN_CLASS_NAMES = {
+ center: "snap-center",
+ start: "snap-start",
+} as const;
+
+// 사진 여러 장을 가로 스크롤 스냅으로 넘기는 공용 갤러리 — 공지 상세·행사 상세·아카이빙 사진
+// 뷰어가 공유한다. 웹(hover 가능 기기)에서는 화살표 버튼이 호버 시 뜨고, 모바일(터치)에서는
+// 스와이프로만 넘긴다. Figma에는 없는 Stream 자체 인터랙션이라 화면 쪽 디자인이 아니라 여기서
+// 새로 만들었다.
function PhotoGallery({
idPrefix,
+ background,
overlay,
photoCount,
+ photos,
showCounter,
slideClassName,
+ photoClassName = "size-full object-cover",
+ className = "",
+ initialPage = 1,
+ onPageChange,
+ snapAlign = "start",
+ snapStop = false,
+ counterClassName = "right-5 bottom-5",
+ counterAlternative = false,
}: PhotoGalleryProps) {
- const [currentPage, setCurrentPage] = useState(1);
+ const totalPages = photos?.length ?? photoCount ?? 0;
+ const [currentPage, setCurrentPage] = useState(initialPage);
const galleryRef = useRef(null);
- const hasMultiplePhotos = photoCount > 1;
+ const hasMultiplePhotos = totalPages > 1;
// 실 이미지 API 전까지는 장수만 알고 URL이 없어서, 슬라이드 key를 미리 만들어 둔다
- // (map 콜백의 index를 key로 쓰면 noArrayIndexKey에 걸린다).
+ // (map 콜백의 index를 key로 쓰면 noArrayIndexKey에 걸린다). 실제 사진도 같은 파일이 여러 장
+ // 올 수 있어서 URL이 아니라 순서로 구분한다.
const photoKeys = useMemo(
() =>
Array.from(
- { length: photoCount },
+ { length: totalPages },
(_, index) => `${idPrefix}-photo-${index}`,
),
- [idPrefix, photoCount],
+ [idPrefix, totalPages],
);
+ // 처음 열 때 initialPage 위치로 맞춰둔다. 이후 넘기는 건 사용자 스크롤이 담당한다.
+ useEffect(() => {
+ const el = galleryRef.current;
+ if (el) {
+ el.scrollLeft = (initialPage - 1) * el.offsetWidth;
+ }
+ }, [initialPage]);
+
// 스크롤 위치로 현재 장을 역산한다.
+ // Safari의 탄성 오버스크롤(rubber-band)은 scrollLeft가 범위를 넘어설 수 있어서 유효 범위로 클램프한다
+ // (같은 처리가 FeedbacksDetailScreen에도 있다 — 안 하면 마지막 장을 세게 밀 때 "27 / 26"이 된다).
const handleGalleryScroll = (event: UIEvent) => {
- const { scrollLeft, offsetWidth } = event.currentTarget;
- if (offsetWidth === 0) {
+ const { scrollLeft, scrollWidth, clientWidth } = event.currentTarget;
+ if (clientWidth === 0) {
+ return;
+ }
+ const maxScrollLeft = Math.max(scrollWidth - clientWidth, 0);
+ const clampedScrollLeft = Math.min(Math.max(scrollLeft, 0), maxScrollLeft);
+ const page = Math.min(
+ Math.round(clampedScrollLeft / clientWidth) + 1,
+ totalPages,
+ );
+ // 스크롤 이벤트는 손가락을 따라 계속 오지만, 장이 실제로 바뀔 때만 알린다.
+ if (page === currentPage) {
return;
}
- setCurrentPage(Math.round(scrollLeft / offsetWidth) + 1);
+ setCurrentPage(page);
+ onPageChange?.(page);
};
// 화살표 클릭(웹 전용)은 프로그래밍적으로 한 장만큼 스크롤한다 — 스와이프와 같은 스냅 위치로 맞춰진다.
@@ -55,19 +130,46 @@ function PhotoGallery({
el.scrollTo({ behavior: "smooth", left: (page - 1) * el.offsetWidth });
};
+ const slideSnapClassName = `${SNAP_ALIGN_CLASS_NAMES[snapAlign]}${
+ snapStop ? " snap-always" : ""
+ }`;
+
return (
-
+
+ {background}
+
+ {/* relative: background(absolute)가 배치된 요소라, 일반 흐름인 슬라이드보다 위에 그려진다.
+ 슬라이드 영역도 배치된 요소로 만들어서 DOM 순서대로 배경 위에 오게 한다. */}
- {/* 하단 Home Bar 자리 — env()라 앱 WebView(네이티브가 이미 인셋)에서는 0이 된다.
- 데스크톱 프레임에서는 Figma대로 34px을 흉내 낸다(BottomNav와 같은 규칙). */}
-
+
);
}
diff --git a/src/features/archives/ArchivesPhotoViewerScreen.tsx b/src/features/archives/ArchivesPhotoViewerScreen.tsx
index 22a7bfa..f6e24f3 100644
--- a/src/features/archives/ArchivesPhotoViewerScreen.tsx
+++ b/src/features/archives/ArchivesPhotoViewerScreen.tsx
@@ -1,20 +1,20 @@
-import { IconButton, PageCounter } from "@wanteddev/wds";
+import { IconButton } from "@wanteddev/wds";
import { IconClose } from "@wanteddev/wds-icon";
-import { useEffect, useRef, useState } from "react";
+import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
+import PhotoGallery from "@/components/ui/PhotoGallery";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { ARCHIVES_DETAIL } from "@/features/archives/constants/archivesDetail";
// Figma: 현장 사진 하나 클릭 시 (nodeId 1526:171364)
-// 가로 scroll-snap으로 한 장씩 넘긴다 — 터치 스와이프·트랙패드는 브라우저 기본 동작으로 처리된다.
-// 각 장에 snap-always(scroll-snap-stop: always)를 줘서 세게 밀어도 여러 장을 건너뛰지 않고 한 장에서 멈춘다.
+// 넘기기·카운터는 공지/행사 상세와 같은 공용 PhotoGallery를 쓰고, 이 화면만의 흐린 배경과
+// 닫기 버튼을 background/overlay 슬롯으로 넘긴다. 세게 밀어도 한 장만 넘어가도록 snapStop을 켠다.
// 사진 목록은 API 연동 전까지 archiveId와 무관하게 상세 화면과 같은 목업을 쓴다.
function ArchivesPhotoViewerScreen() {
const navigate = useNavigate();
- const { photoIndex } = useParams();
+ const { archiveId, photoIndex } = useParams();
const { title, photos } = ARCHIVES_DETAIL;
- const scrollerRef = useRef(null);
// 주소의 photoIndex가 숫자가 아니거나 범위를 벗어나면 첫 장/마지막 장으로 맞춘다
const initialIndex = Math.min(
@@ -23,79 +23,60 @@ function ArchivesPhotoViewerScreen() {
);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
- // 처음 열 때 누른 사진 위치로 스크롤해 둔다. 이후 넘기는 건 사용자 스크롤이 담당한다.
- useEffect(() => {
- const scroller = scrollerRef.current;
- if (scroller) {
- scroller.scrollLeft = initialIndex * scroller.clientWidth;
- }
- }, [initialIndex]);
-
- const handleScroll = () => {
- const scroller = scrollerRef.current;
- if (scroller && scroller.clientWidth > 0) {
- setCurrentIndex(Math.round(scroller.scrollLeft / scroller.clientWidth));
- }
- };
-
return (
- {/* Figma Background blur — 현재 사진을 크게 흐리게 깔아 위아래 빈 공간을 채운다.
- Figma의 711×877 고정값 대신 화면을 채우고 확대하는 방식으로 둔다 — 앱 WebView는 높이가
- 제각각이라 고정 픽셀이면 긴 화면에서 위아래에 빈 띠가 남는다(375×812 프레임 기준 값이었다).
- 아래 캐러셀의 같은 사진이 이름을 가지므로 여기는 장식으로 둔다(alt=""). */}
-
-
-
- {photos.map((photo, index) => (
-
- {/* Figma Image 375×463 */}
-
+ }
+ className="h-full"
+ counterAlternative
+ /* Figma Page Indicator/Counter — 화면 하단에서 47px 위, 가운데 */
+ counterClassName="inset-x-0 bottom-[47px] flex justify-center"
+ idPrefix={`archives-${archiveId}`}
+ initialPage={initialIndex + 1}
+ onPageChange={(page) => setCurrentIndex(page - 1)}
+ overlay={
+ // 슬라이드 영역이 화면을 꽉 채우므로, 헤더는 absolute로 그 위에 띄운다
+
-
- navigate(-1)}
- size={24}
- variant="normal"
- >
-
-
}
- variant="floating"
+ photos={photos.map((photo, index) => ({
+ alt: `${title} 현장 사진 ${index + 1}`,
+ src: photo,
+ }))}
+ showCounter
+ /* Figma Image 375×463 — 화면 높이 안에서 세로 가운데에 둔다 */
+ /* 가로는 화면을 꽉 채우고 세로는 사진 비율대로 둔다 — 자리를 375×463으로 고정하면
+ 비율이 다른 사진에 좌우 여백이 생긴다. 아주 긴 사진만 max-h-full로 화면 안에 맞춘다. */
+ photoClassName="max-h-full w-full object-contain"
+ slideClassName="flex h-full items-center"
+ snapAlign="center"
+ snapStop
/>
-
- {/* Figma Page Indicator/Counter(Size=Small, Alternative=True) — 화면 하단에서 47px 위 */}
-
-
-
);
}
diff --git a/src/features/archives/ArchivesPhotosScreen.tsx b/src/features/archives/ArchivesPhotosScreen.tsx
index a0f60dd..00f3bb6 100644
--- a/src/features/archives/ArchivesPhotosScreen.tsx
+++ b/src/features/archives/ArchivesPhotosScreen.tsx
@@ -2,6 +2,8 @@ import { TopNavigationButton } from "@wanteddev/wds";
import { IconChevronLeft } from "@wanteddev/wds-icon";
import { useNavigate, useParams } from "react-router-dom";
+import LazyImage from "@/components/ui/LazyImage";
+import ScreenBottomSafeArea from "@/components/ui/ScreenBottomSafeArea";
import ScreenHeader from "@/components/ui/ScreenHeader";
import { useScreenHeader } from "@/components/ui/useScreenHeader";
import { ARCHIVES_DETAIL } from "@/features/archives/constants/archivesDetail";
@@ -42,17 +44,15 @@ function ArchivesPhotosScreen() {
type="button"
>
{/* 버튼 안에 글자가 없어서 이 alt가 곧 버튼 이름이다(아카이빙 목록 카드는 제목·날짜가 그 역할을 한다) */}
-
))}
- {/* 하단 Home Bar 자리 — env()라 앱 WebView(네이티브가 이미 인셋)에서는 0이 된다.
- 데스크톱 프레임에서는 Figma대로 34px을 흉내 낸다(BottomNav와 같은 규칙). */}
-
+
);
}
diff --git a/src/features/archives/components/ArchivesPhotoCard.tsx b/src/features/archives/components/ArchivesPhotoCard.tsx
index eb66119..12cd985 100644
--- a/src/features/archives/components/ArchivesPhotoCard.tsx
+++ b/src/features/archives/components/ArchivesPhotoCard.tsx
@@ -1,5 +1,7 @@
import { Typography } from "@wanteddev/wds";
+import LazyImage from "@/components/ui/LazyImage";
+
// Figma: Archives Photo Card (Left-Large 1276:95411, Left-Medium 1276:95412, Right-Small 1276:95416)
export type ArchivesPhotoCardSize = "small" | "medium" | "large";
@@ -32,11 +34,8 @@ function ArchivesPhotoCard({
onClick={onClick}
type="button"
>
-
+ {/* 화면 밖 카드의 사진은 스크롤로 가까워질 때 받는다 — 그 전에는 회색 자리가 보인다 */}
+
void;
+}
+
+// Figma: 아카이빙 목록 Photo Grid (nodeId 1276:95409)의 한 열.
+// 2열 매스너리라 열마다 카드 높이 순서가 달라서, 열을 컴포넌트 단위로 둔다.
+function ArchivesPhotoColumn({
+ items,
+ sizes,
+ onItemClick,
+}: ArchivesPhotoColumnProps) {
+ return (
+