Files
web-page-backend/docs/superpowers/plans/2026-04-24-travel-gallery-redesign.md
2026-04-24 01:10:09 +09:00

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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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>&nbsp;{photos.length} frames developed&nbsp;<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 페이지 접근 후:

  1. 메인 화면: 헤더 + 미니맵 + 앨범 카드 리스트가 표시되는지
  2. 미니맵: 접기/펼치기 토글, 지역 클릭 시 앨범 필터링, "전체 보기" 버튼
  3. 앨범 카드: 대표 사진, 앨범명, 사진 수 뱃지, 호버 효과
  4. 앨범 진입: 카드 클릭 시 AlbumDetail 오버레이, 진입 애니메이션
  5. 사진/영상 탭: SwipeableView 탭 전환, 영상 탭 플레이스홀더
  6. Masonry 그리드: CSS columns 레이아웃, 원본 비율 유지, 스크롤 리빌
  7. 무한 스크롤: 스크롤 하단 도달 시 추가 로드
  8. 라이트박스: 사진 클릭 시 풀스크린, 좌우 탐색, 썸네일 스트립
  9. 뒤로가기: ESC 키, 뒤로가기 버튼으로 앨범 닫기
  10. 반응형: 768px 이하에서 1열 카드, 2열 Masonry, 미니맵 150px
  • Step 3: 발견된 이슈 수정 후 커밋
git add -A src/pages/travel/
git commit -m "fix(travel): UI 검증 후 수정"