diff --git a/src/components/ui/FilterChipGroup.tsx b/src/components/ui/FilterChipGroup.tsx index b9c6142..736d23b 100644 --- a/src/components/ui/FilterChipGroup.tsx +++ b/src/components/ui/FilterChipGroup.tsx @@ -22,6 +22,7 @@ function FilterChipGroup({ options, value, onChange }: FilterChipGroupProps) {
{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 ( + + {alt} 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 순서대로 배경 위에 오게 한다. */}
- {photoKeys.map((photoKey) => ( -
- ))} + {photoKeys.map((photoKey, index) => { + const photo = photos?.[index]; + return ( +
+ {photo && ( + {photo.alt} + )} +
+ ); + })}
{overlay} @@ -86,7 +188,7 @@ function PhotoGallery({ )} - {currentPage < photoCount && ( + {currentPage < totalPages && ( ))}
- {/* 하단 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 ( +
+ {items.map((item, index) => ( + onItemClick(item)} + size={sizes[index % sizes.length]} + title={item.title} + /> + ))} +
+ ); +} + +export default ArchivesPhotoColumn; diff --git a/src/features/archives/components/ArchivesPhotoGrid.tsx b/src/features/archives/components/ArchivesPhotoGrid.tsx index c906828..7c4766b 100644 --- a/src/features/archives/components/ArchivesPhotoGrid.tsx +++ b/src/features/archives/components/ArchivesPhotoGrid.tsx @@ -5,15 +5,17 @@ interface ArchivesPhotoGridProps { onMoreClick: () => void; } -const VISIBLE_PHOTO_COUNT = 3; +// Figma Photo Grid는 칸이 3개다 — 3번째 칸은 "더보기" 오버레이로 덮인다. +const TILE_COUNT = 3; // Figma: 아카이빙 상세 Photo Grid (nodeId 1526:171246) -// 3장까지 보여주고, 더 있으면 마지막 칸을 어둡게 덮고 "N장 더보기"를 띄운다(N = 칸에 안 보이는 장수). +// 3칸을 그리고, 사진이 더 있으면 마지막 칸을 어둡게 덮어 "N장 더보기"를 띄운다. +// N은 덮인 3번째 칸까지 포함한 "지금 못 보는 장수"다(26장이면 24장 더보기 — Figma와 같다). // 더보기 칸을 누르면 전체 사진 화면으로 간다(onMoreClick). function ArchivesPhotoGrid({ photos, onMoreClick }: ArchivesPhotoGridProps) { - const visiblePhotos = photos.slice(0, VISIBLE_PHOTO_COUNT); - const hiddenCount = photos.length - (VISIBLE_PHOTO_COUNT - 1); - const hasMore = photos.length > VISIBLE_PHOTO_COUNT; + const visiblePhotos = photos.slice(0, TILE_COUNT); + const hiddenCount = photos.length - (TILE_COUNT - 1); + const hasMore = photos.length > TILE_COUNT; return (
@@ -29,7 +31,7 @@ function ArchivesPhotoGrid({ photos, onMoreClick }: ArchivesPhotoGridProps) { className="absolute inset-0 size-full object-cover" src={photo} /> - {hasMore && index === VISIBLE_PHOTO_COUNT - 1 && ( + {hasMore && index === TILE_COUNT - 1 && (