feat(travel): useTravelData 훅 추출 — API/캐싱/페이지네이션 로직 분리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 01:13:11 +09:00
parent 6ef687378d
commit c9df3e0e88

View 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;