{label}
+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 ( + <> +
{label}
+Visual Diary
- 여행에서 본 색감과 분위기를 모아 전시하는 페이지입니다. + 여행에서 느낀 감성과 분위기를 모아 전시하는 페이지입니다.
렌더링 포인트
+폴더별 큐레이션
- 사진마다 그리드 크기를 다르게 배치해 리듬을 만들었습니다. + 지역마다 그리드와 기록의 흐름을 다르게 배치해 리듬감을 만들었습니다.
앨범을 불러오는 중...
: null} +Select a region
++ {selectedRegion + ? `${selectedRegion.name} 사진을 불러옵니다.` + : '지도에서 지역을 클릭하면 해당 사진만 로딩됩니다.'} +
+사진을 불러오는 중...
+ ) : null} {error ?{error}
: null} - {!loading && !error && albums.length === 0 ? ( -표시할 앨범이 없습니다.
+ {!loading && !error && selectedRegion && photos.length === 0 ? ( ++ 선택한 지역에 사진이 없습니다. +
) : null} - {albums.map((album) => ( -Album
-- {album.count} photos + {null} + + {selectedPhotoIndex !== null ? ( +
+ {selectedRegion?.name || 'Region'} ·{' '} + {photoSummary?.total ?? photos.length} photos +
+ {photoSummary?.albums?.length ? ( ++ {photoSummary.albums + .map((album) => album.album) + .join(', ')}
-{label}
-+ {photos[selectedPhotoIndex]?.album}{' '} + {photos[selectedPhotoIndex]?.file + ? `- ${photos[selectedPhotoIndex]?.file}` + : ''} +
+ ) : null}