여행 탭 develop
- 지도 추가 - 지도 매칭하여 해당 여행 사진 매칭 - 사진 클릭시 모달로 사진 렌더링 (lazy, cache, thumb 사용)
This commit is contained in:
113
package-lock.json
generated
113
package-lock.json
generated
@@ -8,14 +8,16 @@
|
|||||||
"name": "web-ui",
|
"name": "web-ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"leaflet": "^1.9.4",
|
||||||
"react-dom": "^19.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-router-dom": "^6.30.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^18.2.79",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.2.25",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
@@ -1032,6 +1034,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -1457,25 +1470,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.8",
|
"version": "18.2.79",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz",
|
||||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
"integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"@types/prop-types": "*",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "18.2.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"dependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
@@ -2285,7 +2305,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -2358,6 +2377,13 @@
|
|||||||
"json-buffer": "3.0.1"
|
"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": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -2395,6 +2421,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -2656,26 +2694,44 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.3",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"loose-envify": "^1.1.0",
|
||||||
|
"scheduler": "^0.23.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
@@ -2796,10 +2852,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -13,14 +13,16 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"leaflet": "^1.9.4",
|
||||||
"react-dom": "^19.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
"react-router-dom": "^6.30.3"
|
"react-router-dom": "^6.30.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^18.2.79",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.2.25",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
|||||||
34
src/data/travelRegions.js
Normal file
34
src/data/travelRegions.js
Normal file
@@ -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],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -57,6 +57,56 @@
|
|||||||
gap: 24px;
|
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 {
|
.travel-album {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
@@ -66,6 +116,28 @@
|
|||||||
gap: 18px;
|
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 {
|
.travel-album__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -114,6 +186,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.travel-card.is-wide {
|
.travel-card.is-wide {
|
||||||
@@ -146,6 +219,139 @@
|
|||||||
font-size: 18px;
|
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 {
|
.travel-card__meta {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -159,6 +365,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.travel-map {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.travel-card.is-wide {
|
.travel-card.is-wide {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import './Travel.css';
|
||||||
|
|
||||||
|
const PHOTO_CHUNK_SIZE = 60;
|
||||||
|
const THUMB_STRIP_LIMIT = 36;
|
||||||
|
|
||||||
const normalizePhotos = (items = []) =>
|
const normalizePhotos = (items = []) =>
|
||||||
items
|
items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (typeof item === 'string') return { src: item, title: '' };
|
if (typeof item === 'string') return { src: item, title: '' };
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
return {
|
return {
|
||||||
src: item.url || item.path || item.src || '',
|
src: item.thumb || item.url || item.path || item.src || '',
|
||||||
title: item.title || item.name || '',
|
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);
|
.filter((item) => item && item.src);
|
||||||
|
|
||||||
const getPhotoLabel = (src) => {
|
const getPhotoLabel = (photo) => {
|
||||||
if (!src) return '';
|
if (!photo) return '';
|
||||||
const parts = src.split('/');
|
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];
|
return parts[parts.length - 1];
|
||||||
};
|
};
|
||||||
|
|
||||||
const Travel = () => {
|
const getStripRange = (length, center) => {
|
||||||
const [albums, setAlbums] = useState([]);
|
if (length <= THUMB_STRIP_LIMIT) return [0, length];
|
||||||
const [loading, setLoading] = useState(true);
|
const half = Math.floor(THUMB_STRIP_LIMIT / 2);
|
||||||
const [error, setError] = useState('');
|
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(() => {
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="travel-grid">
|
||||||
|
{visiblePhotos.map((photo, index) => {
|
||||||
|
const label = getPhotoLabel(photo);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={`${regionLabel}-${photo.src}`}
|
||||||
|
className={`travel-card ${
|
||||||
|
index % 6 === 0 ? 'is-wide' : ''
|
||||||
|
}`}
|
||||||
|
onClick={(event) => onSelectPhoto(index, event)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
onSelectPhoto(index, event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.src}
|
||||||
|
alt={label}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(event) => {
|
||||||
|
const img = event.currentTarget;
|
||||||
|
if (photo.original && img.src !== photo.original) {
|
||||||
|
img.src = photo.original;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="travel-card__overlay">
|
||||||
|
<p className="travel-card__title">{label}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="travel-album__footer" ref={sentinelRef}>
|
||||||
|
{canLoadMore ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="travel-load-more"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleCount((prev) =>
|
||||||
|
Math.min(prev + PHOTO_CHUNK_SIZE, photos.length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Load more ({visibleCount}/{photos.length})
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RegionsLayer = ({ geojson, onSelectRegion }) => {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
if (!geojson) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GeoJSON
|
||||||
|
data={geojson}
|
||||||
|
style={() => ({
|
||||||
|
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);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const albumRes = await fetch('/api/travel/albums');
|
const photoRes = await fetch(
|
||||||
if (!albumRes.ok) {
|
`/api/travel/photos?region=${encodeURIComponent(
|
||||||
throw new Error(`앨범 목록 로딩 실패 (${albumRes.status})`);
|
selectedRegion.id
|
||||||
}
|
)}`,
|
||||||
const albumJson = await albumRes.json();
|
{ signal: controller.signal }
|
||||||
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,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
if (!photoRes.ok) {
|
||||||
if (!cancelled) {
|
throw new Error(
|
||||||
setAlbums(hydrated.filter(Boolean));
|
`지역 사진 로딩 실패 (${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) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (err?.name === 'AbortError') return;
|
||||||
setError(err?.message ?? String(err));
|
setError(err?.message ?? String(err));
|
||||||
}
|
setPhotos([]);
|
||||||
|
setPhotoSummary(null);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
setLoading(false);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadAlbums();
|
loadRegionPhotos();
|
||||||
return () => {
|
return () => controller.abort();
|
||||||
cancelled = true;
|
}, [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 (
|
return (
|
||||||
<div className="travel">
|
<div className="travel">
|
||||||
@@ -87,64 +340,182 @@ const Travel = () => {
|
|||||||
<p className="travel-kicker">Visual Diary</p>
|
<p className="travel-kicker">Visual Diary</p>
|
||||||
<h1>Travel Archive</h1>
|
<h1>Travel Archive</h1>
|
||||||
<p className="travel-sub">
|
<p className="travel-sub">
|
||||||
여행에서 본 색감과 분위기를 모아 전시하는 페이지입니다.
|
여행에서 느낀 감성과 분위기를 모아 전시하는 페이지입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="travel-note">
|
<div className="travel-note">
|
||||||
<p className="travel-note__title">렌더링 포인트</p>
|
<p className="travel-note__title">폴더별 큐레이션</p>
|
||||||
<p className="travel-note__desc">
|
<p className="travel-note__desc">
|
||||||
사진마다 그리드 크기를 다르게 배치해 리듬을 만들었습니다.
|
지역마다 그리드와 기록의 흐름을 다르게 배치해 리듬감을 만들었습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="travel-albums">
|
<section
|
||||||
{loading ? <p className="travel-state">앨범을 불러오는 중...</p> : null}
|
className={`travel-albums ${
|
||||||
|
selectedPhotoIndex !== null ? 'is-blurred' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="travel-map">
|
||||||
|
<div className="travel-map__info">
|
||||||
|
<p className="travel-map__title">Select a region</p>
|
||||||
|
<p className="travel-map__desc">
|
||||||
|
{selectedRegion
|
||||||
|
? `${selectedRegion.name} 사진을 불러옵니다.`
|
||||||
|
: '지도에서 지역을 클릭하면 해당 사진만 로딩됩니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MapContainer
|
||||||
|
center={[20, 0]}
|
||||||
|
zoom={2}
|
||||||
|
scrollWheelZoom
|
||||||
|
className="travel-map__canvas"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
|
/>
|
||||||
|
<RegionsLayer
|
||||||
|
geojson={regionsGeojson}
|
||||||
|
onSelectRegion={setSelectedRegion}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="travel-state">사진을 불러오는 중...</p>
|
||||||
|
) : null}
|
||||||
{error ? <p className="travel-error">{error}</p> : null}
|
{error ? <p className="travel-error">{error}</p> : null}
|
||||||
{!loading && !error && albums.length === 0 ? (
|
{!loading && !error && selectedRegion && photos.length === 0 ? (
|
||||||
<p className="travel-state">표시할 앨범이 없습니다.</p>
|
<p className="travel-state">
|
||||||
|
선택한 지역에 사진이 없습니다.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{albums.map((album) => (
|
{null}
|
||||||
<div key={album.name} className="travel-album">
|
</section>
|
||||||
<div className="travel-album__head">
|
{selectedPhotoIndex !== null ? (
|
||||||
<div>
|
<div
|
||||||
<p className="travel-album__eyebrow">Album</p>
|
className="travel-modal"
|
||||||
<h2>{album.name}</h2>
|
role="dialog"
|
||||||
<p className="travel-album__meta">
|
aria-modal="true"
|
||||||
{album.count} photos
|
onClick={() => setSelectedPhotoIndex(null)}
|
||||||
|
style={{ '--modal-offset': `${modalOffset}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="travel-modal__content"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
style={{ marginTop: `${modalOffset}px` }}
|
||||||
|
>
|
||||||
|
<div className="travel-modal__summary">
|
||||||
|
<p className="travel-modal__summary-title">
|
||||||
|
{selectedRegion?.name || 'Region'} ·{' '}
|
||||||
|
{photoSummary?.total ?? photos.length} photos
|
||||||
|
</p>
|
||||||
|
{photoSummary?.albums?.length ? (
|
||||||
|
<p className="travel-modal__summary-meta">
|
||||||
|
{photoSummary.albums
|
||||||
|
.map((album) => album.album)
|
||||||
|
.join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
{album.cover ? (
|
|
||||||
<img
|
|
||||||
className="travel-album__cover"
|
|
||||||
src={album.cover}
|
|
||||||
alt={`${album.name} cover`}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="travel-grid">
|
<button
|
||||||
{album.photos.map((photo, index) => {
|
type="button"
|
||||||
const label = photo.title || getPhotoLabel(photo.src);
|
className="travel-modal__close"
|
||||||
return (
|
onClick={() => setSelectedPhotoIndex(null)}
|
||||||
<article
|
aria-label="Close"
|
||||||
key={`${album.name}-${photo.src}`}
|
>
|
||||||
className={`travel-card ${
|
×
|
||||||
index % 6 === 0 ? 'is-wide' : ''
|
</button>
|
||||||
}`}
|
<div className="travel-modal__nav">
|
||||||
>
|
<button
|
||||||
<img src={photo.src} alt={label} loading="lazy" />
|
type="button"
|
||||||
<div className="travel-card__overlay">
|
className="travel-modal__arrow"
|
||||||
<p className="travel-card__title">{label}</p>
|
onClick={() =>
|
||||||
</div>
|
setSelectedPhotoIndex((prev) =>
|
||||||
</article>
|
prev === null
|
||||||
);
|
? prev
|
||||||
})}
|
: (prev - 1 + photos.length) %
|
||||||
|
photos.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
{'<'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="travel-modal__arrow"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedPhotoIndex((prev) =>
|
||||||
|
prev === null
|
||||||
|
? prev
|
||||||
|
: (prev + 1) % photos.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
{'>'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="travel-modal__strip" role="list">
|
||||||
|
{photos
|
||||||
|
.slice(stripStart, stripEnd)
|
||||||
|
.map((photo, idx) => {
|
||||||
|
const realIndex = stripStart + idx;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${photo.src}-${realIndex}`}
|
||||||
|
type="button"
|
||||||
|
className={`travel-modal__thumb ${
|
||||||
|
realIndex === selectedPhotoIndex
|
||||||
|
? 'is-active'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedPhotoIndex(realIndex)
|
||||||
|
}
|
||||||
|
aria-label={getPhotoLabel(photo)}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={photo.src}
|
||||||
|
alt={getPhotoLabel(photo)}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(event) => {
|
||||||
|
const img = event.currentTarget;
|
||||||
|
if (
|
||||||
|
photo.original &&
|
||||||
|
img.src !== photo.original
|
||||||
|
) {
|
||||||
|
img.src = photo.original;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
photos[selectedPhotoIndex]?.original ||
|
||||||
|
photos[selectedPhotoIndex]?.src
|
||||||
|
}
|
||||||
|
alt={getPhotoLabel(photos[selectedPhotoIndex])}
|
||||||
|
/>
|
||||||
|
{photos[selectedPhotoIndex]?.album ||
|
||||||
|
photos[selectedPhotoIndex]?.file ? (
|
||||||
|
<p className="travel-modal__meta">
|
||||||
|
{photos[selectedPhotoIndex]?.album}{' '}
|
||||||
|
{photos[selectedPhotoIndex]?.file
|
||||||
|
? `- ${photos[selectedPhotoIndex]?.file}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</section>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user