# Travel Gallery Redesign — Design Spec ## Goal Travel 여행 기록 갤러리를 앨범 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. 모놀리식 1,024줄 컴포넌트를 7-8개 집중된 파일로 분리하고, 시네마틱 여행 감성을 강화한다. ## Scope - **포함**: 프론트엔드 리디자인 (컴포넌트 분리 + 새 UX/UI) - **포함**: 동영상 탭 UI 셸 (플레이스홀더) - **제외**: 백엔드 동영상 API (별도 후속 스펙) - **제외**: 핀치 줌 (복잡도 대비 효과 낮음) ## Architecture 점진적 리팩토링 — 기존 API 호출/캐싱/페이지네이션 로직을 `useTravelData` 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 라우팅 변경 없이 React 상태 기반으로 앨범 진입/이탈을 관리한다. ## Tech Stack - React 18 (기존) - Leaflet + react-leaflet (기존, 미니맵으로 축소) - react-swipeable (기존, 라이트박스 스와이프) - SwipeableView 컴포넌트 (기존, 사진/영상 탭) - CSS columns (Masonry 레이아웃) - IntersectionObserver (무한스크롤 + 스크롤 리빌) - Web Animations API / CSS transitions (shared element transition) --- ## 1. Component Structure & File Layout ``` src/pages/travel/ ├── Travel.jsx # 메인 컨테이너 (미니맵 + 앨범 카드 리스트) ├── Travel.css # 전체 레이아웃 + CSS 변수 ├── AlbumCard.jsx # 여행지 앨범 카드 ├── AlbumCard.css ├── AlbumDetail.jsx # 앨범 상세 (탭 + Masonry) ├── AlbumDetail.css ├── MasonryGrid.jsx # Masonry 레이아웃 + 무한스크롤 ├── MasonryGrid.css ├── HeroLightbox.jsx # HERO 확대 전환 라이트박스 ├── HeroLightbox.css ├── MiniMap.jsx # Leaflet 미니맵 ├── MiniMap.css ├── VideoTab.jsx # 영상 탭 UI 셸 ├── VideoTab.css └── useTravelData.js # API 호출 + 캐싱 + 페이지네이션 훅 ``` ### Responsibilities | 파일 | 책임 | |------|------| | `Travel.jsx` | 페이지 레이아웃, 지역 필터 상태, 앨범 선택 상태 관리 | | `useTravelData.js` | API fetch, 10분 TTL 캐시, 앨범별 그룹핑, 페이지네이션 | | `MiniMap.jsx` | Leaflet 지도 렌더링, GeoJSON 폴리곤, 지역 클릭 이벤트 발행 | | `AlbumCard.jsx` | 대표 사진 + 앨범명 + 사진 수 뱃지, 호버 효과 | | `AlbumDetail.jsx` | 앨범 오버레이, 진입/이탈 애니메이션, 사진/영상 탭 전환 | | `MasonryGrid.jsx` | CSS columns Masonry, IntersectionObserver 무한스크롤 + 스크롤 리빌 | | `HeroLightbox.jsx` | shared element transition, 좌우 스와이프, 썸네일 스트립 | | `VideoTab.jsx` | "영상 기능 준비 중" 플레이스홀더 | ### Page Flow ``` Travel.jsx (메인) ├── MiniMap (상단, 접기/펼치기 가능) │ └── 지역 클릭 → selectedRegion 상태 변경 → 앨범 필터 ├── AlbumCard[] (여행지 카드 리스트) │ └── 클릭 → AlbumDetail (오버레이) │ ├── [사진 탭] MasonryGrid │ │ └── 사진 클릭 → HeroLightbox │ └── [영상 탭] VideoTab └── useTravelData (데이터 레이어) ``` --- ## 2. Main View — MiniMap + Album Card List ### MiniMap - 높이: 데스크톱 200px, 모바일 150px - GeoJSON 지역 폴리곤 유지 (기존 MapLayer 로직 추출) - 클릭 시 해당 지역 앨범만 필터링 - 선택된 지역: 지역별 악센트 컬러로 하이라이트 - "전체 보기" 버튼으로 필터 해제 - 접기/펼치기 토글 (기본: 펼침) - 접힌 상태: 높이 0 + overflow hidden, 토글 버튼만 표시 ### Album Card List - **카드 구성**: 대표 사진 배경 (object-fit: cover) + 앨범 이름 + 사진 수 뱃지 - **대표 사진**: 앨범 첫 번째 사진의 썸네일 URL - **카드 레이아웃**: `display: grid` - 데스크톱 (>1024px): 3열 - 태블릿 (769px-1024px): 2열 - 모바일 (<=768px): 1열 - **카드 높이**: 데스크톱 240px, 모바일 200px - **호버**: scale(1.03) + 지역 악센트 글로우 - **지역 필터 전환**: fade 애니메이션 (opacity 300ms) ### Album Data Grouping 백엔드 API 변경 없이 프론트에서 처리: 1. 각 region에 대해 `GET /api/travel/photos?region={id}&page=1&size=1` 호출 2. 응답의 `total` 필드로 사진 수 확보, `items[0]`으로 대표 사진 확보 3. region_map.json의 albums 목록에서 앨범명 추출 4. 기존 10분 TTL 캐시 로직 재활용 --- ## 3. Album Detail — Masonry Grid + Tabs + Transitions ### Entry Animation (Shared Element Transition) 1. 앨범 카드 클릭 시 `getBoundingClientRect()`로 카드 시작 위치 캡처 2. 카드 clone을 `position: fixed`로 생성 3. clone을 `inset: 0` (풀스크린)으로 animate (400ms, cubic-bezier(0.4, 0, 0.2, 1)) 4. 애니메이션 완료 → clone 제거, AlbumDetail 오버레이 표시 ### Exit Animation 1. 뒤로가기/닫기 클릭 2. AlbumDetail을 숨기고, 원래 카드 위치로 역재생 (400ms) 3. 애니메이션 완료 → 앨범 카드 리스트로 복귀 ### Photo/Video Tabs - 앨범 상세 상단에 "사진 | 영상" 탭 바 - 기존 `SwipeableView` 컴포넌트 재활용 (모바일 스와이프 전환) - 영상 탭: VideoTab 컴포넌트 (플레이스홀더) ### Masonry Grid (Photo Tab) - **레이아웃**: CSS `column-count` 기반 - 데스크톱 (>1024px): 4열 - 태블릿 (769px-1024px): 3열 - 모바일 (<=768px): 2열 - **사진 비율**: 원본 유지 (`width: 100%`, `height: auto`) - **갭**: `column-gap: 8px`, 각 사진 `margin-bottom: 8px` - **break-inside**: `avoid` (사진이 컬럼 경계에 걸리지 않도록) - **무한 스크롤**: IntersectionObserver 센티널, rootMargin 300px, page size 20 - **스크롤 리빌**: 뷰포트 진입 시 아래에서 20px 올라오며 fade-in, 사진마다 50ms 지연 - **lazy loading**: `loading="lazy"` 속성, 첫 8장은 `loading="eager"` ### Video Tab (Shell) - 중앙 정렬된 비디오 아이콘 + "영상 기능 준비 중" 텍스트 - 앰버 톤 텍스트, 세리프 폰트 - 백엔드 동영상 API 완성 시 이 컴포넌트 내부만 교체 --- ## 4. HERO Lightbox ### Shared Element Transition (Photo → Fullscreen) 1. Masonry에서 사진 클릭 → `getBoundingClientRect()`로 시작 위치 캡처 2. 사진 clone을 `position: fixed`로 생성 3. clone을 화면 중앙 + 최대 크기로 animate (350ms, cubic-bezier(0.4, 0, 0.2, 1)) 4. 애니메이션 완료 → clone 제거, 라이트박스 UI 표시 5. 배경은 `#000` opacity 0→1 동시 전환 ### Fullscreen Viewer - **배경**: 순수 블랙 `#000`, z-index 3000 - **사진**: `max-width: 100%`, `max-height: calc(100vh - 140px)`, `object-fit: contain` - **좌우 탐색**: - 데스크톱: 좌우 화살표 버튼 (hover 시 표시) - 모바일: react-swipeable로 좌우 스와이프 - 키보드: ArrowLeft/ArrowRight - **하단 썸네일 스트립**: - 높이 68px, 썸네일 52x52px - 활성 썸네일: 앰버 테두리 (2px solid) - 활성 썸네일 자동 센터링 (smooth scroll) - 필름 퍼포레이션 장식 제거 (간소화) - **메타 정보**: 사진 위 또는 아래에 앨범명 + 파일명 (앰버 텍스트, 14px) - **닫기**: - X 버튼 (우상단) - 아래로 스와이프 (모바일, threshold 100px) - ESC 키 - 닫기 시 역재생 transition → 원래 그리드 위치로 복귀 ### Slide Animation (이전/다음) - 좌우 전환 시 현재 사진이 나가고 새 사진이 들어오는 slide 애니메이션 - 280ms, cubic-bezier(0.25, 0.46, 0.45, 0.94) - 방향에 따라 왼쪽/오른쪽에서 진입 --- ## 5. Visual Design — Cinematic Travel Aesthetic ### Color System - **베이스 배경**: `#0f0c09` (깊은 다크) - **베이스 텍스트**: `#f5e6c8` (따뜻한 앰버) - **뮤트 텍스트**: `rgba(245,230,200,0.5)` - **라인/테두리**: `rgba(245,230,200,0.08)` - **지역별 악센트**: - 일본: `#c73e1d` (주홍) - 유럽: `#2563eb` (코발트) - 동남아: `#059669` (에메랄드) - 국내: `#d97706` (호박) - 기타: 기본 앰버 `#d4a574` - 악센트 적용: 앨범 카드 호버 글로우, 미니맵 지역 하이라이트, 탭 활성 상태 ### Typography - **제목/앨범명**: `Cormorant Garamond`, serif (기존 유지) - **메타 정보/뱃지**: `Space Mono`, monospace (기존 유지) - **앨범 카드 제목**: 데스크톱 24px, 모바일 18px - **사진 수 뱃지**: 11px 모노, `rgba(15,12,9,0.7)` 배경 위 앰버 텍스트 ### Album Card Visual - 대표 사진 위 하단 30% 그라디언트: `linear-gradient(transparent, rgba(15,12,9,0.85))` - 그라디언트 위에 앨범명 + 사진 수 - `border-radius: 12px` - `border: 1px solid rgba(245,230,200,0.08)` - 호버: `box-shadow: 0 0 20px rgba({accent}, 0.15)` + `transform: scale(1.03)` ### Masonry Photo Style - `border-radius: 4px` - 호버: `filter: brightness(1.08)` + `cursor: zoom-in` - 스크롤 리빌: translateY(20px) + opacity(0) → translateY(0) + opacity(1), 사진마다 50ms 지연 ### Lightbox Visual - 배경: `#000` - 메타 텍스트: 앰버 `#f5e6c8`, 세리프 폰트, 14px - 썸네일 스트립: 활성 아이템에 앰버 2px 테두리 - 카운터: "3 / 156" 형태, 우상단, 모노스페이스 --- ## 6. Responsive Design ### Breakpoints | 구간 | 앨범 카드 | Masonry 열 | 미니맵 높이 | |------|----------|-----------|-----------| | >1024px | 3열 | 4열 | 200px | | 769-1024px | 2열 | 3열 | 200px | | <=768px | 1열 | 2열 | 150px | ### Mobile Specifics - 앨범 상세: `position: fixed; inset: 0` (풀스크린 오버레이) - 라이트박스: 100dvh, 화살표 버튼 숨김 (스와이프로 대체) - 미니맵: 기본 접힘 (모바일에서 공간 절약) - 하단 네비게이션 고려: `padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom))` --- ## 7. Reduced Motion `prefers-reduced-motion: reduce` 적용 시: - shared element transition (앨범 진입/이탈, 라이트박스 열기/닫기) → 즉시 fade (opacity 0→1, 150ms) - 스크롤 리빌 애니메이션 → 즉시 표시 (opacity 1, transform none) - 카드 호버 scale → 없음 (색상 변화만 유지) - 슬라이드 전환 → 즉시 교체 (fade) - 미니맵 접기/펼치기 → 즉시 전환 --- ## 8. Data Flow ``` useTravelData hook ├── fetchRegions() → GET /api/travel/regions ├── fetchAlbums(region?) → GET /api/travel/photos?region={id}&page=1&size=1 (per region) ├── fetchPhotos(region, page) → GET /api/travel/photos?region={id}&page={n}&size=20 └── cache (Map, 10min TTL) → 기존 캐시 로직 재활용 State: - regions: GeoJSON[] - albums: { id, name, region, coverThumb, totalPhotos }[] - selectedRegion: string | null - selectedAlbum: string | null - photos: Photo[] - page, hasNext, loading, loadingMore ``` ### API Contract (기존 유지, 변경 없음) ``` GET /api/travel/regions → GeoJSON FeatureCollection GET /api/travel/photos?region=japan&page=1&size=20 → { region, page, size, total, has_next, items: [{ album, file, url, thumb, mtime }] } POST /api/travel/reload → { status: "ok" } ``` --- ## 9. Performance Considerations - **앨범 카드 대표 사진**: page=1&size=1로 최소 데이터만 요청 - **Masonry 이미지**: 썸네일(480x480) 사용, 라이트박스에서만 원본 로드 - **무한 스크롤**: 20개씩 점진적 로드, rootMargin 300px 선제 로드 - **lazy loading**: 브라우저 네이티브 `loading="lazy"` - **캐시**: 10분 TTL, 리전 단위 - **스크롤 리빌**: IntersectionObserver 단일 인스턴스로 배치 감시 - **shared element transition**: `will-change: transform` 적용, 합성 레이어로 GPU 가속