- useTravelData: 앨범 목록을 GET /api/travel/albums API로 빌드 (커버 정보 포함) - 커버 지정 성공 시 refreshAlbums → 앨범 카드 즉시 갱신 - onCoverChange 콜백 체인: Travel → AlbumDetail → HeroLightbox Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
232 lines
11 KiB
JavaScript
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;
|