feat(travel): useTravelData 훅 추출 — API/캐싱/페이지네이션 로직 분리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
357
src/pages/travel/useTravelData.js
Normal file
357
src/pages/travel/useTravelData.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user