Skip to content
1 change: 1 addition & 0 deletions src/components/ui/FilterChipGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function FilterChipGroup({ options, value, onChange }: FilterChipGroupProps) {
<div className="flex gap-1.5 overflow-x-auto">
{options.map((option) => (
<FilterChip
aria-pressed={option.value === value}
isActive={option.value === value}
key={option.value}
onClick={() => onChange(option.value)}
Expand Down
68 changes: 68 additions & 0 deletions src/components/ui/LazyImage.tsx
Original file line number Diff line number Diff line change
@@ -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<LazyImageFit, string> = {
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<HTMLImageElement>(null);

// 캐시에 있는 사진은 React가 onLoad를 붙이기 전에 이미 완료돼서 이벤트가 안 온다.
// 마운트 시점에 complete를 한 번 확인해서 회색 자리에 멈춰 있지 않게 한다.
useEffect(() => {
if (imageRef.current?.complete) {
setIsLoaded(true);
}
}, []);

return (
<span
// 회색 자리는 도착 전까지만 둔다 — contain으로 쓰면 사진 옆 여백이 회색으로 남는다.
className={`block overflow-hidden ${
isLoaded ? "" : "bg-thumbnail-placeholder"
} ${className}`}
>
<img
alt={alt}
className={`size-full ${FIT_CLASS_NAMES[fit]} transition-opacity duration-300 ${
isLoaded ? "opacity-100" : "opacity-0"
}`}
decoding="async"
loading={isEager ? "eager" : "lazy"}
onLoad={() => setIsLoaded(true)}
ref={imageRef}
src={src}
/>
</span>
);
}

export default LazyImage;
155 changes: 130 additions & 25 deletions src/components/ui/PhotoGallery.tsx
Original file line number Diff line number Diff line change
@@ -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를 줄 때 각 사진 <img>에 줄 클래스 — 기본은 슬라이드를 꽉 채운다 */
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<HTMLDivElement>(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<HTMLDivElement>) => {
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);
};

// 화살표 클릭(웹 전용)은 프로그래밍적으로 한 장만큼 스크롤한다 — 스와이프와 같은 스냅 위치로 맞춰진다.
Expand All @@ -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 (
<div className="group relative w-full shrink-0">
<div className={`group relative w-full shrink-0 ${className}`}>
{background}

{/* relative: background(absolute)가 배치된 요소라, 일반 흐름인 슬라이드보다 위에 그려진다.
슬라이드 영역도 배치된 요소로 만들어서 DOM 순서대로 배경 위에 오게 한다. */}
<div
className="scrollbar-hidden flex w-full snap-x snap-mandatory overflow-x-auto"
className="scrollbar-hidden relative flex h-full w-full snap-x snap-mandatory overflow-x-auto"
onScroll={hasMultiplePhotos ? handleGalleryScroll : undefined}
ref={galleryRef}
>
{photoKeys.map((photoKey) => (
<div
className={`w-full shrink-0 snap-start bg-thumbnail-placeholder ${slideClassName}`}
key={photoKey}
/>
))}
{photoKeys.map((photoKey, index) => {
const photo = photos?.[index];
return (
<div
className={[
"w-full shrink-0",
slideSnapClassName,
// 실제 사진이 있으면 placeholder 배경은 깔지 않는다
photo ? "" : "bg-thumbnail-placeholder",
slideClassName,
]
.filter(Boolean)
.join(" ")}
key={photoKey}
>
{photo && (
<img
alt={photo.alt}
className={photoClassName}
src={photo.src}
/>
)}
</div>
);
})}
</div>

{overlay}
Expand All @@ -86,7 +188,7 @@ function PhotoGallery({
<IconChevronLeft className="size-5 text-white" />
</button>
)}
{currentPage < photoCount && (
{currentPage < totalPages && (
<button
aria-label="다음 사진"
className="absolute top-1/2 right-4 z-10 hidden size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity focus-visible:opacity-100 [@media(hover:hover)]:flex [@media(hover:hover)]:group-hover:opacity-100"
Expand All @@ -100,11 +202,14 @@ function PhotoGallery({
)}

{showCounter && (
<div className="pointer-events-none absolute right-5 bottom-5 z-10">
<div
className={`pointer-events-none absolute z-10 ${counterClassName}`}
>
<PageCounter
alternative={counterAlternative}
currentPage={currentPage}
size="small"
totalPages={photoCount}
totalPages={totalPages}
/>
</div>
)}
Expand Down
10 changes: 10 additions & 0 deletions src/components/ui/ScreenBottomSafeArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// 본문 맨 아래에 두는 하단 Home Bar 자리.
// env(safe-area-inset-bottom)이라 앱 WebView(네이티브가 이미 인셋)에서는 0이 되고,
// 폰 브라우저·PWA에서만 실제 인셋이 잡힌다. 데스크톱 프레임(sm 이상)에서는 Figma대로 34px을
// 흉내 낸다 — BottomNav가 자기 영역에서 쓰는 규칙과 같다.
// Action Area가 있는 화면은 WDS가 이미 아래 padding 20px을 주므로 h-safe-bottom-extra를 쓴다.
function ScreenBottomSafeArea() {
return <div className="h-safe-bottom sm:h-[34px]" />;
}

export default ScreenBottomSafeArea;
8 changes: 4 additions & 4 deletions src/features/archives/ArchivesDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { IconButton, Typography } from "@wanteddev/wds";
import { IconChevronLeft, IconShare } from "@wanteddev/wds-icon";
import { useNavigate, useParams } from "react-router-dom";

import ScreenBottomSafeArea from "@/components/ui/ScreenBottomSafeArea";
import ScreenHeader from "@/components/ui/ScreenHeader";
import ArchivesDetailSection from "@/features/archives/components/ArchivesDetailSection";
import ArchivesPhotoGrid from "@/features/archives/components/ArchivesPhotoGrid";
Expand Down Expand Up @@ -71,7 +72,8 @@ function ArchivesDetailScreen() {
>
{title}
</Typography>
<dl className="grid grid-cols-[51px_1fr] gap-x-4 gap-y-2">
{/* 라벨 칸은 Figma의 51px을 최소폭으로만 쓴다 — 고정하면 "담당 부서"처럼 긴 라벨이 칸을 넘친다 */}
<dl className="grid grid-cols-[minmax(51px,auto)_1fr] gap-x-4 gap-y-2">
{infoRows.map(({ label, value }) => (
<div className="contents" key={label}>
<Typography
Expand Down Expand Up @@ -129,9 +131,7 @@ function ArchivesDetailScreen() {
))}
</ArchivesDetailSection>
</div>
{/* 하단 Home Bar 자리 — env()라 앱 WebView(네이티브가 이미 인셋)에서는 0이 된다.
데스크톱 프레임에서는 Figma대로 34px을 흉내 낸다(BottomNav와 같은 규칙). */}
<div className="h-safe-bottom sm:h-[34px]" />
<ScreenBottomSafeArea />
</div>
);
}
Expand Down
Loading