71 KiB
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 훅 파일 생성
// 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: 커밋
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 생성
// 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 (
<GeoJSON
key={selectedRegionId || 'none'}
data={geojson}
style={(feature) => {
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 (
<section className="minimap">
<div className="minimap__toolbar">
<button
type="button"
className="minimap__toggle"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? '지도 펼치기' : '지도 접기'}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d={collapsed ? 'M4 6l4 4 4-4' : 'M4 10l4-4 4 4'}
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
/>
</svg>
<span>{collapsed ? 'MAP' : 'MAP'}</span>
</button>
{selectedRegionId && (
<button type="button" className="minimap__clear" onClick={onClearRegion}>
전체 보기
</button>
)}
</div>
<div className={`minimap__container ${collapsed ? 'is-collapsed' : ''}`}>
<MapContainer
center={[20, 10]}
zoom={2}
scrollWheelZoom
className="minimap__leaflet"
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
<MapLayer
geojson={geojson}
selectedRegionId={selectedRegionId}
onSelectRegion={onSelectRegion}
/>
</MapContainer>
{!selectedRegionId && (
<div className="minimap__hint">
<span>CLICK A REGION</span>
</div>
)}
</div>
</section>
);
}
- Step 2: MiniMap.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: 커밋
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 생성
// 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 (
<article
ref={cardRef}
className="album-card"
style={{ '--album-accent': accent }}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
aria-label={`${album.name} — ${album.photoCount}장`}
>
{album.coverThumb && (
<img
className="album-card__cover"
src={album.coverThumb}
alt={album.name}
loading="lazy"
decoding="async"
/>
)}
<div className="album-card__gradient" aria-hidden />
<div className="album-card__info">
<h3 className="album-card__name">{album.name}</h3>
<div className="album-card__meta">
<span className="album-card__region">{album.regionName}</span>
<span className="album-card__count">{album.photoCount} photos</span>
</div>
</div>
</article>
);
}
- Step 2: AlbumCard.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: 커밋
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 생성
// 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 (
<>
<div className="masonry-grid" ref={gridRef}>
{photos.map((photo, index) => {
const label = getPhotoLabel(photo);
return (
<article
key={`${photo.src}-${index}`}
className="masonry-item"
style={{ '--reveal-delay': `${Math.min(index, 12) * 50}ms` }}
onClick={(e) => onSelectPhoto(index, e)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onSelectPhoto(index, e)}
aria-label={label || `Photo ${index + 1}`}
>
<img
src={photo.src}
alt={label}
loading={index < 8 ? 'eager' : 'lazy'}
decoding="async"
fetchpriority={index < 8 ? 'high' : 'auto'}
onError={(e) => {
if (photo.original && e.currentTarget.src !== photo.original) {
e.currentTarget.src = photo.original;
}
}}
/>
<div className="masonry-item__overlay">
<span className="masonry-item__label">{label}</span>
</div>
</article>
);
})}
</div>
<div className="masonry-footer" ref={sentinelRef}>
{isLoadingMore && (
<div className="masonry-loading">
<span className="masonry-loading__dot" />
<span className="masonry-loading__dot" />
<span className="masonry-loading__dot" />
</div>
)}
{!hasNext && photos.length > 0 && (
<p className="masonry-end">
<span>—</span> {photos.length} frames developed <span>—</span>
</p>
)}
</div>
</>
);
}
- Step 2: MasonryGrid.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: 커밋
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 생성
// src/pages/travel/VideoTab.jsx
import './VideoTab.css';
export default function VideoTab() {
return (
<div className="video-tab">
<div className="video-tab__icon" aria-hidden>
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="4" y="8" width="40" height="32" rx="4" stroke="currentColor" strokeWidth="1.5" />
<path d="M20 18v12l10-6-10-6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
</div>
<p className="video-tab__title">영상 기능 준비 중</p>
<p className="video-tab__desc">여행 영상을 감상할 수 있는 기능이 곧 추가됩니다.</p>
</div>
);
}
- Step 2: VideoTab.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: 커밋
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 생성
// 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 (
<div
ref={overlayRef}
className={`hero-lightbox hero-lightbox--${animPhase}`}
style={{ '--lb-accent': accent }}
onClick={handleClose}
role="dialog"
aria-modal="true"
>
<div
className="hero-lightbox__inner"
onClick={(e) => e.stopPropagation()}
{...(isMobile ? swipeHandlers : {})}
>
{/* Close button */}
<button
type="button"
className="hero-lightbox__close"
onClick={handleClose}
aria-label="Close"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
<path d="M2 2l14 14M16 2L2 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
{/* Counter */}
<div className="hero-lightbox__counter">
<span className="hero-lightbox__counter-current" style={{ color: accent }}>
{selectedIndex + 1}
</span>
<span className="hero-lightbox__counter-sep">/</span>
<span className="hero-lightbox__counter-total">{photos.length}</span>
</div>
{/* Photo stage */}
<div className="hero-lightbox__stage">
{!isMobile && (
<button
type="button"
className="hero-lightbox__arrow is-prev"
onClick={goPrev}
disabled={selectedIndex === 0}
aria-label="Previous"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)}
<div className="hero-lightbox__frame" ref={heroRef}>
<img
key={`${selectedIndex}-${slideToken}`}
className={`hero-lightbox__photo slide-${slideDir}`}
src={photo?.original || photo?.src}
alt={getPhotoLabel(photo)}
style={enterStyle}
onError={(e) => {
if (photo?.original && e.currentTarget.src !== photo.original)
e.currentTarget.src = photo.original;
}}
/>
</div>
{!isMobile && (
<button
type="button"
className="hero-lightbox__arrow is-next"
onClick={goNext}
disabled={selectedIndex === photos.length - 1 && !hasNext}
aria-label="Next"
>
{loadingMore && hasNext && selectedIndex === photos.length - 1 ? (
<span className="hero-lightbox__spinner" aria-hidden />
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
)}
</div>
{/* Meta */}
{(photo?.album || photo?.file) && (
<p className="hero-lightbox__meta">
{photo.album}{photo.file ? <span> · {photo.file}</span> : null}
</p>
)}
{/* Thumbnail strip */}
<div className="hero-lightbox__strip" ref={thumbStripRef}>
{photos.slice(stripStart, stripEnd).map((p, idx) => {
const realIndex = stripStart + idx;
return (
<button
key={`${p.src}-${realIndex}`}
type="button"
className={`hero-lightbox__thumb ${realIndex === selectedIndex ? 'is-active' : ''}`}
data-thumb-index={realIndex}
onClick={() => {
setSlideDir(realIndex > selectedIndex ? 'next' : 'prev');
setSlideToken((t) => t + 1);
onNavigate(realIndex);
}}
aria-label={getPhotoLabel(p)}
>
<img
src={p.src}
alt=""
loading="lazy"
decoding="async"
onError={(e) => {
if (p.original && e.currentTarget.src !== p.original)
e.currentTarget.src = p.original;
}}
/>
</button>
);
})}
</div>
</div>
</div>
);
}
- Step 2: HeroLightbox.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: 커밋
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 생성
// 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: (
<div className="album-detail__photo-content">
{loading && (
<div className="album-detail__loading">
<span /><span /><span />
</div>
)}
{error && <p className="album-detail__error">{error}</p>}
{!loading && !error && photos.length === 0 && (
<p className="album-detail__empty">이 앨범에는 아직 사진이 없습니다.</p>
)}
{!loading && !error && photos.length > 0 && (
<PullToRefresh onRefresh={onReload}>
<MasonryGrid
photos={photos}
onSelectPhoto={handleSelectPhoto}
onLoadMore={onLoadMore}
hasNext={hasNext}
isLoadingMore={loadingMore}
regionAccent={accent}
/>
</PullToRefresh>
)}
</div>
),
},
{
key: 'videos',
label: '영상',
content: <VideoTab />,
},
];
return (
<>
<div
ref={overlayRef}
className={`album-detail album-detail--${animPhase}`}
style={{ '--detail-accent': accent }}
>
{/* Header */}
<header className="album-detail__header">
<button
type="button"
className="album-detail__back"
onClick={handleClose}
aria-label="뒤로가기"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
<path d="M13 15l-5-5 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<div className="album-detail__title-group">
<h2 className="album-detail__title">{album.name}</h2>
<span className="album-detail__region-badge" style={{ color: accent }}>
{album.regionName}
</span>
</div>
<span className="album-detail__count">
{photoSummary?.total ?? photos.length} photos
</span>
</header>
{/* Tabs */}
<div className="album-detail__body">
<SwipeableView tabs={tabs} />
</div>
</div>
{/* Lightbox */}
{selectedPhotoIndex != null && (
<HeroLightbox
photos={photos}
selectedIndex={selectedPhotoIndex}
albumName={album.name}
regionId={album.region}
sourceRect={photoSourceRect}
hasNext={hasNext}
loadingMore={loadingMore}
onClose={() => setSelectedPhotoIndex(null)}
onNavigate={setSelectedPhotoIndex}
onLoadMore={onLoadMore}
/>
)}
</>
);
}
- Step 2: AlbumDetail.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: 커밋
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를 새 구조로 교체
// 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 (
<div className="travel" style={{ '--region-accent': regionAccent }}>
{/* ── Header ── */}
<header className="tv-header">
<div className="tv-header__masthead">
<div className="tv-header__meta">
<span className="tv-header__issue">Visual Diary</span>
<span className="tv-header__divider" aria-hidden>·</span>
<span className="tv-header__tagline">여행 포토 아카이브</span>
</div>
<h1 className="tv-header__title">
<span className="tv-header__title-main">Travel</span>
<span className="tv-header__title-italic">Archive</span>
</h1>
<p className="tv-header__desc">
여행에서 포착한 색, 빛, 장면들을 필름처럼 현상합니다.
지도에서 지역을 선택하거나 앨범 카드를 눌러 탐색하세요.
</p>
</div>
{selectedRegion ? (
<div className="tv-header__active-region" style={{ '--accent': regionAccent }}>
<div className="tv-header__region-indicator" />
<div>
<p className="tv-header__region-label">Currently viewing</p>
<p className="tv-header__region-name">{selectedRegion.name}</p>
<p className="tv-header__region-count">
{filteredAlbums.reduce((sum, a) => sum + a.photoCount, 0)} photos
</p>
</div>
</div>
) : (
<div className="tv-header__hint">
<div className="tv-header__hint-icon" aria-hidden>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="1" strokeDasharray="3 3"/>
<path d="M16 8v8l5 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</div>
<p className="tv-header__hint-text">지도에서 지역을 선택하세요</p>
</div>
)}
</header>
{/* ── MiniMap ── */}
<MiniMap
geojson={regions}
selectedRegionId={selectedRegion?.id}
onSelectRegion={setSelectedRegion}
onClearRegion={() => setSelectedRegion(null)}
/>
{/* ── Album Card List ── */}
<section className="tv-albums">
{loadingAlbums && (
<div className="tv-state">
<div className="tv-state__loader"><span /><span /><span /></div>
<p>Loading albums…</p>
</div>
)}
{!loadingAlbums && filteredAlbums.length === 0 && (
<p className="tv-state tv-state--empty">
{selectedRegion ? '이 지역에는 앨범이 없습니다.' : '여행 앨범을 불러오는 중…'}
</p>
)}
{!loadingAlbums && filteredAlbums.length > 0 && (
<div className="album-card-grid">
{filteredAlbums.map((album) => (
<AlbumCard key={album.id} album={album} onClick={handleOpenAlbum} />
))}
</div>
)}
</section>
{/* ── Album Detail Overlay ── */}
{selectedAlbum && (
<AlbumDetail
album={selectedAlbum}
sourceRect={albumSourceRect}
photos={photos}
photoSummary={photoSummary}
loading={loading}
loadingMore={loadingMore}
hasNext={hasNext}
error={error}
onClose={handleCloseAlbum}
onLoadMore={handleLoadMore}
onReload={handleReload}
/>
)}
</div>
);
};
export default Travel;
- Step 2: Travel.css 리팩토링
기존 Travel.css에서 사용되지 않는 photo-mosaic, photo-card, lightbox, filmstrip 스타일을 제거하고, 앨범 카드 리스트용 레이아웃만 남긴다.
기존 Travel.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: 커밋
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: 빌드 성공 확인 후 커밋 (필요 시)
수정 사항이 있으면 커밋:
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 페이지 접근 후:
- 메인 화면: 헤더 + 미니맵 + 앨범 카드 리스트가 표시되는지
- 미니맵: 접기/펼치기 토글, 지역 클릭 시 앨범 필터링, "전체 보기" 버튼
- 앨범 카드: 대표 사진, 앨범명, 사진 수 뱃지, 호버 효과
- 앨범 진입: 카드 클릭 시 AlbumDetail 오버레이, 진입 애니메이션
- 사진/영상 탭: SwipeableView 탭 전환, 영상 탭 플레이스홀더
- Masonry 그리드: CSS columns 레이아웃, 원본 비율 유지, 스크롤 리빌
- 무한 스크롤: 스크롤 하단 도달 시 추가 로드
- 라이트박스: 사진 클릭 시 풀스크린, 좌우 탐색, 썸네일 스트립
- 뒤로가기: ESC 키, 뒤로가기 버튼으로 앨범 닫기
- 반응형: 768px 이하에서 1열 카드, 2열 Masonry, 미니맵 150px
- Step 3: 발견된 이슈 수정 후 커밋
git add -A src/pages/travel/
git commit -m "fix(travel): UI 검증 후 수정"