diff --git a/src/pages/travel/useTravelData.js b/src/pages/travel/useTravelData.js new file mode 100644 index 0000000..9173425 --- /dev/null +++ b/src/pages/travel/useTravelData.js @@ -0,0 +1,357 @@ +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 + + /* ── 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; + + const controller = new AbortController(); + 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(''); + + 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); + 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 res = await fetch(url); + 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); + }, []); + + /* ── 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;