# 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 검증 후 수정" ```