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