From ca6519430a20bec94c52bb0060f11b50fada72fd Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 23 Sep 2026 15:14:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=C2=B7=ED=96=89?= =?UTF-8?q?=EC=82=AC=20=EC=83=81=EC=84=B8=20=EC=82=AC=EC=A7=84=20=EA=B0=A4?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84+?= =?UTF-8?q?=ED=98=B8=EB=B2=84=20=ED=99=94=EC=82=B4=ED=91=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/PhotoGallery.tsx | 115 +++++++++++++++++++ src/components/ui/ScreenLayout.tsx | 6 +- src/features/events/EventsDetailScreen.tsx | 79 ++++--------- src/features/notices/NoticesDetailScreen.tsx | 47 ++++---- 4 files changed, 166 insertions(+), 81 deletions(-) create mode 100644 src/components/ui/PhotoGallery.tsx diff --git a/src/components/ui/PhotoGallery.tsx b/src/components/ui/PhotoGallery.tsx new file mode 100644 index 0000000..e22a148 --- /dev/null +++ b/src/components/ui/PhotoGallery.tsx @@ -0,0 +1,115 @@ +import { PageCounter } from "@wanteddev/wds"; +import { IconChevronLeft, IconChevronRight } from "@wanteddev/wds-icon"; +import { type ReactNode, type UIEvent, useMemo, useRef, useState } from "react"; + +interface PhotoGalleryProps { + idPrefix: string; + // 뒤로가기 버튼처럼 이미지 위에 얹히는 오버레이(화면마다 위치·존재 여부가 다르다 — 예: 행사 + // 상세는 헤더 없이 이미지 위 오버레이 버튼, 공지 상세는 ScreenHeader를 따로 쓴다). + overlay?: ReactNode; + photoCount: number; + showCounter: boolean; + slideClassName: string; +} + +// 사진 여러 장을 가로 스크롤 스냅으로 넘기는 공용 갤러리 — 공지 상세·행사 상세가 공유한다. +// 웹(hover 가능 기기)에서는 화살표 버튼이 호버 시 뜨고, 모바일(터치)에서는 스와이프로만 +// 넘긴다. Figma에는 없는 Stream 자체 인터랙션이라 화면 쪽 디자인이 아니라 여기서 새로 만들었다. +function PhotoGallery({ + idPrefix, + overlay, + photoCount, + showCounter, + slideClassName, +}: PhotoGalleryProps) { + const [currentPage, setCurrentPage] = useState(1); + const galleryRef = useRef(null); + const hasMultiplePhotos = photoCount > 1; + + // 실 이미지 API 전까지는 장수만 알고 URL이 없어서, 슬라이드 key를 미리 만들어 둔다 + // (map 콜백의 index를 key로 쓰면 noArrayIndexKey에 걸린다). + const photoKeys = useMemo( + () => + Array.from( + { length: photoCount }, + (_, index) => `${idPrefix}-photo-${index}`, + ), + [idPrefix, photoCount], + ); + + // 스크롤 위치로 현재 장을 역산한다. + const handleGalleryScroll = (event: UIEvent) => { + const { scrollLeft, offsetWidth } = event.currentTarget; + if (offsetWidth === 0) { + return; + } + setCurrentPage(Math.round(scrollLeft / offsetWidth) + 1); + }; + + // 화살표 클릭(웹 전용)은 프로그래밍적으로 한 장만큼 스크롤한다 — 스와이프와 같은 스냅 위치로 맞춰진다. + const scrollToPage = (page: number) => { + const el = galleryRef.current; + if (!el || el.offsetWidth === 0) { + return; + } + el.scrollTo({ behavior: "smooth", left: (page - 1) * el.offsetWidth }); + }; + + return ( +
+
+ {photoKeys.map((photoKey) => ( +
+ ))} +
+ + {overlay} + + {/* group + 웹 전용(hover:hover) 미디어에서만 화살표를 보여준다 — 터치 기기는 hover 자체가 + 없어서 항상 hidden으로 남고, 스와이프(스크롤 스냅)로만 넘긴다. */} + {hasMultiplePhotos && ( + <> + {currentPage > 1 && ( + + )} + {currentPage < photoCount && ( + + )} + + )} + + {showCounter && ( +
+ +
+ )} +
+ ); +} + +export default PhotoGallery; diff --git a/src/components/ui/ScreenLayout.tsx b/src/components/ui/ScreenLayout.tsx index 0453b1e..f694dc0 100644 --- a/src/components/ui/ScreenLayout.tsx +++ b/src/components/ui/ScreenLayout.tsx @@ -49,8 +49,10 @@ interface ScreenLayoutProps { // 높이는 폰이든 데스크톱이든 뷰포트를 꽉 채운다(h-dvh) — 데스크톱에서 높이를 고정하면 낮은 // 뷰포트에서 화면 아래가 잘리고 Bottom Nav가 밀려난다. 폭만 데스크톱 뷰포트(sm 이상)에서 // 480px로 묶는다 — Figma는 375 기준이지만 데스크톱에서 그대로 쓰면 너무 좁다. 폭에 맞춰 -// 같이 움직여야 하는 고정 px는 FeedbacksQaCard(캐러셀 카드)와 BililgeReturnConfirmModal -// 둘뿐이다. 화면과 같은 배경 위에 서는 컬럼이라 그림자로 경계를 표시한다 +// 같이 움직여야 하는 고정 px는 FeedbacksQaCard(캐러셀 카드), BililgeReturnConfirmModal, +// PhotoGallery(공지·행사 상세 사진 갤러리) 셋이다. PhotoGallery는 고정 높이 대신 +// aspect-square를 쓴다 — 실사진을 담는 영역이라 폭이 넓어져도 정사각 비율이 깨지면 안 된다. +// 화면과 같은 배경 위에 서는 컬럼이라 그림자로 경계를 표시한다 // — App.tsx가 같은 브레이크포인트로 이 컬럼을 가운데 세운다. // 세이프에어리어는 앱 셸이 담당하므로 여기서 env(safe-area-inset-*)를 더하지 않는다(중복 여백이 된다). // 배경도 같은 방식으로 라우트 handle에서 받는다 — 헤더 슬롯까지 이 루트 div가 덮기 때문에, diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx index 6ebbf7b..28ec963 100644 --- a/src/features/events/EventsDetailScreen.tsx +++ b/src/features/events/EventsDetailScreen.tsx @@ -2,14 +2,13 @@ import { ActionArea, ActionAreaButton, Divider, - PageCounter, TopNavigationButton, Typography, } from "@wanteddev/wds"; import { IconChevronLeft } from "@wanteddev/wds-icon"; -import { type UIEvent, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import PhotoGallery from "@/components/ui/PhotoGallery"; import EventsEmptyState from "@/features/events/components/EventsEmptyState"; import EventsStatusBadge from "@/features/events/components/EventsStatusBadge"; import { EVENTS } from "@/features/events/constants/events"; @@ -26,20 +25,8 @@ import { EVENTS } from "@/features/events/constants/events"; function EventsDetailScreen() { const navigate = useNavigate(); const { eventId } = useParams<{ eventId: string }>(); - const [currentPage, setCurrentPage] = useState(1); const event = EVENTS.find((item) => item.id === eventId); - // 실 이미지 API 전까지는 장수만 알고 URL이 없어서, 슬라이드 key를 미리 만들어 둔다. - // map 콜백의 index를 key로 쓰면 noArrayIndexKey에 걸린다. - const imageKeys = useMemo( - () => - Array.from( - { length: event?.imageCount ?? 0 }, - (_, index) => `${event?.id}-image-${index}`, - ), - [event?.id, event?.imageCount], - ); - if (!event) { return (
@@ -54,16 +41,6 @@ function EventsDetailScreen() { const isOpen = event.status === "open"; const hasMultipleImages = event.imageCount > 1; - // 스크롤 위치로 현재 장을 역산한다. 한 장이 뷰포트 폭을 꽉 채우므로 offsetWidth로 나누면 인덱스가 된다. - // scroll 이벤트마다 setState가 불리지만, 같은 값이면 React가 리렌더를 건너뛴다. - const handleHeroScroll = (scrollEvent: UIEvent) => { - const { scrollLeft, offsetWidth } = scrollEvent.currentTarget; - if (offsetWidth === 0) { - return; - } - setCurrentPage(Math.round(scrollLeft / offsetWidth) + 1); - }; - return ( // Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본 // 배경(Background/Normal/Alternative, #F7F7F8)과 다르다. 흰 배경이어야 Action Area의 @@ -75,42 +52,26 @@ function EventsDetailScreen() {
{/* Hero — 실제 행사 이미지 API 전까지 Figma와 같은 단색 placeholder. Figma는 375×375 정사각이라 폭이 유동인 지금 레이아웃에서는 aspect-square로 둔다. - 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(브라우저 기본 스크롤이라 - 터치·트랙패드·키보드가 모두 동작하고, 별도 캐러셀 라이브러리가 필요 없다). */} -
-
- {imageKeys.map((imageKey) => ( -
- ))} -
- - {/* 뒤로가기·카운터는 스크롤되지 않게 스크롤 컨테이너 밖에 절대배치한다 */} -
- navigate(-1)} - variant="icon" - > - - -
- {/* 한 장뿐이면 셀 게 없어서 카운터를 감춘다 */} - {hasMultipleImages && ( -
- + 이미지가 여러 장이면 가로 스크롤 스냅으로 한 장씩 넘긴다(공지 상세와 같은 PhotoGallery). */} + + navigate(-1)} + variant="icon" + > + +
- )} -
+ } + photoCount={event.imageCount} + showCounter={hasMultipleImages} + slideClassName="aspect-square" + />
diff --git a/src/features/notices/NoticesDetailScreen.tsx b/src/features/notices/NoticesDetailScreen.tsx index 4ca8a2c..513f5b5 100644 --- a/src/features/notices/NoticesDetailScreen.tsx +++ b/src/features/notices/NoticesDetailScreen.tsx @@ -1,6 +1,5 @@ import { ContentBadge, - PageCounter, type ThemeColorsToken, TopNavigationButton, Typography, @@ -8,6 +7,7 @@ import { import { IconChevronLeft } from "@wanteddev/wds-icon"; import { useNavigate, useParams } from "react-router-dom"; +import PhotoGallery from "@/components/ui/PhotoGallery"; import ScreenHeader from "@/components/ui/ScreenHeader"; import { useScreenHeader } from "@/components/ui/useScreenHeader"; import { @@ -25,20 +25,25 @@ function NoticesDetailScreen() { const { noticeId } = useParams(); const navigate = useNavigate(); const notice = NOTICES.find((item) => item.id === noticeId); + const photoCount = notice?.photoCount ?? 0; + const backButton = ( + navigate(-1)} + variant="icon" + > + + + ); + + // 사진이 있으면 행사 상세와 같은 방식으로 뒤로가기를 사진 위 오버레이로 그려서 사진이 화면 + // 최상단부터 시작하게 한다(ScreenLayout 헤더 슬롯을 비우면 0px로 접힌다). 사진이 없으면 + // 덮을 이미지가 없어서 기존처럼 흰 배경 헤더를 쓴다. useScreenHeader( - navigate(-1)} - variant="icon" - > - - - } - variant="normal" - />, + notice?.hasThumbnail ? null : ( + + ), ); if (!notice) { @@ -59,13 +64,15 @@ function NoticesDetailScreen() { return (
{notice.hasThumbnail && ( -
- -
+ {backButton}
+ } + photoCount={photoCount} + showCounter + slideClassName="aspect-square" + /> )}
From 9fa1d9cf388d63b226489dd02595834e0feabd6b Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 23 Sep 2026 15:14:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20PhotoGallery=20=ED=8C=90=EB=8B=A8?= =?UTF-8?q?=20=EA=B7=BC=EA=B1=B0=EB=A5=BC=20wds-component-usage.md?= =?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/wds-component-usage.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/conventions/wds-component-usage.md b/docs/conventions/wds-component-usage.md index 280d4e4..52c0992 100644 --- a/docs/conventions/wds-component-usage.md +++ b/docs/conventions/wds-component-usage.md @@ -262,7 +262,9 @@ Figma 상세는 뒤로가기 버튼이 Hero 이미지 **위에 떠 있는** 오 `useScreenHeader`를 호출하지 않으면 슬롯이 `null`(0px)로 남으므로(`useScreenHeader`가 unmount 시 `setHeader(null)`을 한다), 상세 화면은 훅을 아예 호출하지 않고 Hero 안에 `absolute`로 `TopNavigationButton variant="icon"` + `IconChevronLeft`를 얹는다. 버튼 자체는 WDS를 그대로 쓴다. -`ScreenHeader`에 `overlay` prop을 추가하는 방안도 검토했지만(슬롯을 `absolute inset-x-0 top-0 z-10`으로 띄우면 가능 — `ScreenLayout` 프레임이 `relative`다) 공용 컴포넌트가 다른 화면에 영향을 주는 변경이라 로컬로 뒀다. 같은 오버레이 패턴이 두 번째 화면에 나오면 그때 `ScreenHeader`로 올린다(`component-convention.md` §1의 "두 번째 화면에서 실제로 재사용될 때" 규칙과 같은 기준). +`ScreenHeader`에 `overlay` prop을 추가하는 방안도 검토했지만(슬롯을 `absolute inset-x-0 top-0 z-10`으로 띄우면 가능 — `ScreenLayout` 프레임이 `relative`다) 공용 컴포넌트가 다른 화면에 영향을 주는 변경이라 로컬로 뒀다. + +**후기(공지 상세에 같은 패턴이 실제로 생김)**: 이후 공지 상세에도 같은 오버레이 헤더가 필요해졌는데, `ScreenHeader`가 아니라 `PhotoGallery`(아래 "PhotoGallery" 절 참고) 쪽에 `overlay` prop을 둬서 해결했다 — 뒤로가기 버튼이 이미지 영역에 종속된 오버레이라, `ScreenHeader`(헤더 슬롯 전체를 관장) 대신 사진 갤러리 컴포넌트가 자기 위에 뭘 얹을지를 결정하는 게 더 자연스러웠다. 공지 상세는 사진이 있을 때만 이 오버레이를 쓰고(`useScreenHeader(notice?.hasThumbnail ? null : )`), 사진이 없으면 기존처럼 `ScreenHeader`로 되돌아간다. ## 행사 신청 제출 실패 토스트(`1450:93026`) 구현 중 확정된 매핑 | WDS 컴포넌트 | 코드 export | 확인 내용 | @@ -335,6 +337,15 @@ LottieFiles export 원본(`Loading Content`)에는 문구 2줄도 벡터 도형 - 뒤로가기는 행사 신청 화면과 동일하게 `ScreenHeader variant="normal"`의 `leading`에 `TopNavigationButton`+`IconChevronLeft`를 넣는다("Top Navigation 뒤로가기" 절 참고). 공지 상세는 타이틀이 헤더가 아니라 본문(Title Details)에 있어서 `title` prop은 생략한다. - 이미지 갤러리 배경은 실제 공지 사진 API 전까지 `bg-thumbnail-placeholder`(행사 카드와 동일 토큰)를 그대로 재사용했다. +### PhotoGallery — 공지·행사 상세가 공유하는 사진 갤러리(스와이프+호버 화살표는 WDS에 없는 Stream 자체 구현) + +사진이 여러 장일 때 가로 스크롤 스냅으로 넘기는 갤러리를 `src/components/ui/PhotoGallery.tsx`로 공통화해서 공지 상세·행사 상세가 같이 쓴다(도메인 무관 공용 UI라 `features/`가 아니라 `components/ui/`에 둔다 — `coding-style.md` "폴더 구조" 기준). + +- **WDS로 확인된 부분**: 우하단 카운터는 `PageCounter`(위 표), 화살표 아이콘은 `wds-icon`의 `IconChevronLeft`/`IconChevronRight`. +- **Stream 자체 구현(WDS 대응 없음)**: 가로 스크롤 스냅 트랙(`snap-x snap-mandatory`)과 호버 시에만 뜨는 원형 화살표 `
}