import { useCallback, useEffect, useRef, useState } from 'react'; /* ───────────────────────────────────────────── Constants ───────────────────────────────────────────── */ const PAGE_SIZE = 20; const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes /* ───────────────────────────────────────────── Utility — normalise raw API items to a consistent photo shape ───────────────────────────────────────────── */ export const normalizePhotos = (items = []) => items .map((item) => { if (typeof item === 'string') return { src: item, title: '', original: item, file: '', album: '' }; 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); /* ───────────────────────────────────────────── Internal helper — parse fetch JSON to normalised photo list + summary metadata ───────────────────────────────────────────── */ const parsePhotoResponse = (json) => { const items = Array.isArray(json) ? json : json.items ?? []; const meta = Array.isArray(json) ? {} : json ?? {}; const normalized = normalizePhotos(items); const hasNext = typeof meta.has_next === 'boolean' ? meta.has_next : typeof meta.hasNext === 'boolean' ? meta.hasNext : normalized.length >= PAGE_SIZE; const summary = meta && (Object.prototype.hasOwnProperty.call(meta, 'total') || Object.prototype.hasOwnProperty.call(meta, 'matched_albums')) ? { total: meta.total, albums: meta.matched_albums ?? [] } : null; return { normalized, hasNext, summary, matchedAlbums: meta.matched_albums ?? [] }; }; /* ───────────────────────────────────────────── useTravelData — data layer hook for the Travel gallery page ───────────────────────────────────────────── */ const useTravelData = () => { // ── Region & GeoJSON ───────────────────── const [regions, setRegions] = useState(null); // GeoJSON FeatureCollection const [selectedRegion, setSelectedRegion] = useState(null); // { id, name } // ── Album list ─────────────────────────── const [albums, setAlbums] = useState([]); // built from per-region page-1 fetch const [loadingAlbums, setLoadingAlbums] = useState(false); // ── Photo list for selected album ──────── const [photos, setPhotos] = useState([]); const [photoSummary, setPhotoSummary] = useState(null); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasNext, setHasNext] = useState(false); const [error, setError] = useState(''); // ── Internal refs ──────────────────────── const pageRef = useRef(1); const currentAlbumRef = useRef(null); // { regionId, albumName } const cacheRef = useRef(new Map()); // photo data cache key: `${regionId}::${albumName}` const albumCacheRef = useRef(new Map()); // album metadata cache key: regionId const loadAbortRef = useRef(null); // AbortController for loadAlbumPhotos /* ── Load GeoJSON regions once ──────────── */ 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})`); const geojson = await res.json(); setRegions(geojson); } catch (err) { if (err?.name !== 'AbortError') { setError(err?.message ?? String(err)); } } })(); return () => controller.abort(); }, []); /* ── Build album list when regions arrive ── */ useEffect(() => { if (!regions?.features?.length) return; const controller = new AbortController(); (async () => { setLoadingAlbums(true); const builtAlbums = []; for (const feature of regions.features) { if (controller.signal.aborted) break; const regionId = feature?.properties?.id; const regionName = feature?.properties?.name || regionId || ''; if (!regionId) continue; // Use cached album metadata if fresh const cached = albumCacheRef.current.get(regionId); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { builtAlbums.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; // skip failed regions silently const json = await res.json(); const { normalized, matchedAlbums } = parsePhotoResponse(json); const regionAlbums = matchedAlbums.map((ma) => { // Find first photo that belongs to this album for coverThumb const cover = normalized.find((p) => p.album === ma.album); return { id: `${regionId}::${ma.album}`, name: ma.album, region: regionId, regionName, photoCount: ma.count ?? 0, coverThumb: cover?.src || '', }; }); // If API returned no matched_albums, create a single implicit album if (regionAlbums.length === 0 && normalized.length > 0) { regionAlbums.push({ id: `${regionId}::`, name: regionName, region: regionId, regionName, photoCount: normalized.length, coverThumb: normalized[0]?.src || '', }); } albumCacheRef.current.set(regionId, { timestamp: Date.now(), albums: regionAlbums, }); builtAlbums.push(...regionAlbums); } catch (err) { if (err?.name === 'AbortError') break; // Non-fatal — continue with other regions } } if (!controller.signal.aborted) { setAlbums(builtAlbums); setLoadingAlbums(false); } })(); return () => controller.abort(); }, [regions]); /* ── loadAlbumPhotos — initial load ────── */ const loadAlbumPhotos = useCallback(async (regionId, albumName) => { if (!regionId) return; const cacheKey = `${regionId}::${albumName ?? ''}`; currentAlbumRef.current = { regionId, albumName }; // Check photo cache const cached = cacheRef.current.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { setPhotos(cached.items); setPhotoSummary(cached.summary ?? null); pageRef.current = cached.page ?? 2; setHasNext(cached.hasNext ?? false); setLoading(false); setLoadingMore(false); setError(''); return; } setLoading(true); setLoadingMore(false); setError(''); setPhotos([]); setPhotoSummary(null); setHasNext(false); pageRef.current = 1; // Abort any in-flight loadAlbumPhotos request if (loadAbortRef.current) loadAbortRef.current.abort(); const controller = new AbortController(); loadAbortRef.current = controller; try { let url = `/api/travel/photos?region=${encodeURIComponent(regionId)}&page=1&size=${PAGE_SIZE}`; if (albumName) url += `&album=${encodeURIComponent(albumName)}`; const res = await fetch(url, { signal: controller.signal }); if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`); const json = await res.json(); const { normalized, hasNext: hn, summary } = parsePhotoResponse(json); // Filter by album name client-side when API doesn't support album param const filtered = albumName ? normalized.filter((p) => !p.album || p.album === albumName) : normalized; pageRef.current = 2; setPhotos(filtered); setPhotoSummary(summary); setHasNext(hn); cacheRef.current.set(cacheKey, { timestamp: Date.now(), items: filtered, page: 2, hasNext: hn, summary, }); } catch (err) { if (err?.name === 'AbortError') return; setError(err?.message ?? String(err)); setPhotos([]); setPhotoSummary(null); } finally { setLoading(false); } }, []); /* ── loadMorePhotos — infinite scroll ──── */ const loadMorePhotos = useCallback(async (regionId, albumName) => { const activeRegion = regionId ?? currentAlbumRef.current?.regionId; const activeAlbum = albumName ?? currentAlbumRef.current?.albumName; if (!activeRegion || loading || loadingMore || !hasNext) return; setLoadingMore(true); setError(''); const moreController = new AbortController(); try { let url = `/api/travel/photos?region=${encodeURIComponent(activeRegion)}&page=${pageRef.current}&size=${PAGE_SIZE}`; if (activeAlbum) url += `&album=${encodeURIComponent(activeAlbum)}`; const res = await fetch(url, { signal: moreController.signal }); if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`); const json = await res.json(); const { normalized, hasNext: hn, summary } = parsePhotoResponse(json); // Filter by album name client-side const filtered = activeAlbum ? normalized.filter((p) => !p.album || p.album === activeAlbum) : normalized; const cacheKey = `${activeRegion}::${activeAlbum ?? ''}`; setPhotos((prev) => { const merged = [...prev, ...filtered]; cacheRef.current.set(cacheKey, { timestamp: Date.now(), items: merged, page: pageRef.current + 1, hasNext: hn, summary: photoSummary ?? summary, }); return merged; }); if (!photoSummary && summary) setPhotoSummary(summary); setHasNext(hn); pageRef.current += 1; } catch (err) { setError(err?.message ?? String(err)); } finally { setLoadingMore(false); } }, [hasNext, loading, loadingMore, photoSummary]); /* ── reloadAlbumPhotos — pull-to-refresh ─ */ const reloadAlbumPhotos = useCallback(async (regionId, albumName) => { const activeRegion = regionId ?? currentAlbumRef.current?.regionId; const activeAlbum = albumName ?? currentAlbumRef.current?.albumName; if (!activeRegion) return; const cacheKey = `${activeRegion}::${activeAlbum ?? ''}`; cacheRef.current.delete(cacheKey); let url = `/api/travel/photos?region=${encodeURIComponent(activeRegion)}&page=1&size=${PAGE_SIZE}`; if (activeAlbum) url += `&album=${encodeURIComponent(activeAlbum)}`; const reloadController = new AbortController(); try { const res = await fetch(url, { signal: reloadController.signal }); if (!res.ok) throw new Error(`사진 로딩 실패 (${res.status})`); const json = await res.json(); const { normalized, hasNext: hn, summary } = parsePhotoResponse(json); const filtered = activeAlbum ? normalized.filter((p) => !p.album || p.album === activeAlbum) : normalized; pageRef.current = 2; setPhotos(filtered); setHasNext(hn); cacheRef.current.set(cacheKey, { timestamp: Date.now(), items: filtered, page: 2, hasNext: hn, summary, }); if (summary) setPhotoSummary(summary); } catch (err) { if (err?.name === 'AbortError') return; setError(err?.message ?? String(err)); } }, []); /* ── getFilteredAlbums — filter by region ─ */ const getFilteredAlbums = useCallback( (regionId) => { if (!regionId) return albums; return albums.filter((a) => a.region === regionId); }, [albums] ); return { // GeoJSON data regions, // Album list albums, loadingAlbums, // Region filter selectedRegion, setSelectedRegion, // Photo data photos, photoSummary, // Loading states loading, loadingMore, // Error error, // Pagination hasNext, // Actions loadAlbumPhotos, loadMorePhotos, reloadAlbumPhotos, getFilteredAlbums, }; }; export default useTravelData;