2666 lines
71 KiB
Markdown
2666 lines
71 KiB
Markdown
# 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 (
|
|
<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 생성**
|
|
|
|
```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 (
|
|
<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 생성**
|
|
|
|
```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 (
|
|
<>
|
|
<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 생성**
|
|
|
|
```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 (
|
|
<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 생성**
|
|
|
|
```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 (
|
|
<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 생성**
|
|
|
|
```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: (
|
|
<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 생성**
|
|
|
|
```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 (
|
|
<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를 아래 내용으로 전면 교체한다:
|
|
|
|
```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 검증 후 수정"
|
|
```
|