From d4ec482289c41d9f93b1047dbf589edd876a52d1 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 20 Jan 2026 03:20:16 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=AC=ED=96=89=20=ED=83=AD=20develop=20=20-?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EC=B6=94=EA=B0=80=20=20-=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EB=A7=A4=EC=B9=AD=ED=95=98=EC=97=AC=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EC=97=AC=ED=96=89=20=EC=82=AC=EC=A7=84=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=20-=20=EC=82=AC=EC=A7=84=20=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=EC=8B=9C=20=EB=AA=A8=EB=8B=AC=EB=A1=9C=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20(lazy,=20cache,=20thumb=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 113 ++++++-- package.json | 10 +- src/data/travelRegions.js | 34 +++ src/pages/travel/Travel.css | 210 ++++++++++++++ src/pages/travel/Travel.jsx | 557 ++++++++++++++++++++++++++++++------ 5 files changed, 800 insertions(+), 124 deletions(-) create mode 100644 src/data/travelRegions.js diff --git a/package-lock.json b/package-lock.json index 7606e1b..a101ddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,16 @@ "name": "web-ui", "version": "0.0.0", "dependencies": { - "react": "^19.2.0", - "react-dom": "^19.2.0", + "leaflet": "^1.9.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.30.3" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", @@ -1032,6 +1034,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1457,25 +1470,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", - "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "version": "18.2.79", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz", + "integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "csstype": "^3.2.2" + "@types/prop-types": "*", + "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "18.2.25", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", + "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "dependencies": { + "@types/react": "*" } }, "node_modules/@vitejs/plugin-react": { @@ -2285,7 +2305,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -2358,6 +2377,13 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause", + "peer": true + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2395,6 +2421,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2656,26 +2694,44 @@ } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "license": "MIT", "peer": true, "dependencies": { - "scheduler": "^0.27.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^18.2.0" + } + }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" } }, "node_modules/react-refresh": { @@ -2796,10 +2852,13 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } }, "node_modules/semver": { "version": "6.3.1", diff --git a/package.json b/package.json index a4678d2..ddec95d 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,16 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.2.0", - "react-dom": "^19.2.0", + "leaflet": "^1.9.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", "react-router-dom": "^6.30.3" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/src/data/travelRegions.js b/src/data/travelRegions.js new file mode 100644 index 0000000..d20c616 --- /dev/null +++ b/src/data/travelRegions.js @@ -0,0 +1,34 @@ +export const travelRegions = [ + { + id: 'jeju', + label: 'Jeju', + bounds: [ + [33.06, 126.1], + [33.6, 126.95], + ], + }, + { + id: 'maldives', + label: 'Maldives', + bounds: [ + [-0.7, 72.6], + [7.3, 73.9], + ], + }, + { + id: 'osaka', + label: 'Osaka', + bounds: [ + [34.4, 135.2], + [35.0, 135.8], + ], + }, + { + id: 'taipei', + label: 'Taipei', + bounds: [ + [24.9, 121.3], + [25.2, 121.7], + ], + }, +]; diff --git a/src/pages/travel/Travel.css b/src/pages/travel/Travel.css index 8e350ee..0387796 100644 --- a/src/pages/travel/Travel.css +++ b/src/pages/travel/Travel.css @@ -57,6 +57,56 @@ gap: 24px; } +.travel-albums.is-blurred { + filter: blur(3px); + transition: filter 0.2s ease; +} + +.travel-albums.is-blurred * { + pointer-events: none; +} + +.travel-map { + display: grid; + gap: 18px; +} + +.travel-map__canvas { + width: 100%; + min-height: 520px; + border-radius: 22px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(10, 12, 20, 0.6); +} + +.travel-map__info { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 18px; + padding: 14px 16px; + background: rgba(10, 12, 20, 0.75); + backdrop-filter: blur(6px); + display: grid; + gap: 8px; + align-content: center; + text-align: center; +} + +.travel-map__title { + margin: 0; + font-size: 14px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--accent); +} + +.travel-map__desc { + margin: 0; + color: var(--muted); + font-size: 14px; +} + .travel-album { border: 1px solid var(--line); border-radius: 24px; @@ -66,6 +116,28 @@ gap: 18px; } +.travel-album__footer { + display: flex; + justify-content: center; +} + +.travel-load-more { + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(10, 12, 20, 0.6); + color: #f8f4f0; + border-radius: 999px; + padding: 10px 18px; + font-size: 13px; + letter-spacing: 0.02em; + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease; +} + +.travel-load-more:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.5); +} + .travel-album__head { display: flex; justify-content: space-between; @@ -114,6 +186,7 @@ overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.12); min-height: 220px; + cursor: pointer; } .travel-card.is-wide { @@ -146,6 +219,139 @@ font-size: 18px; } +.travel-modal { + position: fixed; + inset: 0; + background: rgba(6, 8, 12, 0.75); + display: grid; + align-items: start; + justify-items: center; + padding: 28px 24px 24px; + z-index: 2000; +} + +.travel-modal__content { + position: relative; + max-width: min(920px, 92vw); + max-height: 86vh; + background: rgba(10, 12, 20, 0.92); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 18px; + padding: 16px; + display: grid; + gap: 10px; + margin-top: 24px; +} + +.travel-modal__summary { + display: grid; + gap: 6px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.travel-modal__summary-title { + margin: 0; + font-size: 15px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #f1c07a; +} + +.travel-modal__summary-meta { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.travel-modal__strip { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 6px; +} + +.travel-modal__thumb { + width: 64px; + height: 48px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(10, 12, 20, 0.6); + padding: 0; + cursor: pointer; + opacity: 0.7; +} + +.travel-modal__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 10px; + display: block; +} + +.travel-modal__thumb.is-active { + opacity: 1; + border-color: rgba(255, 255, 255, 0.6); +} + +.travel-modal__content img { + width: 100%; + height: auto; + max-height: 70vh; + object-fit: contain; + border-radius: 12px; +} + +.travel-modal__meta { + margin: 0; + color: var(--muted); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.travel-modal__close { + position: absolute; + top: 10px; + right: 10px; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(10, 12, 20, 0.8); + color: #f8f4f0; + font-size: 18px; + cursor: pointer; +} + +.travel-modal__nav { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + pointer-events: none; + padding: 0 6px; +} + +.travel-modal__arrow { + pointer-events: auto; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(10, 12, 20, 0.7); + color: #f8f4f0; + font-size: 22px; + cursor: pointer; +} + .travel-card__meta { margin: 0; font-size: 12px; @@ -159,6 +365,10 @@ grid-template-columns: 1fr; } + .travel-map { + grid-template-columns: 1fr; + } + .travel-card.is-wide { grid-column: span 1; } diff --git a/src/pages/travel/Travel.jsx b/src/pages/travel/Travel.jsx index ca30d05..3dc199f 100644 --- a/src/pages/travel/Travel.jsx +++ b/src/pages/travel/Travel.jsx @@ -1,84 +1,337 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { GeoJSON, MapContainer, TileLayer, useMap } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; import './Travel.css'; +const PHOTO_CHUNK_SIZE = 60; +const THUMB_STRIP_LIMIT = 36; + const normalizePhotos = (items = []) => items .map((item) => { if (typeof item === 'string') return { src: item, title: '' }; if (!item) return null; return { - src: item.url || item.path || item.src || '', - title: item.title || item.name || '', + 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); -const getPhotoLabel = (src) => { - if (!src) return ''; - const parts = src.split('/'); +const getPhotoLabel = (photo) => { + if (!photo) return ''; + if (photo.title) return photo.title; + if (photo.file) return photo.file; + if (!photo.src) return ''; + const parts = photo.src.split('/'); return parts[parts.length - 1]; }; -const Travel = () => { - const [albums, setAlbums] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); +const getStripRange = (length, center) => { + if (length <= THUMB_STRIP_LIMIT) return [0, length]; + const half = Math.floor(THUMB_STRIP_LIMIT / 2); + let start = Math.max(0, center - half); + let end = start + THUMB_STRIP_LIMIT; + if (end > length) { + end = length; + start = end - THUMB_STRIP_LIMIT; + } + return [start, end]; +}; + +const TravelPhotoGrid = ({ photos, regionLabel, onSelectPhoto }) => { + const [visibleCount, setVisibleCount] = useState(() => + Math.min(PHOTO_CHUNK_SIZE, photos.length) + ); + const sentinelRef = useRef(null); useEffect(() => { - let cancelled = false; + setVisibleCount(Math.min(PHOTO_CHUNK_SIZE, photos.length)); + }, [photos.length]); - const loadAlbums = async () => { + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return undefined; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting) return; + setVisibleCount((prev) => + Math.min(prev + PHOTO_CHUNK_SIZE, photos.length) + ); + }, + { rootMargin: '240px' } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [photos.length]); + + const visiblePhotos = photos.slice(0, visibleCount); + const canLoadMore = visibleCount < photos.length; + + return ( + <> +
+ {visiblePhotos.map((photo, index) => { + const label = getPhotoLabel(photo); + return ( +
onSelectPhoto(index, event)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onSelectPhoto(index, event); + } + }} + > + {label} { + const img = event.currentTarget; + if (photo.original && img.src !== photo.original) { + img.src = photo.original; + } + }} + /> +
+

{label}

+
+
+ ); + })} +
+
+ {canLoadMore ? ( + + ) : null} +
+ + ); +}; + +const RegionsLayer = ({ geojson, onSelectRegion }) => { + const map = useMap(); + + if (!geojson) return null; + + return ( + ({ + color: '#7c7c7c', + weight: 1, + fillOpacity: 0.2, + })} + onEachFeature={(feature, layer) => { + layer.on('click', () => { + if (!feature?.properties?.id) return; + map.fitBounds(layer.getBounds(), { + padding: [40, 40], + animate: true, + }); + onSelectRegion({ + id: feature.properties.id, + name: feature.properties.name || feature.properties.id, + }); + }); + }} + /> + ); +}; + +const Travel = () => { + const [photos, setPhotos] = useState([]); + const [photoSummary, setPhotoSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [selectedRegion, setSelectedRegion] = useState(null); + const [regionsGeojson, setRegionsGeojson] = useState(null); + const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); + const [modalOffset, setModalOffset] = useState(24); + const cacheRef = useRef(new Map()); + const cacheTtlMs = 10 * 60 * 1000; + + useEffect(() => { + const controller = new AbortController(); + + const loadRegions = async () => { + try { + const regionRes = await fetch('/api/travel/regions', { + signal: controller.signal, + }); + if (!regionRes.ok) { + throw new Error( + `지역 정보 로딩 실패 (${regionRes.status})` + ); + } + const regionJson = await regionRes.json(); + setRegionsGeojson(regionJson); + } catch (err) { + if (err?.name === 'AbortError') return; + setError(err?.message ?? String(err)); + } + }; + + loadRegions(); + return () => controller.abort(); + }, []); + + useEffect(() => { + if (!selectedRegion) return undefined; + const controller = new AbortController(); + + const loadRegionPhotos = async () => { + const cached = cacheRef.current.get(selectedRegion.id); + if (cached && Date.now() - cached.timestamp < cacheTtlMs) { + setPhotos(cached.items); + if (cached.items.length > 0) { + setModalOffset(24); + setSelectedPhotoIndex(0); + } else { + setSelectedPhotoIndex(null); + } + return; + } setLoading(true); setError(''); try { - const albumRes = await fetch('/api/travel/albums'); - if (!albumRes.ok) { - throw new Error(`앨범 목록 로딩 실패 (${albumRes.status})`); - } - const albumJson = await albumRes.json(); - const items = albumJson.items ?? []; - - const hydrated = await Promise.all( - items.map(async (item) => { - const name = item.album || item.name || ''; - if (!name) return null; - const photoRes = await fetch( - `/api/travel/albums/${encodeURIComponent(name)}` - ); - if (!photoRes.ok) { - throw new Error(`앨범 로딩 실패: ${name}`); - } - const photoJson = await photoRes.json(); - const photos = normalizePhotos(photoJson.items ?? []); - return { - name, - count: item.count ?? photos.length, - cover: item.cover || photos[0]?.src || '', - photos, - }; - }) + const photoRes = await fetch( + `/api/travel/photos?region=${encodeURIComponent( + selectedRegion.id + )}`, + { signal: controller.signal } ); - - if (!cancelled) { - setAlbums(hydrated.filter(Boolean)); + if (!photoRes.ok) { + throw new Error( + `지역 사진 로딩 실패 (${photoRes.status})` + ); + } + const photoJson = await photoRes.json(); + const items = Array.isArray(photoJson) + ? photoJson + : photoJson.items ?? []; + const summarySource = Array.isArray(photoJson) + ? {} + : photoJson ?? {}; + setPhotoSummary({ + total: summarySource.total ?? items.length, + albums: summarySource.matched_albums ?? [], + }); + const normalized = normalizePhotos(items); + cacheRef.current.set(selectedRegion.id, { + timestamp: Date.now(), + items: normalized, + }); + setPhotos(normalized); + if (normalized.length > 0) { + setModalOffset(24); + setSelectedPhotoIndex(0); + } else { + setSelectedPhotoIndex(null); } } catch (err) { - if (!cancelled) { - setError(err?.message ?? String(err)); - } + if (err?.name === 'AbortError') return; + setError(err?.message ?? String(err)); + setPhotos([]); + setPhotoSummary(null); } finally { - if (!cancelled) { - setLoading(false); - } + setLoading(false); } }; - loadAlbums(); - return () => { - cancelled = true; + loadRegionPhotos(); + return () => controller.abort(); + }, [selectedRegion]); + + useEffect(() => { + setSelectedPhotoIndex(null); + setPhotoSummary(null); + }, [selectedRegion]); + + useEffect(() => { + if (selectedPhotoIndex === null) return undefined; + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + setSelectedPhotoIndex(null); + return; + } + if (event.key === 'ArrowLeft') { + setSelectedPhotoIndex((prev) => + prev === null + ? prev + : (prev - 1 + photos.length) % photos.length + ); + } + if (event.key === 'ArrowRight') { + setSelectedPhotoIndex((prev) => + prev === null ? prev : (prev + 1) % photos.length + ); + } }; - }, []); + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [photos.length, selectedPhotoIndex]); + + useEffect(() => { + if (selectedPhotoIndex === null) return undefined; + const { overflow } = document.body.style; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = overflow; + }; + }, [selectedPhotoIndex]); + + const [stripStart, stripEnd] = + selectedPhotoIndex === null + ? [0, 0] + : getStripRange(photos.length, selectedPhotoIndex); + + const handleSelectPhoto = (index, event) => { + if (event) { + const pointY = + typeof event.clientY === 'number' + ? event.clientY + : event?.currentTarget?.getBoundingClientRect + ? event.currentTarget.getBoundingClientRect().top + : null; + if (pointY !== null) { + const nextOffset = Math.min( + Math.max(pointY - 120, 16), + window.innerHeight - 200 + ); + setModalOffset(nextOffset); + } else { + setModalOffset(24); + } + } else { + setModalOffset(24); + } + setSelectedPhotoIndex(index); + }; return (
@@ -87,64 +340,182 @@ const Travel = () => {

Visual Diary

Travel Archive

- 여행에서 본 색감과 분위기를 모아 전시하는 페이지입니다. + 여행에서 느낀 감성과 분위기를 모아 전시하는 페이지입니다.

-

렌더링 포인트

+

폴더별 큐레이션

- 사진마다 그리드 크기를 다르게 배치해 리듬을 만들었습니다. + 지역마다 그리드와 기록의 흐름을 다르게 배치해 리듬감을 만들었습니다.

-
- {loading ?

앨범을 불러오는 중...

: null} +
+
+
+

Select a region

+

+ {selectedRegion + ? `${selectedRegion.name} 사진을 불러옵니다.` + : '지도에서 지역을 클릭하면 해당 사진만 로딩됩니다.'} +

+
+ + + + +
+ + {loading ? ( +

사진을 불러오는 중...

+ ) : null} {error ?

{error}

: null} - {!loading && !error && albums.length === 0 ? ( -

표시할 앨범이 없습니다.

+ {!loading && !error && selectedRegion && photos.length === 0 ? ( +

+ 선택한 지역에 사진이 없습니다. +

) : null} - {albums.map((album) => ( -
-
-
-

Album

-

{album.name}

-

- {album.count} photos + {null} +

+ {selectedPhotoIndex !== null ? ( +
setSelectedPhotoIndex(null)} + style={{ '--modal-offset': `${modalOffset}px` }} + > +
event.stopPropagation()} + style={{ marginTop: `${modalOffset}px` }} + > +
+

+ {selectedRegion?.name || 'Region'} ·{' '} + {photoSummary?.total ?? photos.length} photos +

+ {photoSummary?.albums?.length ? ( +

+ {photoSummary.albums + .map((album) => album.album) + .join(', ')}

-
- {album.cover ? ( - {`${album.name} ) : null}
-
- {album.photos.map((photo, index) => { - const label = photo.title || getPhotoLabel(photo.src); - return ( -
- {label} -
-

{label}

-
-
- ); - })} + +
+ +
+
+ {photos + .slice(stripStart, stripEnd) + .map((photo, idx) => { + const realIndex = stripStart + idx; + return ( + + ); + })} +
+ {getPhotoLabel(photos[selectedPhotoIndex])} + {photos[selectedPhotoIndex]?.album || + photos[selectedPhotoIndex]?.file ? ( +

+ {photos[selectedPhotoIndex]?.album}{' '} + {photos[selectedPhotoIndex]?.file + ? `- ${photos[selectedPhotoIndex]?.file}` + : ''} +

+ ) : null}
- ))} -
+ + ) : null} ); };