Files
web-page/src/pages/travel/Travel.jsx
gahusb 59bb05ba22 fix(travel): 앨범 커버 지정이 반영되지 않던 문제 수정
- useTravelData: 앨범 목록을 GET /api/travel/albums API로 빌드 (커버 정보 포함)
- 커버 지정 성공 시 refreshAlbums → 앨범 카드 즉시 갱신
- onCoverChange 콜백 체인: Travel → AlbumDetail → HeroLightbox

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 12:32:46 +09:00

232 lines
11 KiB
JavaScript

import React, { useState, useCallback, useRef } from 'react';
import useTravelData from './useTravelData';
import MiniMap, { getRegionAccent } from './MiniMap';
import AlbumCard from './AlbumCard';
import AlbumDetail from './AlbumDetail';
import './Travel.css';
/* ─────────────────────────────────────────────
Travel — main page component
───────────────────────────────────────────── */
const Travel = () => {
const {
regions,
albums,
loadingAlbums,
selectedRegion,
setSelectedRegion,
photos,
photoSummary,
loading,
loadingMore,
error,
hasNext,
loadAlbumPhotos,
loadMorePhotos,
reloadAlbumPhotos,
getFilteredAlbums,
refreshAlbums,
} = useTravelData();
/* ── Local state ──────────────────────────── */
const [selectedAlbum, setSelectedAlbum] = useState(null);
const [albumSourceRect, setAlbumSourceRect] = useState(null);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const syncTimerRef = useRef(null);
/* ── Computed ──────────────────────────────── */
const regionAccent = getRegionAccent(selectedRegion || '');
const filteredAlbums = getFilteredAlbums(selectedRegion);
/* ── Handlers ─────────────────────────────── */
const handleSelectRegion = useCallback(
(regionId) => {
setSelectedRegion(regionId);
},
[setSelectedRegion],
);
const handleClearRegion = useCallback(() => {
setSelectedRegion(null);
}, [setSelectedRegion]);
const handleOpenAlbum = useCallback(
(album, rect) => {
setSelectedAlbum(album);
setAlbumSourceRect(rect || null);
loadAlbumPhotos(album.region, album.name);
},
[loadAlbumPhotos],
);
const handleCloseAlbum = useCallback(() => {
setSelectedAlbum(null);
setAlbumSourceRect(null);
}, []);
const handleLoadMore = useCallback(() => {
if (!selectedAlbum) return;
loadMorePhotos(selectedAlbum.region, selectedAlbum.name);
}, [selectedAlbum, loadMorePhotos]);
const handleReload = useCallback(() => {
if (!selectedAlbum) return;
return reloadAlbumPhotos(selectedAlbum.region, selectedAlbum.name);
}, [selectedAlbum, reloadAlbumPhotos]);
const handleSync = useCallback(async () => {
if (syncing) return;
setSyncing(true);
setSyncResult(null);
try {
const res = await fetch('/api/travel/sync', { method: 'POST' });
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
setSyncResult(data);
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
syncTimerRef.current = setTimeout(() => setSyncResult(null), 8000);
} catch (e) {
setSyncResult({ error: e.message });
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
syncTimerRef.current = setTimeout(() => setSyncResult(null), 5000);
} finally {
setSyncing(false);
}
}, [syncing]);
/* ── Render ────────────────────────────────── */
return (
<div className="travel" style={{ '--region-accent': regionAccent }}>
{/* ═══════════════════════════════════════
HEADER — editorial masthead
═══════════════════════════════════════ */}
<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>
<button
className={`tv-sync-btn${syncing ? ' is-syncing' : ''}`}
onClick={handleSync}
disabled={syncing}
title="사진 폴더 동기화"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M1.5 8a6.5 6.5 0 0 1 11.25-4.43" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
<path d="M14.5 8a6.5 6.5 0 0 1-11.25 4.43" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
<path d="M12 1.5v2.5h-2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4 14.5v-2.5h2.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{syncing ? '동기화 중…' : '동기화'}
</button>
{syncResult && (
<div className={`tv-sync-toast${syncResult.error ? ' is-error' : ''}`}>
{syncResult.error
? `동기화 실패: ${syncResult.error}`
: `+${syncResult.added} 추가 / ${syncResult.removed} 삭제 / 썸네일 ${syncResult.thumbs_generated}${syncResult.discovered ? ` / 신규 폴더 ${syncResult.discovered}개 발견` : ''} (${syncResult.duration_sec}s)`
}
</div>
)}
</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}</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>
{/* ═══════════════════════════════════════
MAP — collapsible mini-map
═══════════════════════════════════════ */}
<MiniMap
geojson={regions}
selectedRegionId={selectedRegion}
onSelectRegion={handleSelectRegion}
onClearRegion={handleClearRegion}
/>
{/* ═══════════════════════════════════════
ALBUM CARDS
═══════════════════════════════════════ */}
<section className="tv-albums">
{loadingAlbums && filteredAlbums.length === 0 && (
<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>
)}
{filteredAlbums.length > 0 && (
<div className="tv-albums__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}
onCoverChange={refreshAlbums}
/>
)}
</div>
);
};
export default Travel;