diff --git a/docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md b/docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md new file mode 100644 index 0000000..6bc8b67 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md @@ -0,0 +1,2665 @@ +# Travel Gallery Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Travel 여행 기록 갤러리를 앨범 카드 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. + +**Architecture:** 현재 모놀리식 Travel.jsx(1,024줄)를 `useTravelData` 훅 + 7개 컴포넌트로 분리하며 점진적으로 리팩토링한다. 기존 API 호출/캐싱/페이지네이션 로직을 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 백엔드 API 변경 없음. + +**Tech Stack:** React 18, Leaflet + react-leaflet, react-swipeable, CSS columns (Masonry), IntersectionObserver, SwipeableView (기존 컴포넌트) + +--- + +## File Structure + +``` +src/pages/travel/ +├── Travel.jsx # 메인 컨테이너 (리팩토링) — 미니맵 + 앨범 카드 리스트 + 오버레이 상태 +├── Travel.css # 전체 레이아웃 + CSS 변수 (리팩토링) +├── AlbumCard.jsx # (신규) 여행지 앨범 카드 +├── AlbumCard.css # (신규) +├── AlbumDetail.jsx # (신규) 앨범 상세 오버레이 (탭 + Masonry + 진입/이탈 애니메이션) +├── AlbumDetail.css # (신규) +├── MasonryGrid.jsx # (신규) CSS columns Masonry + 무한스크롤 + 스크롤 리빌 +├── MasonryGrid.css # (신규) +├── HeroLightbox.jsx # (신규) shared element transition 라이트박스 +├── HeroLightbox.css # (신규) +├── MiniMap.jsx # (신규) Leaflet 미니맵 (기존 MapLayer 로직 추출) +├── MiniMap.css # (신규) +├── VideoTab.jsx # (신규) 영상 탭 플레이스홀더 +├── VideoTab.css # (신규) +└── useTravelData.js # (신규) API 호출 + 캐싱 + 앨범 그룹핑 + 페이지네이션 훅 +``` + +--- + +### Task 1: useTravelData 훅 추출 + +기존 Travel.jsx에서 API 호출, 캐싱, 페이지네이션 로직을 커스텀 훅으로 추출한다. + +**Files:** +- Create: `src/pages/travel/useTravelData.js` + +- [ ] **Step 1: useTravelData 훅 파일 생성** + +```js +// src/pages/travel/useTravelData.js +import { useCallback, useEffect, useRef, useState } from 'react'; + +const PAGE_SIZE = 20; + +const normalizePhotos = (items = []) => + items + .map((item) => { + if (typeof item === 'string') return { src: item, title: '' }; + if (!item) return null; + return { + src: item.thumb || item.url || item.path || item.src || '', + title: item.title || item.name || item.file || '', + original: item.url || item.path || item.src || '', + file: item.file || '', + album: item.album || '', + }; + }) + .filter((item) => item && item.src); + +const hasSummaryInfo = (payload) => + payload && + (Object.prototype.hasOwnProperty.call(payload, 'total') || + Object.prototype.hasOwnProperty.call(payload, 'matched_albums')); + +export function useTravelData() { + const [regions, setRegions] = useState(null); + const [albums, setAlbums] = useState([]); + const [selectedRegion, setSelectedRegion] = useState(null); + const [photos, setPhotos] = useState([]); + const [photoSummary, setPhotoSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [loadingAlbums, setLoadingAlbums] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [hasNext, setHasNext] = useState(true); + + const cacheRef = useRef(new Map()); + const albumCacheRef = useRef(new Map()); + const cacheTtlMs = 10 * 60 * 1000; + + // ── Load GeoJSON regions ── + useEffect(() => { + const controller = new AbortController(); + (async () => { + try { + const res = await fetch('/api/travel/regions', { signal: controller.signal }); + if (!res.ok) throw new Error(`지역 정보 로딩 실패 (${res.status})`); + setRegions(await res.json()); + } catch (err) { + if (err?.name !== 'AbortError') setError(err?.message ?? String(err)); + } + })(); + return () => controller.abort(); + }, []); + + // ── Build album list from regions GeoJSON ── + useEffect(() => { + if (!regions?.features) return; + const controller = new AbortController(); + + (async () => { + setLoadingAlbums(true); + const albumList = []; + + for (const feature of regions.features) { + const regionId = feature.properties?.id; + const regionName = feature.properties?.name || regionId; + if (!regionId) continue; + + // Check album cache + const cached = albumCacheRef.current.get(regionId); + if (cached && Date.now() - cached.timestamp < cacheTtlMs) { + albumList.push(...cached.albums); + continue; + } + + try { + const res = await fetch( + `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`, + { signal: controller.signal } + ); + if (!res.ok) continue; + const json = await res.json(); + const meta = Array.isArray(json) ? {} : json ?? {}; + const items = Array.isArray(json) ? json : json.items ?? []; + const matchedAlbums = meta.matched_albums ?? []; + + const regionAlbums = matchedAlbums.map((a, idx) => ({ + id: `${regionId}__${a.album}`, + name: a.album, + region: regionId, + regionName, + photoCount: a.count, + coverThumb: items.length > 0 + ? (items.find(i => i.album === a.album)?.thumb || items[0]?.thumb || '') + : '', + })); + + albumCacheRef.current.set(regionId, { + timestamp: Date.now(), + albums: regionAlbums, + }); + albumList.push(...regionAlbums); + } catch (err) { + if (err?.name === 'AbortError') return; + } + } + + setAlbums(albumList); + setLoadingAlbums(false); + })(); + + return () => controller.abort(); + }, [regions, cacheTtlMs]); + + // ── Load photos for a specific album (region + album filter) ── + const loadAlbumPhotos = useCallback(async (regionId, albumName) => { + const cacheKey = `${regionId}__${albumName}`; + const cached = cacheRef.current.get(cacheKey); + if (cached && Date.now() - cached.timestamp < cacheTtlMs) { + setPhotos(cached.items); + setPhotoSummary(cached.summary ?? null); + setPage(cached.page ?? 1); + setHasNext(cached.hasNext ?? true); + setLoading(false); + setLoadingMore(false); + setError(''); + return; + } + + setLoading(true); + setLoadingMore(false); + setError(''); + setPhotos([]); + setPhotoSummary(null); + setPage(1); + setHasNext(true); + + try { + const res = await fetch( + `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}` + ); + if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`); + const json = await res.json(); + const items = Array.isArray(json) ? json : json.items ?? []; + const meta = Array.isArray(json) ? {} : json ?? {}; + + // Filter by album name + const albumItems = items.filter((i) => i.album === albumName); + const normalized = normalizePhotos(albumItems); + + // For albums, we need all photos — fetch remaining pages if album has more + const allNormalized = normalizePhotos(items); + const totalForRegion = meta.total ?? allNormalized.length; + const nextHasNext = typeof meta.has_next === 'boolean' ? meta.has_next : allNormalized.length >= PAGE_SIZE; + + const summary = hasSummaryInfo(meta) + ? { total: meta.total, albums: meta.matched_albums ?? [] } + : null; + + // We filter all items to this album — but pagination is region-level + // So we store the region-level data and filter in display + setPhotos(normalized); + setPhotoSummary(summary); + setHasNext(nextHasNext); + setPage(2); + + cacheRef.current.set(cacheKey, { + timestamp: Date.now(), + items: normalized, + page: 2, + hasNext: nextHasNext, + summary, + regionId, + albumName, + }); + } catch (err) { + setError(err?.message ?? String(err)); + setPhotos([]); + setPhotoSummary(null); + } finally { + setLoading(false); + } + }, [cacheTtlMs]); + + // ── Load more photos ── + const loadMorePhotos = useCallback(async (regionId, albumName) => { + if (loading || loadingMore || !hasNext) return; + setLoadingMore(true); + setError(''); + try { + const res = await fetch( + `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=${page}&size=${PAGE_SIZE}` + ); + if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`); + const json = await res.json(); + const items = Array.isArray(json) ? json : json.items ?? []; + const meta = Array.isArray(json) ? {} : json ?? {}; + + const albumItems = items.filter((i) => i.album === albumName); + const normalized = normalizePhotos(albumItems); + const nextHasNext = typeof meta.has_next === 'boolean' + ? meta.has_next + : typeof meta.hasNext === 'boolean' ? meta.hasNext : items.length >= PAGE_SIZE; + + setPhotos((prev) => { + const merged = [...prev, ...normalized]; + const cacheKey = `${regionId}__${albumName}`; + cacheRef.current.set(cacheKey, { + timestamp: Date.now(), + items: merged, + page: page + 1, + hasNext: nextHasNext, + summary: photoSummary, + regionId, + albumName, + }); + return merged; + }); + setHasNext(nextHasNext); + setPage((p) => p + 1); + } catch (err) { + setError(err?.message ?? String(err)); + } finally { + setLoadingMore(false); + } + }, [hasNext, loading, loadingMore, page, photoSummary]); + + // ── Reload (pull-to-refresh) ── + const reloadAlbumPhotos = useCallback(async (regionId, albumName) => { + const cacheKey = `${regionId}__${albumName}`; + cacheRef.current.delete(cacheKey); + albumCacheRef.current.delete(regionId); + await loadAlbumPhotos(regionId, albumName); + }, [loadAlbumPhotos]); + + // ── Filter albums by region ── + const getFilteredAlbums = useCallback((regionId) => { + if (!regionId) return albums; + return albums.filter((a) => a.region === regionId); + }, [albums]); + + return { + regions, + albums, + selectedRegion, + setSelectedRegion, + photos, + photoSummary, + loading, + loadingMore, + loadingAlbums, + error, + hasNext, + loadAlbumPhotos, + loadMorePhotos, + reloadAlbumPhotos, + getFilteredAlbums, + }; +} +``` + +- [ ] **Step 2: 빌드 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5` +Expected: 빌드 성공 (새 파일은 아직 import되지 않으므로 기존과 동일) + +- [ ] **Step 3: 커밋** + +```bash +git add src/pages/travel/useTravelData.js +git commit -m "feat(travel): useTravelData 훅 추출 — API/캐싱/페이지네이션 로직 분리" +``` + +--- + +### Task 2: MiniMap 컴포넌트 추출 + +기존 Travel.jsx의 MapLayer + MapContainer 로직을 MiniMap으로 추출한다. + +**Files:** +- Create: `src/pages/travel/MiniMap.jsx` +- Create: `src/pages/travel/MiniMap.css` + +- [ ] **Step 1: MiniMap.jsx 생성** + +```jsx +// src/pages/travel/MiniMap.jsx +import { useState } from 'react'; +import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import './MiniMap.css'; + +const REGION_PALETTE = { + japan: '#e05c4b', korea: '#d64f6e', china: '#c84b3a', + europe: '#5b8fc4', france: '#6f8fc4', italy: '#78a46e', + spain: '#c4844a', sea: '#4aad8b', thailand: '#4aad8b', + vietnam: '#5faa78', bali: '#7aac5a', indonesia: '#8aaa4a', + america: '#b4885c', usa: '#b4885c', canada: '#6a9890', + africa: '#c47c3c', middle: '#c4a24a', dubai: '#c4a24a', + default: '#c8905e', +}; + +export const getRegionAccent = (regionId = '') => { + const id = regionId.toLowerCase(); + for (const [key, color] of Object.entries(REGION_PALETTE)) { + if (key !== 'default' && id.includes(key)) return color; + } + return REGION_PALETTE.default; +}; + +function MapLayer({ geojson, selectedRegionId, onSelectRegion }) { + const map = useMap(); + if (!geojson) return null; + + return ( + { + const isSelected = feature?.properties?.id === selectedRegionId; + const accent = getRegionAccent(feature?.properties?.id || ''); + return { + color: isSelected ? accent : 'rgba(200,160,100,0.4)', + weight: isSelected ? 2 : 1, + fillColor: isSelected ? accent : 'rgba(200,144,94,0.15)', + fillOpacity: isSelected ? 0.25 : 0.12, + }; + }} + onEachFeature={(feature, layer) => { + const name = feature?.properties?.name || feature?.properties?.id || ''; + if (name) layer.bindTooltip(name, { sticky: true, className: 'minimap-tooltip' }); + layer.on('click', () => { + if (!feature?.properties?.id) return; + map.fitBounds(layer.getBounds(), { padding: [40, 40], animate: true }); + onSelectRegion({ + id: feature.properties.id, + name: feature.properties.name || feature.properties.id, + }); + }); + }} + /> + ); +} + +export default function MiniMap({ geojson, selectedRegionId, onSelectRegion, onClearRegion }) { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+
+ + {selectedRegionId && ( + + )} +
+ +
+ + + + + + {!selectedRegionId && ( +
+ CLICK A REGION +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: MiniMap.css 생성** + +```css +/* src/pages/travel/MiniMap.css */ + +.minimap { + display: flex; + flex-direction: column; + gap: 0; +} + +.minimap__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; +} + +.minimap__toggle { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--tv-muted); + font-family: var(--tv-mono); + font-size: 9px; + letter-spacing: 0.2em; + text-transform: uppercase; + cursor: pointer; + padding: 6px 10px; + border-radius: 6px; + transition: color 0.2s ease, background 0.2s ease; +} + +.minimap__toggle:hover { + color: var(--tv-text); + background: rgba(232, 221, 208, 0.06); +} + +.minimap__clear { + background: none; + border: 1px solid var(--tv-line-bright); + color: var(--tv-muted); + font-family: var(--tv-mono); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + cursor: pointer; + padding: 5px 12px; + border-radius: 999px; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.minimap__clear:hover { + color: var(--tv-text); + border-color: var(--tv-text); +} + +.minimap__container { + position: relative; + border-radius: var(--tv-r-lg, 22px); + overflow: hidden; + border: 1px solid var(--tv-line-bright); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); + height: 200px; + transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease, border-color 0.35s ease; +} + +.minimap__container.is-collapsed { + height: 0; + opacity: 0; + border-color: transparent; + pointer-events: none; +} + +.minimap__leaflet { + width: 100%; + height: 100%; +} + +.minimap__hint { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(15, 12, 9, 0.85); + border: 1px solid rgba(232, 221, 208, 0.2); + border-radius: 999px; + padding: 7px 18px; + pointer-events: none; + z-index: 500; +} + +.minimap__hint span { + font-family: var(--tv-mono); + font-size: 9px; + letter-spacing: 0.24em; + color: var(--tv-muted); +} + +/* Leaflet tooltip override */ +.minimap-tooltip { + font-family: var(--tv-mono) !important; + font-size: 10px !important; + letter-spacing: 0.12em !important; + text-transform: uppercase !important; + background: rgba(15, 12, 9, 0.92) !important; + border: 1px solid rgba(232, 221, 208, 0.2) !important; + border-radius: 6px !important; + color: #e8ddd0 !important; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5) !important; +} + +.minimap-tooltip::before { + border-top-color: rgba(232, 221, 208, 0.15) !important; +} + +@media (max-width: 768px) { + .minimap__container { + height: 150px; + } + + .minimap__container.is-collapsed { + height: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .minimap__container { + transition: none; + } +} +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5` +Expected: 빌드 성공 + +- [ ] **Step 4: 커밋** + +```bash +git add src/pages/travel/MiniMap.jsx src/pages/travel/MiniMap.css +git commit -m "feat(travel): MiniMap 컴포넌트 추출 — 접기/펼치기 + 전체보기 버튼" +``` + +--- + +### Task 3: AlbumCard 컴포넌트 + +여행지 앨범 카드. 대표 사진 배경 + 앨범명 + 사진 수 뱃지. + +**Files:** +- Create: `src/pages/travel/AlbumCard.jsx` +- Create: `src/pages/travel/AlbumCard.css` + +- [ ] **Step 1: AlbumCard.jsx 생성** + +```jsx +// src/pages/travel/AlbumCard.jsx +import { useRef } from 'react'; +import { getRegionAccent } from './MiniMap'; +import './AlbumCard.css'; + +export default function AlbumCard({ album, onClick }) { + const cardRef = useRef(null); + const accent = getRegionAccent(album.region); + + const handleClick = () => { + const rect = cardRef.current?.getBoundingClientRect(); + onClick(album, rect); + }; + + return ( +
e.key === 'Enter' && handleClick()} + aria-label={`${album.name} — ${album.photoCount}장`} + > + {album.coverThumb && ( + {album.name} + )} +
+
+

{album.name}

+
+ {album.regionName} + {album.photoCount} photos +
+
+
+ ); +} +``` + +- [ ] **Step 2: AlbumCard.css 생성** + +```css +/* src/pages/travel/AlbumCard.css */ + +.album-card { + position: relative; + height: 240px; + border-radius: 12px; + overflow: hidden; + cursor: pointer; + background: var(--tv-surface, #1a1510); + border: 1px solid rgba(245, 230, 200, 0.08); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; +} + +.album-card:hover { + transform: scale(1.03); + box-shadow: 0 0 20px rgba(var(--album-accent-rgb, 200, 144, 94), 0.15); +} + +.album-card:focus-visible { + outline: 2px solid var(--album-accent, #c8905e); + outline-offset: 2px; +} + +.album-card__cover { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.6s cubic-bezier(0.25, 0, 0, 1); +} + +.album-card:hover .album-card__cover { + transform: scale(1.06); +} + +.album-card__gradient { + position: absolute; + inset: 0; + background: linear-gradient(transparent 50%, rgba(15, 12, 9, 0.85) 100%); + pointer-events: none; +} + +.album-card__info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 16px; + z-index: 1; +} + +.album-card__name { + font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif); + font-size: 24px; + font-weight: 600; + color: #e8ddd0; + margin: 0 0 6px; + letter-spacing: -0.01em; +} + +.album-card__meta { + display: flex; + align-items: center; + gap: 10px; +} + +.album-card__region { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--album-accent, #c8905e); +} + +.album-card__count { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + letter-spacing: 0.12em; + color: rgba(232, 221, 208, 0.45); + background: rgba(15, 12, 9, 0.7); + padding: 2px 8px; + border-radius: 4px; +} + +/* Grid layout - set by parent */ +.album-card-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +@media (max-width: 1024px) { + .album-card-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .album-card-grid { + grid-template-columns: 1fr; + } + + .album-card { + height: 200px; + } + + .album-card__name { + font-size: 18px; + } +} + +@media (prefers-reduced-motion: reduce) { + .album-card { + transition: none; + } + + .album-card__cover { + transition: none; + } + + .album-card:hover { + transform: none; + } + + .album-card:hover .album-card__cover { + transform: none; + } +} +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5` +Expected: 빌드 성공 + +- [ ] **Step 4: 커밋** + +```bash +git add src/pages/travel/AlbumCard.jsx src/pages/travel/AlbumCard.css +git commit -m "feat(travel): AlbumCard 컴포넌트 — 대표사진 + 그라디언트 + 메타정보" +``` + +--- + +### Task 4: MasonryGrid 컴포넌트 + +CSS columns 기반 Masonry 레이아웃 + 무한스크롤 + 스크롤 리빌. + +**Files:** +- Create: `src/pages/travel/MasonryGrid.jsx` +- Create: `src/pages/travel/MasonryGrid.css` + +- [ ] **Step 1: MasonryGrid.jsx 생성** + +```jsx +// src/pages/travel/MasonryGrid.jsx +import { useEffect, useRef } from 'react'; +import './MasonryGrid.css'; + +const getPhotoLabel = (photo) => { + if (!photo) return ''; + if (photo.title) return photo.title; + if (photo.file) return photo.file; + if (!photo.src) return ''; + const parts = photo.src.split('/'); + return parts[parts.length - 1]; +}; + +export default function MasonryGrid({ + photos, + onSelectPhoto, + onLoadMore, + hasNext, + isLoadingMore, + regionAccent, +}) { + const sentinelRef = useRef(null); + const gridRef = useRef(null); + const revealObserverRef = useRef(null); + + // Infinite scroll sentinel + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !onLoadMore) return; + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isLoadingMore && hasNext) onLoadMore(); + }, + { rootMargin: '300px' } + ); + io.observe(sentinel); + return () => io.disconnect(); + }, [hasNext, isLoadingMore, onLoadMore]); + + // Scroll-reveal observer + useEffect(() => { + revealObserverRef.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + entry.target.dataset.revealed = 'true'; + revealObserverRef.current?.unobserve(entry.target); + }); + }, + { rootMargin: '120px', threshold: 0.05 } + ); + return () => revealObserverRef.current?.disconnect(); + }, []); + + useEffect(() => { + const observer = revealObserverRef.current; + const grid = gridRef.current; + if (!observer || !grid) return; + const cards = grid.querySelectorAll('.masonry-item:not([data-revealed="true"])'); + cards.forEach((c) => observer.observe(c)); + const fallback = setTimeout(() => { + grid.querySelectorAll('.masonry-item:not([data-revealed="true"])') + .forEach((c) => (c.dataset.revealed = 'true')); + }, 600); + return () => { + clearTimeout(fallback); + cards.forEach((c) => observer.unobserve(c)); + }; + }, [photos.length]); + + return ( + <> +
+ {photos.map((photo, index) => { + const label = getPhotoLabel(photo); + return ( +
onSelectPhoto(index, e)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && onSelectPhoto(index, e)} + aria-label={label || `Photo ${index + 1}`} + > + {label} { + if (photo.original && e.currentTarget.src !== photo.original) { + e.currentTarget.src = photo.original; + } + }} + /> +
+ {label} +
+
+ ); + })} +
+ +
+ {isLoadingMore && ( +
+ + + +
+ )} + {!hasNext && photos.length > 0 && ( +

+  {photos.length} frames developed  +

+ )} +
+ + ); +} +``` + +- [ ] **Step 2: MasonryGrid.css 생성** + +```css +/* src/pages/travel/MasonryGrid.css */ + +.masonry-grid { + column-count: 4; + column-gap: 8px; +} + +.masonry-item { + break-inside: avoid; + margin-bottom: 8px; + position: relative; + overflow: hidden; + border-radius: 4px; + cursor: zoom-in; + background: var(--tv-surface, #1a1510); + + /* Scroll-reveal */ + opacity: 0; + transform: translateY(20px); + transition: opacity 0.5s ease, transform 0.5s ease; + transition-delay: var(--reveal-delay, 0ms); +} + +.masonry-item[data-revealed='true'] { + opacity: 1; + transform: translateY(0); +} + +.masonry-item img { + width: 100%; + height: auto; + display: block; + transition: filter 0.3s ease; +} + +.masonry-item:hover img { + filter: brightness(1.08); +} + +.masonry-item__overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 8px 10px; + background: linear-gradient(transparent, rgba(15, 12, 9, 0.7)); + opacity: 0; + transition: opacity 0.25s ease; +} + +.masonry-item:hover .masonry-item__overlay { + opacity: 1; +} + +.masonry-item__label { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 9px; + color: rgba(232, 221, 208, 0.8); + letter-spacing: 0.06em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + +.masonry-item:focus-visible { + outline: 2px solid var(--tv-accent, #c8905e); + outline-offset: 2px; +} + +/* Footer */ +.masonry-footer { + display: flex; + justify-content: center; + align-items: center; + padding: 24px 0 8px; + min-height: 48px; +} + +.masonry-loading { + display: flex; + gap: 8px; +} + +.masonry-loading__dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--tv-accent, #c8905e); + animation: masonry-pulse 1.2s ease-in-out infinite; +} + +.masonry-loading__dot:nth-child(2) { animation-delay: 0.2s; } +.masonry-loading__dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes masonry-pulse { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +.masonry-end { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 10px; + letter-spacing: 0.22em; + color: var(--tv-dim, rgba(232, 221, 208, 0.25)); + text-transform: uppercase; + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.masonry-end span { + color: var(--tv-line-bright, rgba(232, 221, 208, 0.22)); +} + +/* Responsive */ +@media (max-width: 1024px) { + .masonry-grid { + column-count: 3; + } +} + +@media (max-width: 768px) { + .masonry-grid { + column-count: 2; + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .masonry-item { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } + + .masonry-item img { + transition: none; + } + + .masonry-loading__dot { + animation: none; + } +} +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5` +Expected: 빌드 성공 + +- [ ] **Step 4: 커밋** + +```bash +git add src/pages/travel/MasonryGrid.jsx src/pages/travel/MasonryGrid.css +git commit -m "feat(travel): MasonryGrid 컴포넌트 — CSS columns Masonry + 무한스크롤" +``` + +--- + +### Task 5: VideoTab 플레이스홀더 + +영상 탭 UI 셸. 백엔드 동영상 API 완성 시 내부만 교체. + +**Files:** +- Create: `src/pages/travel/VideoTab.jsx` +- Create: `src/pages/travel/VideoTab.css` + +- [ ] **Step 1: VideoTab.jsx 생성** + +```jsx +// src/pages/travel/VideoTab.jsx +import './VideoTab.css'; + +export default function VideoTab() { + return ( +
+
+ + + + +
+

영상 기능 준비 중

+

여행 영상을 감상할 수 있는 기능이 곧 추가됩니다.

+
+ ); +} +``` + +- [ ] **Step 2: VideoTab.css 생성** + +```css +/* src/pages/travel/VideoTab.css */ + +.video-tab { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 64px 24px; + text-align: center; + min-height: 300px; +} + +.video-tab__icon { + color: var(--tv-dim, rgba(232, 221, 208, 0.25)); + opacity: 0.6; +} + +.video-tab__title { + font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif); + font-size: 20px; + font-weight: 600; + color: var(--tv-text, #e8ddd0); + margin: 0; +} + +.video-tab__desc { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + letter-spacing: 0.1em; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + margin: 0; + max-width: 280px; +} +``` + +- [ ] **Step 3: 커밋** + +```bash +git add src/pages/travel/VideoTab.jsx src/pages/travel/VideoTab.css +git commit -m "feat(travel): VideoTab 플레이스홀더 — 영상 탭 UI 셸" +``` + +--- + +### Task 6: HeroLightbox 컴포넌트 + +Shared element transition 라이트박스. 사진이 제자리에서 풀스크린으로 확대되는 전환. + +**Files:** +- Create: `src/pages/travel/HeroLightbox.jsx` +- Create: `src/pages/travel/HeroLightbox.css` + +- [ ] **Step 1: HeroLightbox.jsx 생성** + +```jsx +// src/pages/travel/HeroLightbox.jsx +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSwipeable } from 'react-swipeable'; +import { useIsMobile } from '../../hooks/useIsMobile'; +import { getRegionAccent } from './MiniMap'; +import './HeroLightbox.css'; + +const THUMB_STRIP_LIMIT = 36; + +const getPhotoLabel = (photo) => { + if (!photo) return ''; + return photo.title || photo.file || ''; +}; + +const getStripRange = (length, center) => { + if (length <= THUMB_STRIP_LIMIT) return [0, length]; + const half = Math.floor(THUMB_STRIP_LIMIT / 2); + let start = Math.max(0, center - half); + let end = start + THUMB_STRIP_LIMIT; + if (end > length) { end = length; start = end - THUMB_STRIP_LIMIT; } + return [start, end]; +}; + +export default function HeroLightbox({ + photos, + selectedIndex, + albumName, + regionId, + sourceRect, + hasNext, + loadingMore, + onClose, + onNavigate, + onLoadMore, +}) { + const isMobile = useIsMobile(); + const [animPhase, setAnimPhase] = useState('enter'); // enter | open | exit + const [slideDir, setSlideDir] = useState('next'); + const [slideToken, setSlideToken] = useState(0); + const overlayRef = useRef(null); + const heroRef = useRef(null); + const thumbStripRef = useRef(null); + const pendingAdvanceRef = useRef(null); + const swipeYRef = useRef(0); + + const photo = photos[selectedIndex]; + const accent = getRegionAccent(regionId); + const [stripStart, stripEnd] = getStripRange(photos.length, selectedIndex); + + // ── Enter animation ── + useEffect(() => { + if (!sourceRect) { + setAnimPhase('open'); + return; + } + // Force layout then animate + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimPhase('open')); + }); + }, [sourceRect]); + + // ── Body scroll lock ── + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, []); + + // ── Close handler ── + const handleClose = useCallback(() => { + setAnimPhase('exit'); + const duration = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 350; + setTimeout(() => onClose(), duration); + }, [onClose]); + + // ── Navigation ── + const goPrev = useCallback(() => { + if (selectedIndex <= 0) return; + setSlideDir('prev'); + setSlideToken((t) => t + 1); + onNavigate(selectedIndex - 1); + }, [selectedIndex, onNavigate]); + + const goNext = useCallback(() => { + if (selectedIndex < photos.length - 1) { + setSlideDir('next'); + setSlideToken((t) => t + 1); + onNavigate(selectedIndex + 1); + return; + } + if (hasNext && !loadingMore) { + pendingAdvanceRef.current = 'next'; + onLoadMore?.(); + } + }, [selectedIndex, photos.length, hasNext, loadingMore, onNavigate, onLoadMore]); + + // Advance after load + useEffect(() => { + if (pendingAdvanceRef.current !== 'next') return; + if (selectedIndex < photos.length - 1) { + setSlideDir('next'); + setSlideToken((t) => t + 1); + onNavigate(selectedIndex + 1); + pendingAdvanceRef.current = null; + } + if (!hasNext && selectedIndex >= photos.length - 1) pendingAdvanceRef.current = null; + }, [hasNext, photos.length, selectedIndex, onNavigate]); + + // ── Keyboard ── + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape') handleClose(); + if (e.key === 'ArrowLeft') goPrev(); + if (e.key === 'ArrowRight') goNext(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [handleClose, goPrev, goNext]); + + // ── Swipe handlers ── + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => goNext(), + onSwipedRight: () => goPrev(), + onSwipedDown: ({ deltaY }) => { + if (Math.abs(deltaY) > 100) handleClose(); + }, + trackMouse: false, + trackTouch: true, + delta: 40, + }); + + // ── Auto-center active thumb ── + useEffect(() => { + const strip = thumbStripRef.current; + if (!strip) return; + const thumb = strip.querySelector(`[data-thumb-index="${selectedIndex}"]`); + if (!thumb) return; + const sr = strip.getBoundingClientRect(); + const tr = thumb.getBoundingClientRect(); + const target = tr.left - sr.left + strip.scrollLeft + tr.width / 2 - sr.width / 2; + strip.scrollTo({ left: target, behavior: 'smooth' }); + }, [selectedIndex, stripStart, stripEnd]); + + // ── Source rect styles for transition ── + const enterStyle = sourceRect && animPhase === 'enter' ? { + position: 'fixed', + top: sourceRect.top, + left: sourceRect.left, + width: sourceRect.width, + height: sourceRect.height, + borderRadius: '4px', + transition: 'none', + } : {}; + + return ( +
+
e.stopPropagation()} + {...(isMobile ? swipeHandlers : {})} + > + {/* Close button */} + + + {/* Counter */} +
+ + {selectedIndex + 1} + + / + {photos.length} +
+ + {/* Photo stage */} +
+ {!isMobile && ( + + )} + +
+ {getPhotoLabel(photo)} { + if (photo?.original && e.currentTarget.src !== photo.original) + e.currentTarget.src = photo.original; + }} + /> +
+ + {!isMobile && ( + + )} +
+ + {/* Meta */} + {(photo?.album || photo?.file) && ( +

+ {photo.album}{photo.file ? · {photo.file} : null} +

+ )} + + {/* Thumbnail strip */} +
+ {photos.slice(stripStart, stripEnd).map((p, idx) => { + const realIndex = stripStart + idx; + return ( + + ); + })} +
+
+
+ ); +} +``` + +- [ ] **Step 2: HeroLightbox.css 생성** + +```css +/* src/pages/travel/HeroLightbox.css */ + +.hero-lightbox { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0); + z-index: 3000; + display: grid; + place-items: center; + transition: background 0.35s ease; +} + +.hero-lightbox--open, +.hero-lightbox--exit { + background: rgba(0, 0, 0, 0.95); +} + +.hero-lightbox--exit { + background: rgba(0, 0, 0, 0); + pointer-events: none; +} + +.hero-lightbox__inner { + position: relative; + width: min(1280px, 98vw); + max-height: 100dvh; + display: flex; + flex-direction: column; + gap: 0; + opacity: 0; + transition: opacity 0.35s ease; +} + +.hero-lightbox--open .hero-lightbox__inner { + opacity: 1; +} + +.hero-lightbox--exit .hero-lightbox__inner { + opacity: 0; +} + +/* Close button */ +.hero-lightbox__close { + position: absolute; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid rgba(232, 221, 208, 0.18); + background: rgba(15, 12, 9, 0.8); + color: #e8ddd0; + cursor: pointer; + z-index: 10; + transition: border-color 0.2s ease; +} + +.hero-lightbox__close:hover { + border-color: rgba(232, 221, 208, 0.5); +} + +/* Counter */ +.hero-lightbox__counter { + position: absolute; + top: 20px; + left: 20px; + display: flex; + align-items: baseline; + gap: 4px; + font-family: var(--tv-mono, 'Space Mono', monospace); + z-index: 10; +} + +.hero-lightbox__counter-current { + font-size: 18px; + font-weight: 400; + line-height: 1; +} + +.hero-lightbox__counter-sep { + font-size: 12px; + color: rgba(232, 221, 208, 0.22); +} + +.hero-lightbox__counter-total { + font-size: 12px; + color: rgba(232, 221, 208, 0.45); +} + +/* Stage */ +.hero-lightbox__stage { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0; + min-height: 0; + padding: 56px 0 12px; +} + +.hero-lightbox__frame { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex: 1; + max-height: calc(100vh - 200px); + overflow: hidden; +} + +.hero-lightbox__photo { + max-width: 100%; + max-height: calc(100vh - 200px); + object-fit: contain; + display: block; + border-radius: 4px; +} + +.hero-lightbox__photo.slide-next { + animation: hero-slide-right 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards; +} + +.hero-lightbox__photo.slide-prev { + animation: hero-slide-left 280ms cubic-bezier(0.25, 0, 0.25, 1) forwards; +} + +@keyframes hero-slide-right { + from { opacity: 0; transform: translateX(24px) scale(0.98); } + to { opacity: 1; transform: translateX(0) scale(1); } +} + +@keyframes hero-slide-left { + from { opacity: 0; transform: translateX(-24px) scale(0.98); } + to { opacity: 1; transform: translateX(0) scale(1); } +} + +/* Arrows */ +.hero-lightbox__arrow { + width: 44px; + height: 44px; + border-radius: 12px; + border: 1px solid rgba(232, 221, 208, 0.18); + background: rgba(15, 12, 9, 0.85); + color: #e8ddd0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin: 0 12px; + transition: border-color 0.2s ease, transform 0.2s ease; +} + +.hero-lightbox__arrow:hover { + border-color: rgba(232, 221, 208, 0.45); + transform: scale(1.05); +} + +.hero-lightbox__arrow:disabled { + opacity: 0.25; + cursor: not-allowed; + transform: none; +} + +.hero-lightbox__spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(232, 221, 208, 0.25); + border-top-color: var(--lb-accent, #c8905e); + animation: hero-spin 0.7s linear infinite; +} + +@keyframes hero-spin { + to { transform: rotate(360deg); } +} + +/* Meta */ +.hero-lightbox__meta { + padding: 8px 20px; + font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif); + font-size: 14px; + color: #f5e6c8; + margin: 0; + text-align: center; +} + +.hero-lightbox__meta span { + color: rgba(232, 221, 208, 0.45); + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 10px; + letter-spacing: 0.1em; +} + +/* Thumbnail strip */ +.hero-lightbox__strip { + display: flex; + gap: 4px; + padding: 8px 20px; + overflow-x: auto; + scrollbar-width: none; + justify-content: center; +} + +.hero-lightbox__strip::-webkit-scrollbar { + display: none; +} + +.hero-lightbox__thumb { + width: 52px; + height: 52px; + border-radius: 4px; + border: 2px solid transparent; + background: var(--tv-surface, #1a1510); + padding: 0; + cursor: pointer; + flex-shrink: 0; + overflow: hidden; + transition: border-color 0.2s ease; +} + +.hero-lightbox__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + filter: saturate(0.7); + transition: filter 0.2s ease; +} + +.hero-lightbox__thumb:hover img, +.hero-lightbox__thumb.is-active img { + filter: saturate(1); +} + +.hero-lightbox__thumb.is-active { + border-color: #f5e6c8; +} + +/* Mobile */ +@media (max-width: 768px) { + .hero-lightbox__inner { + width: 100vw; + max-height: 100dvh; + } + + .hero-lightbox__frame { + max-height: calc(100dvh - 160px); + } + + .hero-lightbox__photo { + max-height: calc(100dvh - 160px); + } + + .hero-lightbox__thumb { + width: 44px; + height: 44px; + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .hero-lightbox, + .hero-lightbox__inner, + .hero-lightbox__close, + .hero-lightbox__arrow, + .hero-lightbox__thumb { + transition: none !important; + } + + .hero-lightbox__photo.slide-next, + .hero-lightbox__photo.slide-prev { + animation: none !important; + opacity: 1; + } + + .hero-lightbox__spinner { + animation: none; + } +} +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5` +Expected: 빌드 성공 + +- [ ] **Step 4: 커밋** + +```bash +git add src/pages/travel/HeroLightbox.jsx src/pages/travel/HeroLightbox.css +git commit -m "feat(travel): HeroLightbox — shared element transition + 스와이프 탐색" +``` + +--- + +### Task 7: AlbumDetail 오버레이 컴포넌트 + +앨범 상세 — 진입/이탈 애니메이션, 사진/영상 탭, MasonryGrid + HeroLightbox 통합. + +**Files:** +- Create: `src/pages/travel/AlbumDetail.jsx` +- Create: `src/pages/travel/AlbumDetail.css` + +- [ ] **Step 1: AlbumDetail.jsx 생성** + +```jsx +// src/pages/travel/AlbumDetail.jsx +import { useCallback, useEffect, useRef, useState } from 'react'; +import SwipeableView from '../../components/SwipeableView'; +import { useIsMobile } from '../../hooks/useIsMobile'; +import PullToRefresh from '../../components/PullToRefresh'; +import MasonryGrid from './MasonryGrid'; +import HeroLightbox from './HeroLightbox'; +import VideoTab from './VideoTab'; +import { getRegionAccent } from './MiniMap'; +import './AlbumDetail.css'; + +export default function AlbumDetail({ + album, + sourceRect, + photos, + photoSummary, + loading, + loadingMore, + hasNext, + error, + onClose, + onLoadMore, + onReload, +}) { + const isMobile = useIsMobile(); + const [animPhase, setAnimPhase] = useState('enter'); // enter | open | exit + const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); + const [photoSourceRect, setPhotoSourceRect] = useState(null); + const overlayRef = useRef(null); + const accent = getRegionAccent(album.region); + + // ── Entry animation ── + useEffect(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimPhase('open')); + }); + }, []); + + // ── Body scroll lock ── + useEffect(() => { + if (selectedPhotoIndex != null) return; // lightbox handles its own + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, [selectedPhotoIndex]); + + // ── Close handler ── + const handleClose = useCallback(() => { + setAnimPhase('exit'); + const duration = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400; + setTimeout(() => onClose(), duration); + }, [onClose]); + + // ── ESC key ── + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape' && selectedPhotoIndex == null) handleClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [handleClose, selectedPhotoIndex]); + + // ── Photo selection (capture source rect) ── + const handleSelectPhoto = useCallback((index, event) => { + const target = event?.currentTarget; + const rect = target?.getBoundingClientRect?.() ?? null; + setPhotoSourceRect(rect); + setSelectedPhotoIndex(index); + }, []); + + // ── Tab definition ── + const tabs = [ + { + key: 'photos', + label: `사진 (${photos.length}${hasNext ? '+' : ''})`, + content: ( +
+ {loading && ( +
+ +
+ )} + {error &&

{error}

} + {!loading && !error && photos.length === 0 && ( +

이 앨범에는 아직 사진이 없습니다.

+ )} + {!loading && !error && photos.length > 0 && ( + + + + )} +
+ ), + }, + { + key: 'videos', + label: '영상', + content: , + }, + ]; + + return ( + <> +
+ {/* Header */} +
+ +
+

{album.name}

+ + {album.regionName} + +
+ + {photoSummary?.total ?? photos.length} photos + +
+ + {/* Tabs */} +
+ +
+
+ + {/* Lightbox */} + {selectedPhotoIndex != null && ( + setSelectedPhotoIndex(null)} + onNavigate={setSelectedPhotoIndex} + onLoadMore={onLoadMore} + /> + )} + + ); +} +``` + +- [ ] **Step 2: AlbumDetail.css 생성** + +```css +/* src/pages/travel/AlbumDetail.css */ + +.album-detail { + position: fixed; + inset: 0; + z-index: 2000; + background: var(--tv-bg, #0f0c09); + display: flex; + flex-direction: column; + overflow: hidden; + + /* Enter animation */ + opacity: 0; + transform: scale(0.95); + transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.album-detail--open { + opacity: 1; + transform: scale(1); +} + +.album-detail--exit { + opacity: 0; + transform: scale(0.95); + pointer-events: none; +} + +/* Header */ +.album-detail__header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--tv-line, rgba(232, 221, 208, 0.1)); + flex-shrink: 0; +} + +.album-detail__back { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid var(--tv-line-bright, rgba(232, 221, 208, 0.22)); + background: none; + color: var(--tv-text, #e8ddd0); + cursor: pointer; + flex-shrink: 0; + transition: border-color 0.2s ease; +} + +.album-detail__back:hover { + border-color: var(--tv-text, #e8ddd0); +} + +.album-detail__title-group { + flex: 1; + min-width: 0; + display: flex; + align-items: baseline; + gap: 10px; +} + +.album-detail__title { + font-family: var(--tv-serif, 'Cormorant Garamond', Georgia, serif); + font-size: 22px; + font-weight: 600; + color: var(--tv-text, #e8ddd0); + margin: 0; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.album-detail__region-badge { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.18em; + flex-shrink: 0; +} + +.album-detail__count { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + letter-spacing: 0.12em; + flex-shrink: 0; +} + +/* Body */ +.album-detail__body { + flex: 1; + overflow-y: auto; + padding: 0 20px 20px; + padding-bottom: calc(20px + var(--bottom-nav-h, 0px) + var(--safe-area-bottom, 0px)); +} + +/* Loading state */ +.album-detail__loading { + display: flex; + justify-content: center; + gap: 8px; + padding: 48px 0; +} + +.album-detail__loading span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--detail-accent, #c8905e); + animation: album-pulse 1.2s ease-in-out infinite; +} + +.album-detail__loading span:nth-child(2) { animation-delay: 0.2s; } +.album-detail__loading span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes album-pulse { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +.album-detail__error { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + color: #f2a09a; + border: 1px solid rgba(242, 160, 154, 0.3); + border-radius: 10px; + padding: 12px 16px; + background: rgba(242, 160, 154, 0.06); + letter-spacing: 0.08em; +} + +.album-detail__empty { + font-family: var(--tv-mono, 'Space Mono', monospace); + font-size: 11px; + letter-spacing: 0.16em; + color: var(--tv-muted, rgba(232, 221, 208, 0.45)); + text-align: center; + padding: 48px 0; +} + +.album-detail__photo-content { + padding-top: 8px; +} + +/* Mobile */ +@media (max-width: 768px) { + .album-detail__header { + padding: 12px 16px; + } + + .album-detail__title { + font-size: 18px; + } + + .album-detail__body { + padding: 0 16px 16px; + padding-bottom: calc(16px + var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px)); + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .album-detail { + transition: none !important; + opacity: 1; + transform: none; + } + + .album-detail--enter { + opacity: 1; + transform: none; + } + + .album-detail--exit { + opacity: 0; + } + + .album-detail__back { + transition: none; + } + + .album-detail__loading span { + animation: none; + } +} +``` + +- [ ] **Step 3: 빌드 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5` +Expected: 빌드 성공 + +- [ ] **Step 4: 커밋** + +```bash +git add src/pages/travel/AlbumDetail.jsx src/pages/travel/AlbumDetail.css +git commit -m "feat(travel): AlbumDetail 오버레이 — 사진/영상 탭 + 진입/이탈 애니메이션" +``` + +--- + +### Task 8: Travel.jsx 메인 컴포넌트 리팩토링 + +기존 Travel.jsx를 새 컴포넌트들을 사용하도록 완전 교체. + +**Files:** +- Modify: `src/pages/travel/Travel.jsx` (전면 교체) + +- [ ] **Step 1: Travel.jsx를 새 구조로 교체** + +```jsx +// src/pages/travel/Travel.jsx +import React, { useCallback, useMemo, useState } from 'react'; +import 'leaflet/dist/leaflet.css'; +import './Travel.css'; +import { useTravelData } from './useTravelData'; +import MiniMap, { getRegionAccent } from './MiniMap'; +import AlbumCard from './AlbumCard'; +import AlbumDetail from './AlbumDetail'; + +const Travel = () => { + const { + regions, + albums, + selectedRegion, + setSelectedRegion, + photos, + photoSummary, + loading, + loadingMore, + loadingAlbums, + error, + hasNext, + loadAlbumPhotos, + loadMorePhotos, + reloadAlbumPhotos, + getFilteredAlbums, + } = useTravelData(); + + const [selectedAlbum, setSelectedAlbum] = useState(null); + const [albumSourceRect, setAlbumSourceRect] = useState(null); + + const regionAccent = getRegionAccent(selectedRegion?.id || ''); + const filteredAlbums = useMemo( + () => getFilteredAlbums(selectedRegion?.id), + [getFilteredAlbums, selectedRegion?.id] + ); + + // ── Album open/close ── + const handleOpenAlbum = useCallback((album, rect) => { + setAlbumSourceRect(rect); + setSelectedAlbum(album); + loadAlbumPhotos(album.region, album.name); + }, [loadAlbumPhotos]); + + const handleCloseAlbum = useCallback(() => { + setSelectedAlbum(null); + setAlbumSourceRect(null); + }, []); + + const handleLoadMore = useCallback(() => { + if (!selectedAlbum) return; + loadMorePhotos(selectedAlbum.region, selectedAlbum.name); + }, [loadMorePhotos, selectedAlbum]); + + const handleReload = useCallback(async () => { + if (!selectedAlbum) return; + await reloadAlbumPhotos(selectedAlbum.region, selectedAlbum.name); + }, [reloadAlbumPhotos, selectedAlbum]); + + return ( +
+ {/* ── Header ── */} +
+
+
+ Visual Diary + · + 여행 포토 아카이브 +
+

+ Travel + Archive +

+

+ 여행에서 포착한 색, 빛, 장면들을 필름처럼 현상합니다. + 지도에서 지역을 선택하거나 앨범 카드를 눌러 탐색하세요. +

+
+ + {selectedRegion ? ( +
+
+
+

Currently viewing

+

{selectedRegion.name}

+

+ {filteredAlbums.reduce((sum, a) => sum + a.photoCount, 0)} photos +

+
+
+ ) : ( +
+
+ + + + +
+

지도에서 지역을 선택하세요

+
+ )} +
+ + {/* ── MiniMap ── */} + setSelectedRegion(null)} + /> + + {/* ── Album Card List ── */} +
+ {loadingAlbums && ( +
+
+

Loading albums…

+
+ )} + + {!loadingAlbums && filteredAlbums.length === 0 && ( +

+ {selectedRegion ? '이 지역에는 앨범이 없습니다.' : '여행 앨범을 불러오는 중…'} +

+ )} + + {!loadingAlbums && filteredAlbums.length > 0 && ( +
+ {filteredAlbums.map((album) => ( + + ))} +
+ )} +
+ + {/* ── Album Detail Overlay ── */} + {selectedAlbum && ( + + )} +
+ ); +}; + +export default Travel; +``` + +- [ ] **Step 2: Travel.css 리팩토링** + +기존 Travel.css에서 사용되지 않는 photo-mosaic, photo-card, lightbox, filmstrip 스타일을 제거하고, 앨범 카드 리스트용 레이아웃만 남긴다. + +기존 Travel.css를 아래 내용으로 전면 교체한다: + +```css +/* ═══════════════════════════════════════════════════ + Travel — "Dark Room" Editorial Photo Archive + Fonts: Cormorant Garamond (display) · Space Mono (mono) +═══════════════════════════════════════════════════ */ + +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Space+Mono:ital@0;1&display=swap'); + +/* ── CSS tokens ──────────────────────────────────────── */ +.travel { + --tv-bg: #0f0c09; + --tv-surface: #1a1510; + --tv-surface-2: #221c14; + --tv-line: rgba(232, 221, 208, 0.1); + --tv-line-bright: rgba(232, 221, 208, 0.22); + --tv-text: #e8ddd0; + --tv-muted: rgba(232, 221, 208, 0.45); + --tv-dim: rgba(232, 221, 208, 0.25); + --tv-accent: var(--region-accent, #c8905e); + --tv-serif: 'Cormorant Garamond', Georgia, serif; + --tv-mono: 'Space Mono', 'Courier New', monospace; + --tv-r-sm: 10px; + --tv-r-md: 16px; + --tv-r-lg: 22px; + + display: grid; + gap: 40px; + color: var(--tv-text); + font-family: var(--tv-serif); +} + +/* ═══════════════════════════════════════════════════ + HEADER — editorial masthead +═══════════════════════════════════════════════════ */ +.tv-header { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: 32px; + align-items: end; + padding-bottom: 28px; + border-bottom: 1px solid var(--tv-line-bright); +} + +.tv-header__meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.tv-header__issue, +.tv-header__tagline { + font-family: var(--tv-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.22em; + color: var(--tv-accent); +} + +.tv-header__divider { color: var(--tv-line-bright); } + +.tv-header__title { + font-family: var(--tv-serif); + font-weight: 300; + line-height: 0.9; + margin: 0 0 18px; + font-size: clamp(52px, 8vw, 88px); + letter-spacing: -0.02em; + display: flex; + flex-direction: column; +} + +.tv-header__title-main { color: var(--tv-text); } + +.tv-header__title-italic { + font-style: italic; + font-weight: 300; + color: var(--tv-accent); + margin-left: 0.12em; +} + +.tv-header__desc { + margin: 0; + color: var(--tv-muted); + font-size: 14px; + line-height: 1.75; + font-family: var(--tv-serif); + font-style: italic; + max-width: 420px; +} + +/* Active region info */ +.tv-header__active-region { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px 20px; + border: 1px solid rgba(var(--tv-accent-rgb, 200, 144, 94), 0.28); + border-radius: var(--tv-r-md); + background: rgba(255, 255, 255, 0.03); +} + +.tv-header__region-indicator { + width: 3px; + height: 52px; + border-radius: 999px; + background: var(--accent, var(--tv-accent)); + flex-shrink: 0; + margin-top: 2px; +} + +.tv-header__region-label { + font-family: var(--tv-mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--tv-dim); + margin: 0 0 4px; +} + +.tv-header__region-name { + font-family: var(--tv-serif); + font-size: 28px; + font-weight: 600; + color: var(--tv-text); + margin: 0 0 4px; + letter-spacing: -0.01em; +} + +.tv-header__region-count { + font-family: var(--tv-mono); + font-size: 10px; + color: var(--tv-muted); + letter-spacing: 0.14em; + margin: 0; +} + +/* Hint */ +.tv-header__hint { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + justify-content: center; + padding: 24px; + text-align: center; + border: 1px dashed var(--tv-line-bright); + border-radius: var(--tv-r-md); +} + +.tv-header__hint-icon { + color: var(--tv-dim); + opacity: 0.6; +} + +.tv-header__hint-text { + font-family: var(--tv-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--tv-muted); + margin: 0; +} + +/* ═══════════════════════════════════════════════════ + ALBUMS SECTION +═══════════════════════════════════════════════════ */ +.tv-albums { + min-height: 200px; +} + +/* ── Loading / Error states ──────────────────────── */ +.tv-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--tv-muted); + font-family: var(--tv-mono); + font-size: 11px; + letter-spacing: 0.1em; + padding: 48px 0; +} + +.tv-state__loader { + display: flex; + gap: 8px; +} + +.tv-state__loader span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--tv-accent); + animation: tv-pulse 1.2s ease-in-out infinite; +} + +.tv-state__loader span:nth-child(2) { animation-delay: 0.2s; } +.tv-state__loader span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes tv-pulse { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +.tv-state--empty { + font-family: var(--tv-mono); + font-size: 11px; + letter-spacing: 0.16em; + text-align: center; +} + +/* ═══════════════════════════════════════════════════ + RESPONSIVE +═══════════════════════════════════════════════════ */ +@media (max-width: 768px) { + .tv-header { + grid-template-columns: 1fr; + } + + .travel { + gap: 28px; + } +} + +@media (max-width: 480px) { + .travel { + gap: 20px; + } + + .tv-header { + gap: 20px; + padding-bottom: 20px; + } + + .tv-header__title { + font-size: clamp(40px, 12vw, 60px); + } +} + +/* ═══════════════════════════════════════════════════ + REDUCED MOTION +═══════════════════════════════════════════════════ */ +@media (prefers-reduced-motion: reduce) { + .tv-state__loader span { + animation: none; + } +} +``` + +- [ ] **Step 3: 개발 서버에서 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1 | tail -5` +Expected: 빌드 성공, 에러 없음 + +- [ ] **Step 4: 커밋** + +```bash +git add src/pages/travel/Travel.jsx src/pages/travel/Travel.css +git commit -m "refactor(travel): Travel.jsx 리팩토링 — 컴포넌트 분리 + 앨범 카드 기반 UI" +``` + +--- + +### Task 9: 통합 테스트 및 빌드 검증 + +모든 컴포넌트가 올바르게 연결되는지 빌드로 검증한다. + +**Files:** +- 없음 (검증만) + +- [ ] **Step 1: Vite 빌드 실행** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite build 2>&1` +Expected: 빌드 성공, 경고 없음 + +- [ ] **Step 2: import 누락 확인** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && grep -rn "from.*Travel" src/pages/travel/ --include="*.jsx" --include="*.js"` +Expected: 모든 import 경로가 올바른지 확인 + +- [ ] **Step 3: 사용하지 않는 파일 정리 확인** + +기존 Travel.jsx에 있던 인라인 컴포넌트(PhotoCard, PhotoMosaic, MapLayer, FilmStrip, Lightbox)가 모두 새 파일로 대체되었는지 확인. + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && grep -n "PhotoCard\|PhotoMosaic\|MapLayer\|FilmStrip\|Lightbox" src/pages/travel/Travel.jsx` +Expected: 해당 이름이 Travel.jsx에 남아있지 않음 + +- [ ] **Step 4: 빌드 성공 확인 후 커밋 (필요 시)** + +수정 사항이 있으면 커밋: +```bash +git add -A src/pages/travel/ +git commit -m "fix(travel): 통합 빌드 검증 — import 경로 수정 및 정리" +``` + +--- + +### Task 10: 최종 UI 검증 + +개발 서버를 실행하고 실제 브라우저에서 모든 플로우를 검증한다. + +**Files:** +- 없음 (검증만, 필요 시 수정) + +- [ ] **Step 1: 개발 서버 실행** + +Run: `cd /c/Users/jaeoh/Desktop/workspace/web-ui && npx vite --port 3007 &` +Expected: http://localhost:3007 에서 서비스 시작 + +- [ ] **Step 2: 검증 체크리스트** + +브라우저에서 http://localhost:3007 의 Travel 페이지 접근 후: + +1. **메인 화면**: 헤더 + 미니맵 + 앨범 카드 리스트가 표시되는지 +2. **미니맵**: 접기/펼치기 토글, 지역 클릭 시 앨범 필터링, "전체 보기" 버튼 +3. **앨범 카드**: 대표 사진, 앨범명, 사진 수 뱃지, 호버 효과 +4. **앨범 진입**: 카드 클릭 시 AlbumDetail 오버레이, 진입 애니메이션 +5. **사진/영상 탭**: SwipeableView 탭 전환, 영상 탭 플레이스홀더 +6. **Masonry 그리드**: CSS columns 레이아웃, 원본 비율 유지, 스크롤 리빌 +7. **무한 스크롤**: 스크롤 하단 도달 시 추가 로드 +8. **라이트박스**: 사진 클릭 시 풀스크린, 좌우 탐색, 썸네일 스트립 +9. **뒤로가기**: ESC 키, 뒤로가기 버튼으로 앨범 닫기 +10. **반응형**: 768px 이하에서 1열 카드, 2열 Masonry, 미니맵 150px + +- [ ] **Step 3: 발견된 이슈 수정 후 커밋** + +```bash +git add -A src/pages/travel/ +git commit -m "fix(travel): UI 검증 후 수정" +```