feat(ui): update UI v1
@@ -9,6 +9,12 @@ The **Pinar** font is installed and served locally. The application no longer re
|
||||
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
||||
|
||||
## Recent UI/UX Improvements
|
||||
* **Projects Map Restoration**: Successfully re-integrated and mounted the interactive project overview map directly at the top of the Projects page to display all project markers.
|
||||
* **Project Details Update**: Revamped and updated the presentation of project details for better clarity and structure.
|
||||
* **Stats Section Relocation**: Moved the statistics (metrics) section higher up in the page layout for better visibility and impact.
|
||||
* **FAQ Section Redesign**: Refactored the FAQ layout into a centered vertical stack with full RTL/LTR responsiveness, featuring an integrated centered header block (title, description, and accent line) and direction-aware (`text-start`) accordion content.
|
||||
* **Newsletter Section Polish**: Updated the newsletter container to a clean solid `bg-copper` background while introducing high-contrast button styling (`bg-ink` default transitioning to white on hover) preserving site-wide kinetic animation logic.
|
||||
* **Article Cards Spacing**: Refined vertical layout spacing and responsive gaps between title, excerpt, and footer blocks without impacting typography scales.
|
||||
* **Tiptap Editor Fixes**:
|
||||
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
||||
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||
@@ -60,3 +66,11 @@ npm run build
|
||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
|
||||
```
|
||||
|
||||
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، میتوانید از این کامیت یکخطی استفاده کنید:
|
||||
|
||||
```bash
|
||||
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
@@ -1041,6 +1044,92 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -1973,6 +2062,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2450,6 +2545,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -2783,6 +2887,12 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -3157,6 +3267,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -3310,6 +3426,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -3637,6 +3759,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -3650,6 +3778,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -3753,6 +3887,39 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
|
||||
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||
"@maplibre/mlt": "^1.3.0",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
@@ -3796,6 +3963,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
@@ -3817,6 +3993,12 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -4042,6 +4224,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4081,6 +4275,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pmtiles": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
|
||||
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -4253,6 +4456,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -4413,6 +4622,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4444,6 +4659,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -4501,6 +4722,15 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -4623,6 +4853,15 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -4653,6 +4892,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -5046,6 +5294,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npx vite",
|
||||
"build": "npx vite build",
|
||||
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"preview": "npx vite preview"
|
||||
},
|
||||
@@ -19,9 +20,12 @@
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
// node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
var fleb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
/* unused */
|
||||
0,
|
||||
0,
|
||||
/* impossible */
|
||||
0
|
||||
]);
|
||||
var fdeb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
8,
|
||||
8,
|
||||
9,
|
||||
9,
|
||||
10,
|
||||
10,
|
||||
11,
|
||||
11,
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
13,
|
||||
/* unused */
|
||||
0,
|
||||
0
|
||||
]);
|
||||
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||
var freb = function(eb, start) {
|
||||
var b2 = new u16(31);
|
||||
for (var i = 0; i < 31; ++i) {
|
||||
b2[i] = start += 1 << eb[i - 1];
|
||||
}
|
||||
var r = new i32(b2[30]);
|
||||
for (var i = 1; i < 30; ++i) {
|
||||
for (var j2 = b2[i]; j2 < b2[i + 1]; ++j2) {
|
||||
r[j2] = j2 - b2[i] << 5 | i;
|
||||
}
|
||||
}
|
||||
return { b: b2, r };
|
||||
};
|
||||
var _a = freb(fleb, 2);
|
||||
var fl = _a.b;
|
||||
var revfl = _a.r;
|
||||
fl[28] = 258, revfl[258] = 28;
|
||||
var _b = freb(fdeb, 0);
|
||||
var fd = _b.b;
|
||||
var revfd = _b.r;
|
||||
var rev = new u16(32768);
|
||||
for (i = 0; i < 32768; ++i) {
|
||||
x2 = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||
x2 = (x2 & 52428) >> 2 | (x2 & 13107) << 2;
|
||||
x2 = (x2 & 61680) >> 4 | (x2 & 3855) << 4;
|
||||
rev[i] = ((x2 & 65280) >> 8 | (x2 & 255) << 8) >> 1;
|
||||
}
|
||||
var x2;
|
||||
var i;
|
||||
var hMap = function(cd, mb, r) {
|
||||
var s = cd.length;
|
||||
var i = 0;
|
||||
var l = new u16(mb);
|
||||
for (; i < s; ++i) {
|
||||
if (cd[i])
|
||||
++l[cd[i] - 1];
|
||||
}
|
||||
var le = new u16(mb);
|
||||
for (i = 1; i < mb; ++i) {
|
||||
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||
}
|
||||
var co;
|
||||
if (r) {
|
||||
co = new u16(1 << mb);
|
||||
var rvb = 15 - mb;
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
var sv = i << 4 | cd[i];
|
||||
var r_1 = mb - cd[i];
|
||||
var v2 = le[cd[i] - 1]++ << r_1;
|
||||
for (var m = v2 | (1 << r_1) - 1; v2 <= m; ++v2) {
|
||||
co[rev[v2] >> rvb] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
co = new u16(s);
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return co;
|
||||
};
|
||||
var flt = new u8(288);
|
||||
for (i = 0; i < 144; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
for (i = 144; i < 256; ++i)
|
||||
flt[i] = 9;
|
||||
var i;
|
||||
for (i = 256; i < 280; ++i)
|
||||
flt[i] = 7;
|
||||
var i;
|
||||
for (i = 280; i < 288; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
var fdt = new u8(32);
|
||||
for (i = 0; i < 32; ++i)
|
||||
fdt[i] = 5;
|
||||
var i;
|
||||
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||
var max = function(a) {
|
||||
var m = a[0];
|
||||
for (var i = 1; i < a.length; ++i) {
|
||||
if (a[i] > m)
|
||||
m = a[i];
|
||||
}
|
||||
return m;
|
||||
};
|
||||
var bits = function(d2, p, m) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8) >> (p & 7) & m;
|
||||
};
|
||||
var bits16 = function(d2, p) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8 | d2[o + 2] << 16) >> (p & 7);
|
||||
};
|
||||
var shft = function(p) {
|
||||
return (p + 7) / 8 | 0;
|
||||
};
|
||||
var slc = function(v2, s, e) {
|
||||
if (s == null || s < 0)
|
||||
s = 0;
|
||||
if (e == null || e > v2.length)
|
||||
e = v2.length;
|
||||
return new u8(v2.subarray(s, e));
|
||||
};
|
||||
var ec = [
|
||||
"unexpected EOF",
|
||||
"invalid block type",
|
||||
"invalid length/literal",
|
||||
"invalid distance",
|
||||
"stream finished",
|
||||
"no stream handler",
|
||||
,
|
||||
// determined by compression function
|
||||
"no callback",
|
||||
"invalid UTF-8 data",
|
||||
"extra field too long",
|
||||
"date not in range 1980-2099",
|
||||
"filename too long",
|
||||
"stream finishing",
|
||||
"invalid zip data"
|
||||
// determined by unknown compression method
|
||||
];
|
||||
var err = function(ind, msg, nt) {
|
||||
var e = new Error(msg || ec[ind]);
|
||||
e.code = ind;
|
||||
if (Error.captureStackTrace)
|
||||
Error.captureStackTrace(e, err);
|
||||
if (!nt)
|
||||
throw e;
|
||||
return e;
|
||||
};
|
||||
var inflt = function(dat, st, buf, dict) {
|
||||
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||
if (!sl || st.f && !st.l)
|
||||
return buf || new u8(0);
|
||||
var noBuf = !buf;
|
||||
var resize = noBuf || st.i != 2;
|
||||
var noSt = st.i;
|
||||
if (noBuf)
|
||||
buf = new u8(sl * 3);
|
||||
var cbuf = function(l2) {
|
||||
var bl = buf.length;
|
||||
if (l2 > bl) {
|
||||
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||
nbuf.set(buf);
|
||||
buf = nbuf;
|
||||
}
|
||||
};
|
||||
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||
var tbts = sl * 8;
|
||||
do {
|
||||
if (!lm) {
|
||||
final = bits(dat, pos, 1);
|
||||
var type = bits(dat, pos + 1, 3);
|
||||
pos += 3;
|
||||
if (!type) {
|
||||
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||
if (t > sl) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + l);
|
||||
buf.set(dat.subarray(s, t), bt);
|
||||
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||
continue;
|
||||
} else if (type == 1)
|
||||
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||
else if (type == 2) {
|
||||
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||
pos += 14;
|
||||
var ldt = new u8(tl);
|
||||
var clt = new u8(19);
|
||||
for (var i = 0; i < hcLen; ++i) {
|
||||
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||
}
|
||||
pos += hcLen * 3;
|
||||
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||
var clm = hMap(clt, clb, 1);
|
||||
for (var i = 0; i < tl; ) {
|
||||
var r = clm[bits(dat, pos, clbmsk)];
|
||||
pos += r & 15;
|
||||
var s = r >> 4;
|
||||
if (s < 16) {
|
||||
ldt[i++] = s;
|
||||
} else {
|
||||
var c = 0, n = 0;
|
||||
if (s == 16)
|
||||
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||
else if (s == 17)
|
||||
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||
else if (s == 18)
|
||||
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||
while (n--)
|
||||
ldt[i++] = c;
|
||||
}
|
||||
}
|
||||
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||
lbt = max(lt);
|
||||
dbt = max(dt);
|
||||
lm = hMap(lt, lbt, 1);
|
||||
dm = hMap(dt, dbt, 1);
|
||||
} else
|
||||
err(1);
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||
var lpos = pos;
|
||||
for (; ; lpos = pos) {
|
||||
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||
pos += c & 15;
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (!c)
|
||||
err(2);
|
||||
if (sym < 256)
|
||||
buf[bt++] = sym;
|
||||
else if (sym == 256) {
|
||||
lpos = pos, lm = null;
|
||||
break;
|
||||
} else {
|
||||
var add = sym - 254;
|
||||
if (sym > 264) {
|
||||
var i = sym - 257, b2 = fleb[i];
|
||||
add = bits(dat, pos, (1 << b2) - 1) + fl[i];
|
||||
pos += b2;
|
||||
}
|
||||
var d2 = dm[bits16(dat, pos) & dms], dsym = d2 >> 4;
|
||||
if (!d2)
|
||||
err(3);
|
||||
pos += d2 & 15;
|
||||
var dt = fd[dsym];
|
||||
if (dsym > 3) {
|
||||
var b2 = fdeb[dsym];
|
||||
dt += bits16(dat, pos) & (1 << b2) - 1, pos += b2;
|
||||
}
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var end = bt + add;
|
||||
if (bt < dt) {
|
||||
var shift = dl - dt, dend = Math.min(dt, end);
|
||||
if (shift + bt < 0)
|
||||
err(3);
|
||||
for (; bt < dend; ++bt)
|
||||
buf[bt] = dict[shift + bt];
|
||||
}
|
||||
for (; bt < end; ++bt)
|
||||
buf[bt] = buf[bt - dt];
|
||||
}
|
||||
}
|
||||
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||
if (lm)
|
||||
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||
} while (!final);
|
||||
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||
};
|
||||
var et = /* @__PURE__ */ new u8(0);
|
||||
var gzs = function(d2) {
|
||||
if (d2[0] != 31 || d2[1] != 139 || d2[2] != 8)
|
||||
err(6, "invalid gzip data");
|
||||
var flg = d2[3];
|
||||
var st = 10;
|
||||
if (flg & 4)
|
||||
st += (d2[10] | d2[11] << 8) + 2;
|
||||
for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d2[st++])
|
||||
;
|
||||
return st + (flg & 2);
|
||||
};
|
||||
var gzl = function(d2) {
|
||||
var l = d2.length;
|
||||
return (d2[l - 4] | d2[l - 3] << 8 | d2[l - 2] << 16 | d2[l - 1] << 24) >>> 0;
|
||||
};
|
||||
var zls = function(d2, dict) {
|
||||
if ((d2[0] & 15) != 8 || d2[0] >> 4 > 7 || (d2[0] << 8 | d2[1]) % 31)
|
||||
err(6, "invalid zlib data");
|
||||
if ((d2[1] >> 5 & 1) == +!dict)
|
||||
err(6, "invalid zlib data: " + (d2[1] & 32 ? "need" : "unexpected") + " dictionary");
|
||||
return (d2[1] >> 3 & 4) + 2;
|
||||
};
|
||||
function inflateSync(data, opts) {
|
||||
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function gunzipSync(data, opts) {
|
||||
var st = gzs(data);
|
||||
if (st + 8 > data.length)
|
||||
err(6, "invalid gzip data");
|
||||
return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
|
||||
}
|
||||
function unzlibSync(data, opts) {
|
||||
return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function decompressSync(data, opts) {
|
||||
return data[0] == 31 && data[1] == 139 && data[2] == 8 ? gunzipSync(data, opts) : (data[0] & 15) != 8 || data[0] >> 4 > 7 || (data[0] << 8 | data[1]) % 31 ? inflateSync(data, opts) : unzlibSync(data, opts);
|
||||
}
|
||||
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||
var tds = 0;
|
||||
try {
|
||||
td.decode(et, { stream: true });
|
||||
tds = 1;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// node_modules/pmtiles/dist/esm/index.js
|
||||
var j = Object.defineProperty;
|
||||
var S = Math.pow;
|
||||
var d = (o, t) => j(o, "name", { value: t, configurable: true });
|
||||
var h = (o, t, e) => new Promise((r, n) => {
|
||||
var s = (c) => {
|
||||
try {
|
||||
a(e.next(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, i = (c) => {
|
||||
try {
|
||||
a(e.throw(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, a = (c) => c.done ? r(c.value) : Promise.resolve(c.value).then(s, i);
|
||||
a((e = e.apply(o, t)).next());
|
||||
});
|
||||
var re = d((o, t) => {
|
||||
let e = false, r = "", n = L.GridLayer.extend({ createTile: d((s, i) => {
|
||||
let a = document.createElement("img"), c = new AbortController(), l = c.signal;
|
||||
return a.cancel = () => {
|
||||
c.abort();
|
||||
}, e || (o.getHeader().then((u) => {
|
||||
u.tileType === 1 || u.tileType === 6 ? console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details.") : u.tileType === 2 ? r = "image/png" : u.tileType === 3 ? r = "image/jpeg" : u.tileType === 4 ? r = "image/webp" : u.tileType === 5 && (r = "image/avif");
|
||||
}), e = true), o.getZxy(s.z, s.x, s.y, l).then((u) => {
|
||||
if (u) {
|
||||
let m = new Blob([u.data], { type: r }), g = window.URL.createObjectURL(m);
|
||||
a.src = g;
|
||||
} else a.style.display = "none";
|
||||
a.cancel = void 0, i(void 0, a);
|
||||
}).catch((u) => {
|
||||
if (u.name !== "AbortError") throw u;
|
||||
}), a;
|
||||
}, "createTile"), _removeTile: d(function(s) {
|
||||
let i = this._tiles[s];
|
||||
i && (i.el.cancel && i.el.cancel(), i.el.src && window.URL.revokeObjectURL(i.el.src), i.el.width = 0, i.el.height = 0, i.el.deleted = true, L.DomUtil.remove(i.el), delete this._tiles[s], this.fire("tileunload", { tile: i.el, coords: this._keyToTileCoords(s) }));
|
||||
}, "_removeTile") });
|
||||
return new n(t);
|
||||
}, "leafletRasterLayer");
|
||||
var z = d((o) => (t, e) => {
|
||||
if (e instanceof AbortController) return o(t, e);
|
||||
let r = new AbortController();
|
||||
return o(t, r).then((n) => e(void 0, n.data, n.cacheControl || "", n.expires || ""), (n) => e(n)).catch((n) => e(n)), { cancel: d(() => r.abort(), "cancel") };
|
||||
}, "v3compat");
|
||||
var A = class A2 {
|
||||
constructor(t) {
|
||||
this.tilev4 = d((t2, e) => h(this, null, function* () {
|
||||
if (t2.type === "json") {
|
||||
let g = t2.url.substr(10), p = this.tiles.get(g);
|
||||
if (p || (p = new w(g), this.tiles.set(g, p)), this.metadata) {
|
||||
let K = yield p.getTileJson(t2.url);
|
||||
return e.signal.throwIfAborted(), { data: K };
|
||||
}
|
||||
let f = yield p.getHeader();
|
||||
return e.signal.throwIfAborted(), (f.minLon >= f.maxLon || f.minLat >= f.maxLat) && console.error(`Bounds of PMTiles archive ${f.minLon},${f.minLat},${f.maxLon},${f.maxLat} are not valid.`), { data: { tiles: [`${t2.url}/{z}/{x}/{y}`], minzoom: f.minZoom, maxzoom: f.maxZoom, bounds: [f.minLon, f.minLat, f.maxLon, f.maxLat] } };
|
||||
}
|
||||
let r = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/), n = t2.url.match(r);
|
||||
if (!n) throw new Error("Invalid PMTiles protocol URL");
|
||||
let s = n[1], i = this.tiles.get(s);
|
||||
i || (i = new w(s), this.tiles.set(s, i));
|
||||
let a = n[2], c = n[3], l = n[4], u = yield i == null ? void 0 : i.getZxy(+a, +c, +l, e.signal);
|
||||
if (e.signal.throwIfAborted(), u) return { data: new Uint8Array(u.data), cacheControl: u.cacheControl, expires: u.expires };
|
||||
let m = yield i.getHeader();
|
||||
if (m.tileType === 1 || m.tileType === 6) {
|
||||
if (this.errorOnMissingTile) throw new Error("Tile not found.");
|
||||
return { data: new Uint8Array() };
|
||||
}
|
||||
return { data: null };
|
||||
}), "tilev4");
|
||||
this.tile = z(this.tilev4);
|
||||
this.tiles = /* @__PURE__ */ new Map(), this.metadata = (t == null ? void 0 : t.metadata) || false, this.errorOnMissingTile = (t == null ? void 0 : t.errorOnMissingTile) || false;
|
||||
}
|
||||
add(t) {
|
||||
this.tiles.set(t.source.getKey(), t);
|
||||
}
|
||||
get(t) {
|
||||
return this.tiles.get(t);
|
||||
}
|
||||
};
|
||||
d(A, "Protocol");
|
||||
var B = A;
|
||||
function b(o, t) {
|
||||
return (t >>> 0) * 4294967296 + (o >>> 0);
|
||||
}
|
||||
d(b, "toNum");
|
||||
function N(o, t) {
|
||||
let e = t.buf, r = e[t.pos++], n = (r & 112) >> 4;
|
||||
if (r < 128 || (r = e[t.pos++], n |= (r & 127) << 3, r < 128) || (r = e[t.pos++], n |= (r & 127) << 10, r < 128) || (r = e[t.pos++], n |= (r & 127) << 17, r < 128) || (r = e[t.pos++], n |= (r & 127) << 24, r < 128) || (r = e[t.pos++], n |= (r & 1) << 31, r < 128)) return b(o, n);
|
||||
throw new Error("Expected varint not more than 10 bytes");
|
||||
}
|
||||
d(N, "readVarintRemainder");
|
||||
function x(o) {
|
||||
let t = o.buf, e = t[o.pos++], r = e & 127;
|
||||
return e < 128 || (e = t[o.pos++], r |= (e & 127) << 7, e < 128) || (e = t[o.pos++], r |= (e & 127) << 14, e < 128) || (e = t[o.pos++], r |= (e & 127) << 21, e < 128) ? r : (e = t[o.pos], r |= (e & 15) << 28, N(r, o));
|
||||
}
|
||||
d(x, "readVarint");
|
||||
function I(o, t, e, r, n) {
|
||||
return n === 0 ? r !== 0 ? [o - 1 - e, o - 1 - t] : [e, t] : [t, e];
|
||||
}
|
||||
d(I, "rotate");
|
||||
function q(o, t, e) {
|
||||
if (o > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
if (t >= 1 << o || e >= 1 << o) throw new Error("tile x/y outside zoom level bounds");
|
||||
let r = ((1 << o) * (1 << o) - 1) / 3, n = o - 1, [s, i] = [t, e];
|
||||
for (let a = 1 << n; a > 0; a >>= 1) {
|
||||
let c = s & a, l = i & a;
|
||||
r += (3 * c ^ l) * (1 << n), [s, i] = I(a, s, i, c, l), n--;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(q, "zxyToTileId");
|
||||
function G(o) {
|
||||
let t = 3 * o + 1;
|
||||
return t < 4294967296 ? 31 - Math.clz32(t) : 63 - Math.clz32(t / 4294967296);
|
||||
}
|
||||
d(G, "tileIdToZ");
|
||||
function ie(o) {
|
||||
let t = G(o) >> 1;
|
||||
if (t > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
let e = ((1 << t) * (1 << t) - 1) / 3, r = o - e, n = 0, s = 0, i = 1 << t;
|
||||
for (let a = 1; a < i; a <<= 1) {
|
||||
let c = a & r / 2, l = a & (r ^ c);
|
||||
[n, s] = I(a, n, s, c, l), r = r / 2, n += c, s += l;
|
||||
}
|
||||
return [t, n, s];
|
||||
}
|
||||
d(ie, "tileIdToZxy");
|
||||
var J = ((s) => (s[s.Unknown = 0] = "Unknown", s[s.None = 1] = "None", s[s.Gzip = 2] = "Gzip", s[s.Brotli = 3] = "Brotli", s[s.Zstd = 4] = "Zstd", s))(J || {});
|
||||
function P(o, t) {
|
||||
return h(this, null, function* () {
|
||||
if (t === 1 || t === 0) return o;
|
||||
if (t === 2) {
|
||||
if (typeof globalThis.DecompressionStream == "undefined") return decompressSync(new Uint8Array(o));
|
||||
let e = new Response(o).body;
|
||||
if (!e) throw new Error("Failed to read response stream");
|
||||
let r = e.pipeThrough(new globalThis.DecompressionStream("gzip"));
|
||||
return new Response(r).arrayBuffer();
|
||||
}
|
||||
throw new Error("Compression method not supported");
|
||||
});
|
||||
}
|
||||
d(P, "defaultDecompress");
|
||||
var O = ((a) => (a[a.Unknown = 0] = "Unknown", a[a.Mvt = 1] = "Mvt", a[a.Png = 2] = "Png", a[a.Jpeg = 3] = "Jpeg", a[a.Webp = 4] = "Webp", a[a.Avif = 5] = "Avif", a[a.Mlt = 6] = "Mlt", a))(O || {});
|
||||
function _(o) {
|
||||
return o === 1 ? ".mvt" : o === 2 ? ".png" : o === 3 ? ".jpg" : o === 4 ? ".webp" : o === 5 ? ".avif" : o === 6 ? ".mlt" : "";
|
||||
}
|
||||
d(_, "tileTypeExt");
|
||||
var Y = 127;
|
||||
function Q(o, t) {
|
||||
let e = 0, r = o.length - 1;
|
||||
for (; e <= r; ) {
|
||||
let n = r + e >> 1, s = t - o[n].tileId;
|
||||
if (s > 0) e = n + 1;
|
||||
else if (s < 0) r = n - 1;
|
||||
else return o[n];
|
||||
}
|
||||
return r >= 0 && (o[r].runLength === 0 || t - o[r].tileId < o[r].runLength) ? o[r] : null;
|
||||
}
|
||||
d(Q, "findTile");
|
||||
var T = class T2 {
|
||||
constructor(t) {
|
||||
this.file = t;
|
||||
}
|
||||
getKey() {
|
||||
return this.file.name;
|
||||
}
|
||||
getBytes(t, e) {
|
||||
return h(this, null, function* () {
|
||||
return { data: yield this.file.slice(t, t + e).arrayBuffer() };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(T, "FileSource");
|
||||
var D = class D2 {
|
||||
constructor(t, e = new Headers(), r = void 0) {
|
||||
var a, c;
|
||||
this.url = t, this.customHeaders = e, this.credentials = r, this.mustReload = false;
|
||||
let n = "";
|
||||
"navigator" in globalThis && (n = (c = (a = globalThis.navigator) == null ? void 0 : a.userAgent) != null ? c : "");
|
||||
let s = n.indexOf("Windows") > -1, i = /Chrome|Chromium|Edg|OPR|Brave/.test(n);
|
||||
this.chromeWindowsNoCache = false, s && i && (this.chromeWindowsNoCache = true);
|
||||
}
|
||||
getKey() {
|
||||
return this.url;
|
||||
}
|
||||
setHeaders(t) {
|
||||
this.customHeaders = t;
|
||||
}
|
||||
getBytes(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s, i;
|
||||
r ? i = r : (s = new AbortController(), i = s.signal);
|
||||
let a = new Headers(this.customHeaders);
|
||||
a.set("range", `bytes=${t}-${t + e - 1}`);
|
||||
let c;
|
||||
this.mustReload ? c = "reload" : this.chromeWindowsNoCache && (c = "no-store");
|
||||
let l = yield fetch(this.url, { signal: i, cache: c, headers: a, credentials: this.credentials });
|
||||
if (t === 0 && l.status === 416) {
|
||||
let p = l.headers.get("Content-Range");
|
||||
if (!p || !p.startsWith("bytes */")) throw new Error("Missing content-length on 416 response");
|
||||
let f = +p.substr(8);
|
||||
a.set("range", `bytes=0-${f - 1}`), l = yield fetch(this.url, { signal: i, cache: "reload", headers: a, credentials: this.credentials });
|
||||
}
|
||||
let u = l.headers.get("Etag");
|
||||
if (u != null && u.startsWith("W/") && (u = null), l.status === 416 || n && u && u !== n) throw this.mustReload = true, new v(`Server returned non-matching ETag ${n} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);
|
||||
if (l.status >= 300) throw new Error(`Bad response code: ${l.status}`);
|
||||
let m = l.headers.get("Content-Length");
|
||||
if (l.status === 200 && (!m || +m > e)) throw s && s.abort(), new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");
|
||||
return { data: yield l.arrayBuffer(), etag: u || void 0, cacheControl: l.headers.get("Cache-Control") || void 0, expires: l.headers.get("Expires") || void 0 };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(D, "FetchSource");
|
||||
var E = D;
|
||||
function y(o, t) {
|
||||
let e = o.getUint32(t + 4, true), r = o.getUint32(t + 0, true);
|
||||
return e * S(2, 32) + r;
|
||||
}
|
||||
d(y, "getUint64");
|
||||
function X(o, t) {
|
||||
let e = new DataView(o), r = e.getUint8(7);
|
||||
if (r > 3) throw new Error(`Archive is spec version ${r} but this library supports up to spec version 3`);
|
||||
return { specVersion: r, rootDirectoryOffset: y(e, 8), rootDirectoryLength: y(e, 16), jsonMetadataOffset: y(e, 24), jsonMetadataLength: y(e, 32), leafDirectoryOffset: y(e, 40), leafDirectoryLength: y(e, 48), tileDataOffset: y(e, 56), tileDataLength: y(e, 64), numAddressedTiles: y(e, 72), numTileEntries: y(e, 80), numTileContents: y(e, 88), clustered: e.getUint8(96) === 1, internalCompression: e.getUint8(97), tileCompression: e.getUint8(98), tileType: e.getUint8(99), minZoom: e.getUint8(100), maxZoom: e.getUint8(101), minLon: e.getInt32(102, true) / 1e7, minLat: e.getInt32(106, true) / 1e7, maxLon: e.getInt32(110, true) / 1e7, maxLat: e.getInt32(114, true) / 1e7, centerZoom: e.getUint8(118), centerLon: e.getInt32(119, true) / 1e7, centerLat: e.getInt32(123, true) / 1e7, etag: t };
|
||||
}
|
||||
d(X, "bytesToHeader");
|
||||
function Z(o) {
|
||||
let t = { buf: new Uint8Array(o), pos: 0 }, e = x(t), r = [], n = 0;
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
r.push({ tileId: n + i, offset: 0, length: 0, runLength: 1 }), n += i;
|
||||
}
|
||||
for (let s = 0; s < e; s++) r[s].runLength = x(t);
|
||||
for (let s = 0; s < e; s++) r[s].length = x(t);
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
i === 0 && s > 0 ? r[s].offset = r[s - 1].offset + r[s - 1].length : r[s].offset = i - 1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(Z, "deserializeIndex");
|
||||
var R = class R2 extends Error {
|
||||
};
|
||||
d(R, "EtagMismatch");
|
||||
var v = R;
|
||||
function V(o, t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield o.getBytes(0, 16384);
|
||||
if (new DataView(e.data).getUint16(0, true) !== 19792) throw new Error("Wrong magic number for PMTiles archive");
|
||||
let n = e.data.slice(0, Y), s = X(n, e.etag), i = e.data.slice(s.rootDirectoryOffset, s.rootDirectoryOffset + s.rootDirectoryLength), a = `${o.getKey()}|${s.etag || ""}|${s.rootDirectoryOffset}|${s.rootDirectoryLength}`, c = Z(yield t(i, s.internalCompression));
|
||||
return [s, [a, c.length, c]];
|
||||
});
|
||||
}
|
||||
d(V, "getHeaderAndRoot");
|
||||
function F(o, t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = yield o.getBytes(e, r, s, n.etag), a = yield t(i.data, n.internalCompression), c = Z(a);
|
||||
if (c.length === 0) throw new Error("Empty directory is invalid");
|
||||
return c;
|
||||
});
|
||||
}
|
||||
d(F, "getDirectory");
|
||||
var U = class U2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, r.data;
|
||||
let n = yield V(t, this.decompress);
|
||||
return n[1] && this.cache.set(n[1][0], { lastUsed: this.counter++, data: n[1][2] }), this.cache.set(e, { lastUsed: this.counter++, data: n[0] }), this.prune(), n[0];
|
||||
});
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) return a.lastUsed = this.counter++, a.data;
|
||||
let c = yield F(t, this.decompress, e, r, n, s);
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: c }), this.prune(), c;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size > this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
this.cache.delete(t.getKey());
|
||||
});
|
||||
}
|
||||
};
|
||||
d(U, "ResolvedValueCache");
|
||||
var M = class M2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.invalidations = /* @__PURE__ */ new Map(), this.pendingFetches = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, yield r.data;
|
||||
let n = new Promise((s, i) => {
|
||||
V(t, this.decompress).then((a) => {
|
||||
a[1] && this.cache.set(a[1][0], { lastUsed: this.counter++, data: Promise.resolve(a[1][2]) }), s(a[0]), this.prune();
|
||||
}).catch((a) => {
|
||||
i(a);
|
||||
});
|
||||
});
|
||||
return this.cache.set(e, { lastUsed: this.counter++, data: n }), n;
|
||||
});
|
||||
}
|
||||
trackSignal(t, e, r) {
|
||||
e.refs++, r.addEventListener("abort", () => {
|
||||
--e.refs <= 0 && this.pendingFetches.get(t) === e && (e.controller.abort(), this.cache.delete(t), this.pendingFetches.delete(t));
|
||||
}, { once: true });
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) {
|
||||
a.lastUsed = this.counter++;
|
||||
let m = this.pendingFetches.get(i);
|
||||
return m && this.trackSignal(i, m, s != null ? s : new AbortController().signal), yield a.data;
|
||||
}
|
||||
let c = new AbortController(), l = { controller: c, refs: 0 };
|
||||
this.trackSignal(i, l, s != null ? s : new AbortController().signal), this.pendingFetches.set(i, l);
|
||||
let u = new Promise((m, g) => {
|
||||
F(t, this.decompress, e, r, n, c.signal).then((p) => {
|
||||
this.pendingFetches.delete(i), m(p), this.prune();
|
||||
}).catch((p) => {
|
||||
g(p);
|
||||
});
|
||||
});
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: u }), u;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size >= this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey();
|
||||
if (this.invalidations.get(e)) return yield this.invalidations.get(e);
|
||||
this.cache.delete(t.getKey());
|
||||
let r = new Promise((n, s) => {
|
||||
this.getHeader(t).then((i) => {
|
||||
n(), this.invalidations.delete(e);
|
||||
}).catch((i) => {
|
||||
s(i);
|
||||
});
|
||||
});
|
||||
this.invalidations.set(e, r);
|
||||
});
|
||||
}
|
||||
};
|
||||
d(M, "SharedPromiseCache");
|
||||
var C = M;
|
||||
var H = class H2 {
|
||||
constructor(t, e, r) {
|
||||
typeof t == "string" ? this.source = new E(t) : this.source = t, r ? this.decompress = r : this.decompress = P, e ? this.cache = e : this.cache = new C();
|
||||
}
|
||||
getHeader() {
|
||||
return h(this, null, function* () {
|
||||
return yield this.cache.getHeader(this.source);
|
||||
});
|
||||
}
|
||||
getZxyAttempt(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s = q(t, e, r), i = yield this.cache.getHeader(this.source);
|
||||
if (n == null || n.throwIfAborted(), t < i.minZoom || t > i.maxZoom) return;
|
||||
let a = i.rootDirectoryOffset, c = i.rootDirectoryLength;
|
||||
for (let l = 0; l <= 3; l++) {
|
||||
let u = yield this.cache.getDirectory(this.source, a, c, i, n);
|
||||
n == null || n.throwIfAborted();
|
||||
let m = Q(u, s);
|
||||
if (m) {
|
||||
if (m.runLength > 0) {
|
||||
let g = yield this.source.getBytes(i.tileDataOffset + m.offset, m.length, n, i.etag);
|
||||
return { data: yield this.decompress(g.data, i.tileCompression), cacheControl: g.cacheControl, expires: g.expires };
|
||||
}
|
||||
a = i.leafDirectoryOffset + m.offset, c = m.length;
|
||||
} else return;
|
||||
}
|
||||
throw new Error("Maximum directory depth exceeded");
|
||||
});
|
||||
}
|
||||
getZxy(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getZxyAttempt(t, e, r, n);
|
||||
} catch (s) {
|
||||
if (s instanceof v) return this.cache.invalidate(this.source), yield this.getZxyAttempt(t, e, r, n);
|
||||
throw s;
|
||||
}
|
||||
});
|
||||
}
|
||||
getMetadataAttempt() {
|
||||
return h(this, null, function* () {
|
||||
let t = yield this.cache.getHeader(this.source), e = yield this.source.getBytes(t.jsonMetadataOffset, t.jsonMetadataLength, void 0, t.etag), r = yield this.decompress(e.data, t.internalCompression), n = new TextDecoder("utf-8");
|
||||
return JSON.parse(n.decode(r));
|
||||
});
|
||||
}
|
||||
getMetadata() {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getMetadataAttempt();
|
||||
} catch (t) {
|
||||
if (t instanceof v) return this.cache.invalidate(this.source), yield this.getMetadataAttempt();
|
||||
throw t;
|
||||
}
|
||||
});
|
||||
}
|
||||
getTileJson(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield this.getHeader(), r = yield this.getMetadata(), n = _(e.tileType);
|
||||
return { tilejson: "3.0.0", scheme: "xyz", tiles: [`${t}/{z}/{x}/{y}${n}`], vector_layers: r.vector_layers, attribution: r.attribution, description: r.description, name: r.name, version: r.version, bounds: [e.minLon, e.minLat, e.maxLon, e.maxLat], center: [e.centerLon, e.centerLat, e.centerZoom], minzoom: e.minZoom, maxzoom: e.maxZoom };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(H, "PMTiles");
|
||||
var w = H;
|
||||
|
||||
// src/map/pmtiles.worker.ts
|
||||
var workerScope = self;
|
||||
workerScope.addProtocol("pmtiles", new B().tile);
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 283 KiB |
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* بهروزرسانی یکبارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
|
||||
*
|
||||
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» میشود — هم روی دیسک
|
||||
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
|
||||
* موفقیت پشتیبان، پالت تازه نوشته و آپلود میشود.
|
||||
*
|
||||
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
|
||||
* میشوند، نه JSON خام؛ پس تنها راه درست بهروزرسانی، PUT روی همان API است.
|
||||
*
|
||||
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیشفرض هیچ تغییری نمیدهد:
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --palette
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --restore
|
||||
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
|
||||
*
|
||||
* در همهٔ حالتها (جز restore) ترتیب امن رعایت میشود: خواندن و اعتبارسنجی →
|
||||
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
|
||||
*
|
||||
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده میشوند (MINIO_ROOT_USER /
|
||||
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نامها.
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* پالت تازه */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** نگاشت دستهها به رنگهای تازهٔ برند — تنها منبع حقیقت این اسکریپت */
|
||||
const PALETTE = {
|
||||
background: '#181512',
|
||||
urban: '#27221c',
|
||||
highway: '#6a5544',
|
||||
street: '#4a3a2d',
|
||||
alley: '#352c25',
|
||||
water: '#172c32',
|
||||
parks: '#20312b',
|
||||
nature: '#314238',
|
||||
mainText: '#ead9c6',
|
||||
subText: '#a99584',
|
||||
borders: '#3e362e',
|
||||
} as const
|
||||
|
||||
type Category = keyof typeof PALETTE
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* قواعد دستهبندی لایهها */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Layer = {
|
||||
id: string
|
||||
type: string
|
||||
'source-layer'?: string
|
||||
paint?: Record<string, unknown>
|
||||
layout?: Record<string, unknown>
|
||||
}
|
||||
type StyleDocument = {
|
||||
version?: number
|
||||
layers?: Layer[]
|
||||
sources?: Record<string, unknown>
|
||||
/** الگوی نشانی گلیفها — باید به پوشهٔ v2.0 باکت اشاره کند */
|
||||
glyphs?: string
|
||||
}
|
||||
|
||||
/** پوشهٔ فونتهای آفلاین در باکت MinIO */
|
||||
const GLYPHS_FOLDER = 'v2.0'
|
||||
/** الگوی استاندارد گلیف — جانگهدارها دستنخورده میمانند */
|
||||
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
|
||||
|
||||
/**
|
||||
* اصلاح مسیر گلیفها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
|
||||
* «v2.0/{fontstack}/{range}.pbf» تبدیل میشود تا با ساختار پوشههای باکت
|
||||
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمیکند.
|
||||
*/
|
||||
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
|
||||
const current = (style.glyphs ?? '').trim()
|
||||
const marker = current.indexOf('{fontstack}')
|
||||
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
|
||||
if (next === current) return null
|
||||
style.glyphs = next
|
||||
return { from: current || '(خالی)', to: next }
|
||||
}
|
||||
|
||||
/** آیا مقدار text-field به یک فیلد «نام» اشاره میکند؟ (name، name_en، name:latin و…) */
|
||||
const NAME_REFERENCE = /name(:[a-z-]+)?/i
|
||||
|
||||
/**
|
||||
* فونتهای محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
|
||||
* تنها پوشههای Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
|
||||
* فقط لاتیناند.
|
||||
*/
|
||||
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
|
||||
|
||||
/**
|
||||
* بازگردانی متن برچسبها به قالب دوزبانهٔ اصلی، بهتفکیک لایه.
|
||||
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راهها)
|
||||
*/
|
||||
const TEXT_FIELD_RESTORE: Record<string, string> = {
|
||||
water_name: '{name:latin}\n{name:nonlatin}',
|
||||
place_other: '{name:latin}\n{name:nonlatin}',
|
||||
place_suburb: '{name:latin}\n{name:nonlatin}',
|
||||
place_village: '{name:latin}\n{name:nonlatin}',
|
||||
place_town: '{name:latin}\n{name:nonlatin}',
|
||||
place_city: '{name:latin}\n{name:nonlatin}',
|
||||
place_city_large: '{name:latin}\n{name:nonlatin}',
|
||||
place_state: '{name:latin}\n{name:nonlatin}',
|
||||
highway_name_other: '{name:latin} {name:nonlatin}',
|
||||
place_country_other: '{name:latin}',
|
||||
place_country_minor: '{name:latin}',
|
||||
place_country_major: '{name:latin}',
|
||||
}
|
||||
|
||||
/** قالب پیشفرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
|
||||
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
|
||||
|
||||
function referencesName(value: unknown): boolean {
|
||||
if (typeof value === 'string') return NAME_REFERENCE.test(value)
|
||||
if (Array.isArray(value)) return value.some(referencesName)
|
||||
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
|
||||
return false
|
||||
}
|
||||
|
||||
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
|
||||
function persianFontFor(stack: string[]): string {
|
||||
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
|
||||
if (local) return local
|
||||
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
|
||||
}
|
||||
|
||||
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
|
||||
|
||||
/**
|
||||
* آمادهسازی برچسبها برای فارسی:
|
||||
* ۱) متن به قالب دوزبانهٔ اصلی برمیگردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
|
||||
* ۲) پشتهٔ فونت بازچینی میشود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
|
||||
* چون مپلیبر برای هر پشته فقط یک فایل گلیف میگیرد، همان فونت اول باید هر دو خط
|
||||
* را پوشش دهد و Noto Sans لاتین را هم دارد.
|
||||
*/
|
||||
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
|
||||
const changed: LabelChange[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
for (const layer of style.layers ?? []) {
|
||||
const layout = layer.layout
|
||||
if (layer.type !== 'symbol' || !layout) continue
|
||||
|
||||
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
|
||||
if (!stack.length) continue
|
||||
|
||||
const persianFont = persianFontFor(stack)
|
||||
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
|
||||
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
|
||||
|
||||
if (!('text-field' in layout)) {
|
||||
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح میشود
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, font: fontChange })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!referencesName(layout['text-field'])) {
|
||||
layout['text-font'] = restacked
|
||||
skipped.push(layer.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const from = JSON.stringify(layout['text-field']).slice(0, 48)
|
||||
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
|
||||
layout['text-field'] = to
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
|
||||
}
|
||||
|
||||
return { changed, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* بررسی فونتها: هر فونتی که در text-font لایههای برچسب استفاده شده باید در
|
||||
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
|
||||
*/
|
||||
async function verifyFonts(style: StyleDocument): Promise<void> {
|
||||
const fonts = new Set<string>()
|
||||
for (const layer of style.layers ?? []) {
|
||||
if (layer.type !== 'symbol') continue
|
||||
const stack = layer.layout?.['text-font']
|
||||
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
|
||||
}
|
||||
|
||||
if (!fonts.size) {
|
||||
console.log('• فونتی در لایههای برچسب پیدا نشد')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
|
||||
const missing: string[] = []
|
||||
for (const font of [...fonts].sort()) {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
|
||||
const ok = (await fetch(url, { method: 'HEAD' })).ok
|
||||
if (!ok) missing.push(font)
|
||||
console.log(` ${ok ? '✓' : '✗'} ${font}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
missing.length
|
||||
? `✗ فونتهای ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
|
||||
: `✓ همهٔ فونتها در ${GLYPHS_FOLDER} باکت موجودند`,
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بررسی پوشش فونتهای محلی (کدام فونت فارسی را رندر میکند؟) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** بازههای کلیدی گلیف: لاتین پایه، عربی/فارسی، فرمهای نمایشی عربی */
|
||||
const COVERAGE_RANGES = [
|
||||
{ label: 'لاتین', range: '0-255' },
|
||||
{ label: 'فارسی', range: '1536-1791' },
|
||||
{ label: 'فرم فارسی', range: '65024-65279' },
|
||||
] as const
|
||||
|
||||
/** فهرست پوشههای فونت داخل v2.0 باکت */
|
||||
async function listFontFolders(): Promise<string[]> {
|
||||
const response = await fetch(
|
||||
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
|
||||
)
|
||||
const xml = await response.text()
|
||||
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
|
||||
return [...xml.matchAll(pattern)].map((match) => match[1])
|
||||
}
|
||||
|
||||
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
|
||||
async function rangeCoverage(font: string, range: string): Promise<string> {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return 'نیست'
|
||||
const bytes = Number(response.headers.get('content-length') ?? 0)
|
||||
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
|
||||
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
|
||||
}
|
||||
|
||||
/** گزارش پوشش خطها برای همهٔ فونتهای باکت — ورودی تصمیم انتخاب فونت برچسبها */
|
||||
async function auditFonts(): Promise<void> {
|
||||
const fonts = await listFontFolders()
|
||||
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
|
||||
for (const font of fonts) {
|
||||
const cells: string[] = []
|
||||
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
|
||||
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
|
||||
}
|
||||
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر میکند.')
|
||||
}
|
||||
|
||||
/**
|
||||
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا میشود تا مطمئن شویم سند
|
||||
* سالم است و اسکیمای سبک (نسخه، لایهها، منابع) نشکسته است.
|
||||
*/
|
||||
function validateStyle(style: StyleDocument, label: string): void {
|
||||
const problems: string[] = []
|
||||
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
|
||||
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
|
||||
else {
|
||||
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
|
||||
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
|
||||
}
|
||||
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
|
||||
problems.push('sources باید یک شیء باشد')
|
||||
}
|
||||
|
||||
console.log(
|
||||
problems.length
|
||||
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
|
||||
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
|
||||
)
|
||||
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
category: Category
|
||||
/** با این الگو روی شناسهٔ لایه تطبیق داده میشود */
|
||||
test: RegExp
|
||||
/** کدام ویژگیهای paint رنگآمیزی شوند */
|
||||
properties: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* جدول قواعد — به ترتیب بررسی میشود و نخستین تطبیق برنده است.
|
||||
* رنگهای داخل عبارتها (interpolate/match) هم بازنویسی میشوند تا پالت
|
||||
* یکدست بماند.
|
||||
*/
|
||||
const RULES: Rule[] = [
|
||||
{ category: 'background', test: /^background$/, properties: ['background-color'] },
|
||||
|
||||
// آب: خود لایههای آب و راههای آبی (برچسبها قاعدهٔ جدا دارند)
|
||||
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// پارکها پیش از طبیعت بررسی میشوند تا چمن/پارک زیر «طبیعت» نرود
|
||||
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// طبیعت: پوشش زمینِ جنگلی/علفزارِ بیرون از شهر
|
||||
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// کاربری شهری و ساختمانها
|
||||
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
|
||||
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
|
||||
|
||||
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
|
||||
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
// برچسبهای اصلی: شهر/شهرک و نام بزرگراهها
|
||||
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
|
||||
|
||||
// بقیهٔ برچسبها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
|
||||
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
|
||||
]
|
||||
|
||||
/** ویژگیهای فرعی که همیشه با رنگ پسزمینه هماهنگ میشوند تا خوانایی بماند */
|
||||
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
|
||||
/** ویژگیهای آیکون برچسبها که با متن همرنگ میشوند */
|
||||
const ICON_PROPERTIES = ['icon-color']
|
||||
|
||||
const COLOR_PATTERN = /^(#|rgb|hsl)/i
|
||||
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بازنویسی رنگها (توابع خالص) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارتها — را با رنگ تازه عوض میکند */
|
||||
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
|
||||
if (isColor(value)) {
|
||||
stats.seen.add(value.trim())
|
||||
stats.replaced += 1
|
||||
return color
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
|
||||
|
||||
/** یک لایه را بر پایهٔ قواعد رنگآمیزی میکند و گزارش تغییرها را برمیگرداند */
|
||||
function recolorLayer(layer: Layer): LayerReport {
|
||||
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
|
||||
const stats = { replaced: 0, seen: new Set<string>() }
|
||||
|
||||
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
|
||||
|
||||
if (rule) {
|
||||
for (const property of rule.properties) {
|
||||
if (!(property in layer.paint)) continue
|
||||
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لایههای برچسب: هاله و آیکونها با پالت هماهنگ میشوند
|
||||
if (layer.type === 'symbol') {
|
||||
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
|
||||
? PALETTE[rule.category]
|
||||
: PALETTE.subText
|
||||
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
|
||||
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
|
||||
}
|
||||
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
|
||||
if (!(property in layer.paint)) continue
|
||||
const target = property === 'icon-color' ? textColor : PALETTE.background
|
||||
layer.paint[property] = paintColor(layer.paint[property], target, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لبهٔ ساختمانها با رنگ مرزها همخانواده میشود
|
||||
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
|
||||
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
|
||||
}
|
||||
|
||||
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
|
||||
}
|
||||
|
||||
/** کل سند سبک را بازنویسی میکند (بدون دستزدن به ساختار و ویژگیهای غیررنگی) */
|
||||
function recolorStyle(style: StyleDocument): LayerReport[] {
|
||||
const layers = style.layers ?? []
|
||||
return layers.map(recolorLayer)
|
||||
}
|
||||
|
||||
/** گزارش فشردهٔ تغییرهای پالت — بهتفکیک دسته و لایههای دستنخورده */
|
||||
function reportPalette(reports: LayerReport[]): void {
|
||||
const byCategory = new Map<string, number>()
|
||||
for (const report of reports) {
|
||||
if (!report.replaced) continue
|
||||
const category = report.category ?? 'بدوندسته'
|
||||
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
|
||||
}
|
||||
|
||||
console.log('\nگزارش تغییرها:')
|
||||
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
|
||||
}
|
||||
|
||||
const untouched = reports.filter((report) => !report.replaced)
|
||||
if (untouched.length) {
|
||||
console.log(`\nلایههای بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Credentials = { user: string; secret: string }
|
||||
|
||||
async function readCredentials(): Promise<Credentials> {
|
||||
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
|
||||
const fromFile: Record<string, string> = {}
|
||||
try {
|
||||
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
|
||||
if (match) fromFile[match[1]] = match[2].trim()
|
||||
}
|
||||
} catch {
|
||||
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین میشوند */
|
||||
}
|
||||
|
||||
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
|
||||
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
|
||||
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
|
||||
return { user, secret }
|
||||
}
|
||||
|
||||
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
|
||||
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
|
||||
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
|
||||
|
||||
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام میدهند */
|
||||
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
|
||||
const url = new URL(objectPath, endpoint)
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
||||
const dateStamp = amzDate.slice(0, 8)
|
||||
const payloadHash = sha256(body)
|
||||
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
|
||||
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
||||
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
|
||||
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
|
||||
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
|
||||
const authorization =
|
||||
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'x-amz-content-sha256': payloadHash,
|
||||
'x-amz-date': amzDate,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* اجرا */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
|
||||
const STYLE_PATH = '/maps/style.json'
|
||||
const BACKUP_PATH = '/maps/style.backup.json'
|
||||
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
|
||||
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
|
||||
|
||||
/** بازگردانی: پشتیبان دقیقاً بهجای style.json نوشته میشود */
|
||||
async function restore(): Promise<void> {
|
||||
const backup = await readFile(LOCAL_BACKUP, 'utf8')
|
||||
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
|
||||
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
|
||||
|
||||
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
|
||||
console.log(
|
||||
restored === backup
|
||||
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
|
||||
: '✗ محتوای بازگرداندهشده با پشتیبان یکسان نیست — دستی بررسی کنید',
|
||||
)
|
||||
}
|
||||
|
||||
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
const action = ACTIONS.find((flag) => process.argv.includes(flag))
|
||||
|
||||
// پیشفرض امن: بدون پرچم هیچ نوشتنی انجام نمیشود
|
||||
if (!action) {
|
||||
console.log(
|
||||
'کاری انجام نشد — یکی از پرچمها را بدهید:\n' +
|
||||
' --audit-fonts گزارش پوشش خطهای هر فونت در v2.0 (فقط خواندن)\n' +
|
||||
' --fix-labels برچسبهای دوزبانه + پشتهٔ فونت محلی\n' +
|
||||
' --fix-glyphs اصلاح مسیر گلیفها به v2.0\n' +
|
||||
' --palette اعمال پالت تازه\n' +
|
||||
' --restore بازگردانی از پشتیبان\n' +
|
||||
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--audit-fonts') {
|
||||
await auditFonts()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--restore') {
|
||||
await restore()
|
||||
return
|
||||
}
|
||||
|
||||
// ۱) خواندن سبک فعلی
|
||||
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
|
||||
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
|
||||
const original = await response.text()
|
||||
const style = JSON.parse(original) as StyleDocument
|
||||
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
|
||||
validateStyle(style, 'نسخهٔ فعلی')
|
||||
|
||||
// ۲) پشتیبانگیری، پیش از هر تغییر
|
||||
await mkdir(OUTPUT_DIR, { recursive: true })
|
||||
await writeFile(LOCAL_BACKUP, original, 'utf8')
|
||||
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
|
||||
if (!dryRun) {
|
||||
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
|
||||
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
|
||||
}
|
||||
|
||||
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
|
||||
if (action === '--fix-labels') {
|
||||
const { changed, skipped } = fixLabels(style)
|
||||
console.log(`\nلایههای برچسب اصلاحشده (${changed.length}):`)
|
||||
for (const layer of changed) {
|
||||
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
|
||||
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(`\nلایههای بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
|
||||
}
|
||||
await verifyFonts(style)
|
||||
} else if (action === '--fix-glyphs') {
|
||||
const change = fixGlyphsPath(style)
|
||||
console.log(
|
||||
change ? `\nمسیر گلیفها: ${change.from} → ${change.to}` : '\nمسیر گلیفها از قبل به v2.0 اشاره میکند',
|
||||
)
|
||||
await verifyFonts(style)
|
||||
} else {
|
||||
reportPalette(recolorStyle(style))
|
||||
}
|
||||
validateStyle(style, 'نسخهٔ تازه')
|
||||
|
||||
// ۴) نوشتن و آپلود نسخهٔ تازه
|
||||
const updated = JSON.stringify(style, null, 2)
|
||||
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
|
||||
if (dryRun) {
|
||||
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
|
||||
return
|
||||
}
|
||||
|
||||
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
|
||||
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('\nخطا:', error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
@@ -129,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}, [isEditMode])
|
||||
|
||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||
useEffect(() => {
|
||||
const syncAuth = () => setIsAuthenticated(!!getToken())
|
||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.addEventListener('storage', syncAuth)
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.removeEventListener('storage', syncAuth)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saving') return
|
||||
|
||||
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
|
||||
|
||||
const TOKEN_KEY = 'aramesh_admin_token'
|
||||
|
||||
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
||||
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string | null): void => {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
import { getToken, setToken } from './admin/adminClient'
|
||||
import { localizeSite } from './i18n/localize'
|
||||
import { useLang } from './i18n/lang'
|
||||
|
||||
@@ -230,10 +230,16 @@ interface SiteContextValue {
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
// پیشفرضهای بیاثر — مصرفکننده بیرون از Provider هم بدون خطا رندر میشود
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
reload: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
updateSite: () => {},
|
||||
})
|
||||
|
||||
/** سقف انتظار داده سایت — سرور بیپاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بیپایان */
|
||||
const SITE_TIMEOUT_MS = 15_000
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
@@ -241,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const load = useCallback(() => {
|
||||
let cancelled = false
|
||||
setError(false)
|
||||
fetch(`${API_URL}/api/site`)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
||||
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
@@ -255,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
if (res.status === 401) {
|
||||
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
||||
setToken(null)
|
||||
} else if (res.ok) {
|
||||
const all = (await res.json()) as Article[]
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||
json.articles = [...json.articles, ...drafts]
|
||||
}
|
||||
} catch {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
@@ -275,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -26,28 +26,28 @@ export function ArticleCard({ article }: { article: Article }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<h3 className="mb-4 md:mb-6 text-xl font-bold leading-relaxed text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
<p className="mb-6 md:mb-10 text-[15px] font-light leading-loose text-ivory/60 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-6 md:pt-8 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-4">
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:hover:[&>svg]:-translate-x-1 ltr:hover:[&>svg]:translate-x-1">
|
||||
{t('readMore')}
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
|
||||
|
||||
type BaseProps = {
|
||||
children: React.ReactNode
|
||||
variant?: Variant
|
||||
className?: string
|
||||
}
|
||||
|
||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||
copper: {
|
||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
||||
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
||||
text: 'group-hover:text-char font-semibold',
|
||||
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
outline: {
|
||||
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
||||
fill: 'bg-white/10',
|
||||
text: 'group-hover:text-copper',
|
||||
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
||||
},
|
||||
dark: {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||
},
|
||||
'dark-ivory': {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-ivory',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
||||
},
|
||||
}
|
||||
|
||||
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<>
|
||||
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||
|
||||
{/* انیمیشن پر شدن موجی و نرم */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
||||
/>
|
||||
|
||||
{/* متن و آیکون دکمه */}
|
||||
<span
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||
const SHELL =
|
||||
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
|
||||
|
||||
export function ButtonLink({
|
||||
to,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
}: BaseProps & { to: string }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function ButtonAnchor({
|
||||
href,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { PremiumButton } from './PremiumButton'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
@@ -194,17 +194,15 @@ export function ContactSection() {
|
||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-8 font-light"
|
||||
<PremiumButton
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
}}
|
||||
className="mt-8 font-light"
|
||||
>
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
</Button>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||
@@ -253,21 +251,17 @@ export function ContactSection() {
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<Button
|
||||
<PremiumButton
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
content.sending_label
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
</>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
)}
|
||||
</Button>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { LanguagesIcon } from 'lucide-react'
|
||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||
|
||||
/**
|
||||
* تنظیمات ثابت هر زبان.
|
||||
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
|
||||
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
|
||||
*/
|
||||
const LANGS = {
|
||||
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
|
||||
en: { label: 'English', sliderTranslate: 'translate-x-full' },
|
||||
} as const
|
||||
|
||||
type LangKey = keyof typeof LANGS
|
||||
|
||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||
export function LangToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, t } = useLang()
|
||||
@@ -22,10 +33,61 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
||||
onClick={switchLang}
|
||||
aria-label={t('switchLangAria')}
|
||||
title={t('switchLangAria')}
|
||||
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
|
||||
dir="ltr"
|
||||
className={[
|
||||
// ── Capsule shell ──────────────────────────────────────────────
|
||||
'relative flex items-center',
|
||||
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
|
||||
'bg-[#1a1715]',
|
||||
// deep inset shadow → recessed track feel
|
||||
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
|
||||
// interaction
|
||||
'cursor-pointer select-none',
|
||||
'active:scale-[0.975] transition-transform duration-150 ease-lux',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
||||
<span>{t('switchLang')}</span>
|
||||
{/* ── Inner Track ───────────────────────────────────────────────
|
||||
این دیو داخلی باعث میشود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
|
||||
*/}
|
||||
<div className="relative w-full h-full flex items-center">
|
||||
|
||||
{/* ── Slider pill ─────────────────────────────────────────────── */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'absolute top-0 left-0', // چسبیده به لبههای Track داخلی
|
||||
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
|
||||
'bg-[#27221c] border border-[#d5874c]',
|
||||
// subtle raised shadow — tactile without being flashy
|
||||
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
|
||||
'transition-transform duration-[380ms] ease-lux',
|
||||
LANGS[lang as LangKey].sliderTranslate,
|
||||
].join(' ')}
|
||||
/>
|
||||
|
||||
{/* ── Labels ──────────────────────────────────────────────────── */}
|
||||
<span className="relative z-10 flex w-full h-full">
|
||||
{(Object.keys(LANGS) as LangKey[]).map((key) => {
|
||||
const isActive = lang === key
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={[
|
||||
// each label occupies exactly half the capsule
|
||||
'w-1/2 h-full flex justify-center items-center',
|
||||
'text-[11.5px] font-semibold tracking-wide leading-none',
|
||||
'transition-colors duration-[380ms] ease-lux',
|
||||
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
|
||||
].join(' ')}
|
||||
>
|
||||
{LANGS[key].label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||
|
||||
type Props = {
|
||||
images: string[]
|
||||
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowRight') prev()
|
||||
if (e.key === 'ArrowLeft') next()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
{/* Solid Brand Background */}
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
|
||||
className="absolute inset-0 bg-[#27221c]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Close Button at Top Right */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
|
||||
className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
<MdClose size={24} />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index as number]}
|
||||
alt=""
|
||||
className="relative max-h-[80vh] w-auto max-w-full object-contain"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
|
||||
{/* Main Content Area */}
|
||||
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
|
||||
|
||||
{/* Prev Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
<MdChevronRight size={28} />
|
||||
</button>
|
||||
<span className="text-sm font-light text-ivory/70" dir="ltr">
|
||||
{(index as number) + 1} / {images.length}
|
||||
</span>
|
||||
|
||||
{/* Image */}
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index as number]}
|
||||
alt=""
|
||||
className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
/>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
<MdChevronLeft size={28} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 z-10 flex items-center justify-center">
|
||||
<span className="text-sm font-light text-copper" dir="ltr">
|
||||
{(index as number) + 1} / {images.length}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
type Props = {
|
||||
to?: string
|
||||
href?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium Button replicating the cinematic, high-end documentary aesthetic.
|
||||
* Features an exact cubic-bezier background fill from the bottom, transforming
|
||||
* text color smoothly to maintain contrast.
|
||||
*/
|
||||
export function PremiumButton({ to, href, children, className = '', onClick, disabled, type = 'button' }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Primary: ink, Secondary: copper
|
||||
const baseClasses = `
|
||||
group/btn relative inline-flex items-center justify-center overflow-hidden
|
||||
rounded-full px-10 py-4 text-sm font-bold tracking-widest text-copper
|
||||
border border-copper bg-ink
|
||||
transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg
|
||||
cursor-pointer select-none
|
||||
${className}
|
||||
`.trim()
|
||||
|
||||
const innerEffect = (
|
||||
<>
|
||||
<span className="absolute inset-0 z-0 bg-copper scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
||||
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) onClick();
|
||||
if (to) {
|
||||
e.preventDefault();
|
||||
navigate(to);
|
||||
}
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={baseClasses} onClick={handleClick}>
|
||||
{innerEffect}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button type={type} disabled={disabled} className={baseClasses} onClick={handleClick}>
|
||||
{innerEffect}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +1,89 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { PremiumButton } from './PremiumButton'
|
||||
import type { StaticProject } from '../data/projectsData'
|
||||
import type { ProjectCardItem } from '../data/site'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
type ProjectCardProps = {
|
||||
project: StaticProject | ProjectCardItem;
|
||||
imageUrl?: string;
|
||||
detailHref?: string;
|
||||
}
|
||||
|
||||
export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
const statusText = useStatusLabel(project.status)
|
||||
const { t, href, lang } = useLang()
|
||||
const { t, lang } = useLang()
|
||||
|
||||
// Type guards for backward compatibility
|
||||
const isStatic = 'name' in project;
|
||||
|
||||
// Resolve localized texts
|
||||
const title = isStatic
|
||||
? (project.name[lang as 'en' | 'fa'] || project.name.en)
|
||||
: (project as ProjectCardItem).title;
|
||||
|
||||
const locRaw = isStatic ? project.location : (project as ProjectCardItem).location;
|
||||
const location = typeof locRaw === 'string' ? locRaw : (locRaw?.[lang as 'en' | 'fa'] || locRaw?.en);
|
||||
|
||||
const areaRaw = isStatic ? project.area : (project as ProjectCardItem).area;
|
||||
const area = typeof areaRaw === 'string' ? areaRaw : (areaRaw?.[lang as 'en' | 'fa'] || areaRaw?.en);
|
||||
|
||||
const finalImageUrl = imageUrl || (project as ProjectCardItem).image;
|
||||
const finalDetailHref = detailHref || (project as ProjectCardItem).detailHref || '#';
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
|
||||
|
||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
<Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
|
||||
{/* Fixed height image wrapper */}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
{finalImageUrl && (
|
||||
<img
|
||||
src={finalImageUrl}
|
||||
alt={title}
|
||||
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
|
||||
<div
|
||||
className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
||||
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<div className="flex flex-1 flex-col p-7 pb-4">
|
||||
<div className="text-center">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{project.title}
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
{project.location}
|
||||
</p>
|
||||
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
|
||||
</div>
|
||||
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
||||
<span>{t('yearLabel')} {project.year}</span>
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
||||
</div>
|
||||
{area && (
|
||||
<>
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
<div className="mt-auto flex items-center justify-end text-sm font-light text-ivory/60">
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* پایهٔ کارت: یک دکمه واحد */}
|
||||
<div className="flex flex-col gap-2 px-7 pb-7">
|
||||
<PremiumButton to={finalDetailHref} className="w-full">
|
||||
{t('viewProject')}
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MdApartment } from 'react-icons/md'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
/**
|
||||
* جاینگهدار تصویر پروژه — همان زبان بصری کارت پاپآپ، برای هر پروژهای که
|
||||
* تصویری در داده ندارد (مثل ردیفهای شیت). تکمنبع این ظاهر همینجاست.
|
||||
*/
|
||||
export function ProjectImagePlaceholder({ className = '' }: { className?: string }) {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-char to-ink text-ivory/25 ${className}`}
|
||||
>
|
||||
<MdApartment className="h-8 w-8" aria-hidden="true" />
|
||||
<span className="text-[10px] font-light">{t('mapImagePlaceholder')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { PremiumButton } from '../PremiumButton';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
@@ -53,10 +53,9 @@ export function Articles() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
||||
<PremiumButton to={href(heading.cta_href)} className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -18,20 +18,22 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
|
||||
|
||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
||||
|
||||
{/* Header — centered, full width */}
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
|
||||
<SectionHeading
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
||||
{/* Accordion list — constrained width, direction-aware */}
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<ul>
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
@@ -43,9 +45,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
@@ -63,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
import { projectsData } from '../../data/projectsData'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects, status_labels } = useSite()
|
||||
const { status_labels } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
const label = status_labels.find((s) => s.key === key)
|
||||
if (!label) return
|
||||
@@ -30,8 +31,12 @@ export function FeaturedProjects() {
|
||||
{ key: 'done', label: useStatusLabel('done') },
|
||||
{ key: 'progress', label: useStatusLabel('progress') },
|
||||
]
|
||||
|
||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||
|
||||
// Filter and slice static project data
|
||||
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
|
||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
||||
@@ -44,7 +49,6 @@ export function FeaturedProjects() {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
@@ -82,19 +86,26 @@ export function FeaturedProjects() {
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="grid gap-6 lg:grid-cols-3"
|
||||
>
|
||||
{list.map((project, i) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</EditableItem>
|
||||
</motion.div>
|
||||
))}
|
||||
{list.map((project, i) => {
|
||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
imageUrl={imageUrl}
|
||||
detailHref={`/projects/${project.slug}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -103,11 +114,7 @@ export function FeaturedProjects() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={href(heading.cta_href)}
|
||||
variant="outline"
|
||||
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
|
||||
>
|
||||
<PremiumButton to={href(heading.cta_href)}>
|
||||
<InlineEdit
|
||||
value={heading.cta_label}
|
||||
onSave={(v) =>
|
||||
@@ -117,10 +124,7 @@ export function FeaturedProjects() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function Gallery() {
|
||||
<AnimatePresence>
|
||||
{active !== null && gallery[active] && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -74,49 +74,71 @@ export function Gallery() {
|
||||
aria-label={t('viewImage')}
|
||||
onClick={close}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
|
||||
aria-label={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
|
||||
aria-label={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
|
||||
{/* Flex Container for Prev, Image, Next */}
|
||||
<div
|
||||
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
alt={gallery[active].alt}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modern Dot Pagination */}
|
||||
<div
|
||||
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{gallery.map((_, idx) => {
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActive(idx)}
|
||||
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
|
||||
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
|
||||
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -84,45 +84,20 @@ export function Hero() {
|
||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-10 flex flex-wrap items-center gap-4"
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={href(hero.cta_primary_href)}
|
||||
variant="copper"
|
||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
||||
>
|
||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
{/* دکمه اصلی */}
|
||||
<PremiumButton to={href(hero.cta_primary_href)}>
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</PremiumButton>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<ButtonLink
|
||||
to={href(hero.cta_secondary_href)}
|
||||
variant="outline"
|
||||
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
||||
>
|
||||
{/* دکمه ثانویه */}
|
||||
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
|
||||
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
|
||||
>
|
||||
<p className="text-3xl font-bold text-copper font-mono">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckIcon, SendIcon } from 'lucide-react'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -98,7 +98,7 @@ export function Newsletter() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||
|
||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||
@@ -142,19 +142,18 @@ export function Newsletter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
disabled={sending}
|
||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
||||
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
||||
<span>
|
||||
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
||||
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
</form>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,68 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
|
||||
function Counter({ value }: {value: number;}) {
|
||||
const { lang } = useLang();
|
||||
function useCounter(targetNumber: number, duration: number = 2000) {
|
||||
const [count, setCount] = useState(0);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
let frame = 0;
|
||||
const total = 60;
|
||||
const id = window.setInterval(() => {
|
||||
frame += 1;
|
||||
const progress = 1 - Math.pow(1 - frame / total, 3);
|
||||
setDisplay(Math.round(value * progress));
|
||||
if (frame >= total) window.clearInterval(id);
|
||||
}, 20);
|
||||
return () => window.clearInterval(id);
|
||||
}, [inView, value]);
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
return <span ref={ref}>{formatNumber(display, lang)}</span>;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
observer.disconnect();
|
||||
|
||||
let startTime: number | null = null;
|
||||
const step = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = timestamp - startTime;
|
||||
|
||||
// ease-out cubic
|
||||
const easeOut = progress >= duration ? 1 : 1 - Math.pow(1 - progress / duration, 3);
|
||||
|
||||
setCount(Math.floor(easeOut * targetNumber));
|
||||
|
||||
if (progress < duration) {
|
||||
window.requestAnimationFrame(step);
|
||||
} else {
|
||||
setCount(targetNumber);
|
||||
}
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [targetNumber, duration]);
|
||||
|
||||
return { count, ref };
|
||||
}
|
||||
|
||||
function Counter({ value }: { value: string | number }) {
|
||||
const { lang } = useLang();
|
||||
|
||||
const strValue = String(value);
|
||||
// Match the first sequence of digits
|
||||
const numMatch = strValue.match(/(\d+)/);
|
||||
const targetNumber = numMatch ? parseInt(numMatch[0], 10) : 0;
|
||||
|
||||
const { count, ref } = useCounter(targetNumber, 1500);
|
||||
|
||||
// If there's a number, replace it with the animated (and localized) count, otherwise render string as is.
|
||||
const formattedDisplay = formatNumber(count, lang);
|
||||
const renderValue = numMatch ? strValue.replace(numMatch[0], formattedDisplay) : strValue;
|
||||
|
||||
return <span ref={ref}>{renderValue}</span>;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
@@ -89,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
|
||||
{showLink && (
|
||||
<Reveal delay={0.22}>
|
||||
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
|
||||
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
|
||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ export function Team() {
|
||||
const { team } = useSite()
|
||||
const { t } = useLang()
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title" >
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10 ">
|
||||
<SectionHeading
|
||||
title={t('fallbackTeamTitle')}
|
||||
lead={t('fallbackTeamLead')}
|
||||
@@ -22,7 +22,8 @@ export function Team() {
|
||||
{team.map((member, i) => (
|
||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="team" item={member} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
{/* اضافه شدن rounded-2xl برای گرد شدن گوشهها */}
|
||||
<article className="group flex h-full flex-col items-center rounded-2xl border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
@@ -40,4 +41,4 @@ export function Team() {
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
|
||||
return (
|
||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
@@ -35,7 +36,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
>
|
||||
<EditableItem resource="timeline" item={item}>
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
|
||||
{/* لایه نوری ظریف که در هاور روشن میشود */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||
|
||||
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
||||
@@ -72,13 +72,15 @@ export function Timeline() {
|
||||
</div>
|
||||
|
||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
||||
{/* خط پسزمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
|
||||
/>
|
||||
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
|
||||
style={{ scaleY }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
||||
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
||||
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
||||
|
||||
// آدرس پایه کاشیهای نقشه (MinIO: style.json، iran.pmtiles، فونتها و اسپرایت).
|
||||
// در صورت تغییر مبدا سرو کاشیها VITE_MAP_URL را تنظیم کنید.
|
||||
export const MAP_BASE_URL: string =
|
||||
(import.meta.env.VITE_MAP_URL as string | undefined) ?? 'http://localhost:19000/maps'
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* مختصات پروژههای گروه ساختمانی آرامش — برگرفته از شیت «Sheet1.csv».
|
||||
*
|
||||
* دو نکتهی دادهای که در همانجا حل میشوند:
|
||||
* ۱) ستونهای neshan_url و google_maps_url در شیت فقط برچسب بودند
|
||||
* («Open in Neshan») و نشانی واقعی نداشتند؛ پس پیوندها از روی مختصات
|
||||
* ساخته میشوند که الگوی تثبیتشدهی هر دو سرویس است.
|
||||
* ۲) ستون توضیحات خالی است؛ متن پیشفرض در لایهی رابط کاربری جایگزین میشود.
|
||||
*/
|
||||
|
||||
import type { Project, ProjectCardItem } from './site'
|
||||
|
||||
export type MapProject = {
|
||||
id: number
|
||||
name: string
|
||||
/** سال شمسی — ممکن است خالی باشد */
|
||||
year: string
|
||||
/** پروژهی در جریان (ستون new شیت) */
|
||||
isNew: boolean
|
||||
lat: number
|
||||
lng: number
|
||||
description: string
|
||||
}
|
||||
|
||||
/** مرکز پیشفرض نقشه — اصفهان */
|
||||
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
|
||||
|
||||
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
|
||||
|
||||
/**
|
||||
* ستونهای برچسبی شیت حذف شدهاند (نشانی واقعی نداشتند) و ستون توضیحات به
|
||||
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
|
||||
*/
|
||||
const RAW_CSV = `name,year,new,lat,lng,description
|
||||
پروژه سروش,1381,no,32.6662842,51.701735,
|
||||
چهرازی-زیتون,1383,no,32.6700428,51.69985,
|
||||
جعفریان,1384,no,32.6669259,51.701085,
|
||||
بهار,1385,yes,32.6468963,51.6899119,
|
||||
حسن مرادی,1385,no,32.6704278,51.6985567,
|
||||
کوثر,1386,no,32.6522044,51.6856474,
|
||||
صبا,1387,no,32.6541363,51.6847828,
|
||||
پورمعالی - بهشت,1387,no,32.6500402,51.6919823,
|
||||
2 پروژه- فردوس,1388,no,32.6614576,51.706429,
|
||||
ارم,1388,no,32.6563712,51.710694,
|
||||
سینا,1388,no,32.6469299,51.6855403,
|
||||
مسجد سید,1389,yes,32.6669953,51.6557085,
|
||||
هفده شهریور,1389,yes,32.7249998,51.6543819,
|
||||
آرامش رفیع زاده,1390,no,32.6467107,51.6873398,
|
||||
بن بست شریعت,1390,yes,32.6459939,51.6897556,
|
||||
قصر - یاسین,1390,no,32.6663549,51.7065512,
|
||||
پردیس,1390,no,32.6518961,51.6973621,
|
||||
آرامش حکیم,1392,no,32.6426783,51.6813477,
|
||||
آرامش دخانیات,1393,no,32.6411924,51.6797555,
|
||||
بهشتی فرشادی,1393,no,32.6531989,51.6790984,
|
||||
کاکتوس,1394,no,32.6486747,51.6859003,
|
||||
آرامش - صیاد,1395,no,32.6486798,51.712659,
|
||||
کوثر,1395,yes,32.6506204,51.6344053,
|
||||
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424,
|
||||
میرزا نصیر,1396,no,32.668195,51.7173079,
|
||||
نیکان,1396,yes,32.6504663,51.6355982,
|
||||
آرامش-رسا,1397,no,32.6522126,51.6857297,
|
||||
نگین,1397,yes,32.6507084,51.6313395,
|
||||
آرامش - بوعلی 2,1398,no,32.645391,51.6917828,
|
||||
آرامش استاد همایی,1398,no,32.6563505,51.691988,
|
||||
آرامش صدر,1398,no,32.6469234,51.6826176,
|
||||
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268,
|
||||
آرامش- صدر,1399,no,32.6469205,51.6829887,
|
||||
آرامش - سنایی,1400,yes,32.6973339,51.6194128,
|
||||
مغازه لباس فروشی,1400,no,32.6516965,51.7020497,
|
||||
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244,
|
||||
آرامش - بزرگمهر,1401,no,32.651381,51.6993575,
|
||||
آرامش - سعدی,1401,yes,32.6970115,51.6196909,
|
||||
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248,
|
||||
آرامش 2,1401,yes,32.6505704,51.6337863,
|
||||
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827,
|
||||
آرامش احمدآباد,1401,no,32.6626101,51.6969828,
|
||||
تجاری آتشگاه,1401,yes,32.6515087,51.6033278,
|
||||
آرامش - شهیدان,1402,yes,32.6854968,51.6171964,
|
||||
آرامش خواجو,1402,no,32.6419187,51.6831873,
|
||||
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519,
|
||||
آرامش - انقلاب,1403,yes,32.719769,51.645921,
|
||||
آرامش - شریف,1404,no,32.6473319,51.6955996,
|
||||
آرامش - کهندژ,1404,yes,32.6649497,51.630077,
|
||||
آرامش جی,1404,no,32.6610717,51.7110184,
|
||||
آرامش - بابک,1405,yes,32.674076,51.6368204,
|
||||
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771,
|
||||
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643,
|
||||
آرامش - وحدت,1405,yes,32.6537564,51.6197007,
|
||||
آرامش پروین,1405,no,32.6618223,51.7098152,
|
||||
خلیفه سلطانی,1405,yes,32.7035398,51.6223296,
|
||||
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975,
|
||||
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342,
|
||||
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436,
|
||||
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,`
|
||||
|
||||
/**
|
||||
* تجزیهی یک خط CSV — پشتیبانی از فیلدهای گیومهدار و گیومهی دوگانهی درونی.
|
||||
* جداکننده کاما و نویسههای مخفی ابتدای فایل (BOM) هم حذف میشوند.
|
||||
*/
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const char = line[i]
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"'
|
||||
i += 1
|
||||
} else inQuotes = false
|
||||
} else current += char
|
||||
} else if (char === '"') inQuotes = true
|
||||
else if (char === ',') {
|
||||
fields.push(current.trim())
|
||||
current = ''
|
||||
} else current += char
|
||||
}
|
||||
fields.push(current.trim())
|
||||
return fields
|
||||
}
|
||||
|
||||
/** CSV خام → آرایهی تایپشدهی JSON */
|
||||
export function parseProjectsCsv(raw: string): MapProject[] {
|
||||
const rows: CsvRow[] = []
|
||||
const lines = raw.replace(/^\ufeff/, '').split(/\r?\n/)
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
if (!line.trim()) continue
|
||||
const [name, year = '', isNew = '', lat = '', lng = '', description = ''] = parseCsvLine(line)
|
||||
const parsedLat = Number(lat)
|
||||
const parsedLng = Number(lng)
|
||||
if (!name || !Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) continue
|
||||
rows.push({ name, year, isNew: isNew.toLowerCase() === 'yes', lat: parsedLat, lng: parsedLng, description })
|
||||
}
|
||||
|
||||
return rows.map((row, index) => ({ id: index + 1, ...row }))
|
||||
}
|
||||
|
||||
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
|
||||
|
||||
/** پیوند نشان روی نقشه برای سرویس نشان */
|
||||
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string {
|
||||
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z`
|
||||
}
|
||||
|
||||
/** پیوند نقشه گوگل */
|
||||
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
|
||||
return `https://www.google.com/maps?q=${project.lat},${project.lng}`
|
||||
}
|
||||
|
||||
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
|
||||
|
||||
/**
|
||||
* نرمالسازی نام برای تطبیق شیت با دادهی بکاند: نویسههای نامرئی،
|
||||
* ی/ک عربی، نشانهها و واژههای عمومی حذف میشوند تا «آرامش - سنایی» و
|
||||
* «پروژه سنایی» یک کلید بگیرند.
|
||||
*/
|
||||
export function normalizeProjectName(name: string): string {
|
||||
let result = (name ?? '')
|
||||
.normalize('NFC')
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||
.replace(/[يى]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/[ۀة]/g, 'ه')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
for (const noise of NAME_NOISE) result = result.replace(new RegExp(`(^|\\s)${noise}(?=\\s|$)`, 'g'), ' ')
|
||||
return result.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** پروژهی شیت متناظر با یک نام (تطبیق دقیق نرمالشده، سپس دربرگیری) */
|
||||
export function findMapProjectByName(name: string): MapProject | undefined {
|
||||
const key = normalizeProjectName(name)
|
||||
if (!key) return undefined
|
||||
|
||||
const exact = MAP_PROJECTS.find((project) => normalizeProjectName(project.name) === key)
|
||||
if (exact) return exact
|
||||
|
||||
return MAP_PROJECTS.find((project) => {
|
||||
const candidate = normalizeProjectName(project.name)
|
||||
return candidate.length >= 3 && key.length >= 3 && (candidate.includes(key) || key.includes(candidate))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* محل جایگزین وقتی پروژهای در شیت پیدا نشد — مرکز اصفهان با نام پروژهی
|
||||
* بکاند، تا نمای تکپروژه هرگز بدون نقشه نماند.
|
||||
*/
|
||||
export function createPlaceholderProject(name: string, description = ''): MapProject {
|
||||
return {
|
||||
id: 0,
|
||||
name,
|
||||
year: '',
|
||||
isNew: false,
|
||||
lat: ISFAHAN_CENTER[1],
|
||||
lng: ISFAHAN_CENTER[0],
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* کادر محدودهٔ نقشه از مختصات پروژهها — با حاشیه تا لبهٔ نقشه خالی نماند و
|
||||
* کاربر به بیرون از محدودهٔ پروژهها جابهجا نشود.
|
||||
*/
|
||||
export function boundsForProjects(
|
||||
projects: Pick<MapProject, 'lat' | 'lng'>[],
|
||||
padding = 0.02,
|
||||
): [[number, number], [number, number]] | undefined {
|
||||
if (!projects.length) return undefined
|
||||
const lats = projects.map((project) => project.lat)
|
||||
const lngs = projects.map((project) => project.lng)
|
||||
return [
|
||||
[Math.min(...lngs) - padding, Math.min(...lats) - padding],
|
||||
[Math.max(...lngs) + padding, Math.max(...lats) + padding],
|
||||
]
|
||||
}
|
||||
|
||||
/** ردیف شیت → کارت فهرست: عنوان از شیت، وضعیت از ستون new، نشانی از مختصات */
|
||||
export function csvProjectToCard(project: MapProject, detailHref: string): ProjectCardItem {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.name,
|
||||
status: project.isNew ? 'progress' : 'done',
|
||||
detailHref,
|
||||
addressUrl: buildNeshanUrl(project),
|
||||
}
|
||||
}
|
||||
|
||||
/** پروژهٔ بکاند → کارت فهرست؛ نشانی نشان از تطبیق نامی با شیت میآید */
|
||||
export function apiProjectToCard(project: Project, detailHref: string): ProjectCardItem {
|
||||
const matched = findMapProjectByName(project.title)
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
status: project.status,
|
||||
detailHref,
|
||||
location: project.location || undefined,
|
||||
image: project.image || undefined,
|
||||
area: project.area || undefined,
|
||||
addressUrl: matched ? buildNeshanUrl(matched) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export type BilingualText = { en: string; fa: string };
|
||||
|
||||
export type StaticProject = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: BilingualText;
|
||||
location: BilingualText;
|
||||
area: BilingualText;
|
||||
status: 'done' | 'progress';
|
||||
};
|
||||
|
||||
|
||||
export const projectsData: StaticProject[] = [
|
||||
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
];
|
||||
@@ -32,6 +32,10 @@ export type Project = {
|
||||
units_en?: string;
|
||||
client: string;
|
||||
client_en?: string;
|
||||
/** مختصات جغرافیایی پروژه — عرض جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||
lat?: number;
|
||||
/** مختصات جغرافیایی پروژه — طول جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||
lng?: number;
|
||||
summary: string;
|
||||
summary_en?: string;
|
||||
body: string[];
|
||||
@@ -66,3 +70,20 @@ export type Article = {
|
||||
/** هشتگها/برچسبهای مقاله */
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* شکل پروژه در کارتهای فهرست — از پروژهٔ بکاند یا ردیف شیت پر میشود؛
|
||||
* فیلدهای اختیاری فقط وقتی رندر میشوند که دادهشان موجود باشد.
|
||||
*/
|
||||
export type ProjectCardItem = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
status: ProjectStatus;
|
||||
/** مسیر صفحهٔ جزئیات پروژه */
|
||||
detailHref: string;
|
||||
/** نشانی بیرونی پروژه (نشان) — در نبودش دکمهٔ آن رندر نمیشود */
|
||||
addressUrl?: string;
|
||||
location?: string;
|
||||
image?: string;
|
||||
area?: string;
|
||||
};
|
||||
|
||||
@@ -48,9 +48,7 @@ export const fa = {
|
||||
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران.',
|
||||
|
||||
// کارت پروژه
|
||||
yearLabel: 'سال:',
|
||||
sqm: 'متر مربع',
|
||||
|
||||
// جزئیات پروژه
|
||||
projectNotFound: 'این پروژه پیدا نشد',
|
||||
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
||||
@@ -106,6 +104,18 @@ export const fa = {
|
||||
nextTestimonial: 'نظر بعدی',
|
||||
selectTestimonialAria: 'انتخاب نظر',
|
||||
testimonialByPrefix: 'نظر',
|
||||
|
||||
// نقشه پروژهها (مپلیبر)
|
||||
mapAria: 'نقشه موقعیت پروژهها',
|
||||
mapLoading: 'در حال بارگذاری نقشه…',
|
||||
mapErrorText: 'بارگذاری نقشه ناموفق بود — اتصال به سرور کاشیها را بررسی کنید.',
|
||||
viewProject: 'مشاهده پروژه',
|
||||
mapFullscreen: 'نمایش تمامصفحه',
|
||||
mapExitFullscreen: 'خروج از تمامصفحه',
|
||||
viewAddress: 'مشاهده آدرس پروژه',
|
||||
mapNewBadge: 'جدید',
|
||||
mapProjectDescription: 'پروژه اجراشده در اصفهان',
|
||||
mapImagePlaceholder: 'تصویر پروژه',
|
||||
}
|
||||
|
||||
export type Dict = typeof fa
|
||||
@@ -151,7 +161,6 @@ export const en: Dict = {
|
||||
fallbackFaqTitle: 'Frequently Asked Questions',
|
||||
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
|
||||
|
||||
yearLabel: 'Year:',
|
||||
sqm: 'm²',
|
||||
|
||||
projectNotFound: 'Project not found',
|
||||
@@ -206,6 +215,17 @@ export const en: Dict = {
|
||||
nextTestimonial: 'Next testimonial',
|
||||
selectTestimonialAria: 'Select testimonial',
|
||||
testimonialByPrefix: 'Testimonial by',
|
||||
|
||||
mapAria: 'Projects location map',
|
||||
mapLoading: 'Loading map…',
|
||||
mapErrorText: 'The map failed to load — check the tile server connection.',
|
||||
viewProject: 'View project',
|
||||
mapFullscreen: 'Fullscreen',
|
||||
mapExitFullscreen: 'Exit fullscreen',
|
||||
viewAddress: 'View project location',
|
||||
mapNewBadge: 'New',
|
||||
mapProjectDescription: 'Project delivered in Isfahan',
|
||||
mapImagePlaceholder: 'Project image',
|
||||
}
|
||||
|
||||
export const dict = { fa, en }
|
||||
|
||||
@@ -366,4 +366,78 @@ html[lang='en'] .article-body h3 {
|
||||
}
|
||||
.aramesh-datepicker .rmdp-action-button {
|
||||
color: #d5874c;
|
||||
}
|
||||
|
||||
/* ---------- نقشه مپلیبر — کنترلها و پاپآپ همرنگ تم تیره ---------- */
|
||||
.lux-map-popup .maplibregl-popup-content {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* دم پاپآپ — همرنگ کارت شیشهای */
|
||||
.lux-map-popup .maplibregl-popup-tip {
|
||||
border-top-color: #1e1a16;
|
||||
}
|
||||
.maplibregl-ctrl-group {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(245, 243, 239, 0.14);
|
||||
border-radius: 0.75rem !important;
|
||||
background: rgba(30, 26, 22, 0.85) !important;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.maplibregl-ctrl-group button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.maplibregl-ctrl-group button:hover {
|
||||
background-color: rgba(213, 135, 76, 0.16) !important;
|
||||
}
|
||||
.maplibregl-ctrl-group button + button {
|
||||
border-top: 1px solid rgba(245, 243, 239, 0.12) !important;
|
||||
}
|
||||
/* آیکونهای پیشفرض کنترلها تیرهاند — با اینورتر روشن میشوند */
|
||||
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
|
||||
filter: invert(0.92);
|
||||
}
|
||||
.maplibregl-ctrl-attrib.maplibregl-compact {
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(30, 26, 22, 0.85);
|
||||
color: rgba(245, 243, 239, 0.55);
|
||||
font-size: 10px;
|
||||
}
|
||||
.maplibregl-ctrl-attrib a {
|
||||
color: rgba(213, 135, 76, 0.9);
|
||||
}
|
||||
.maplibregl-ctrl-attrib-button {
|
||||
filter: invert(0.92);
|
||||
}
|
||||
.maplibregl-ctrl-scale {
|
||||
border-color: rgba(245, 243, 239, 0.35);
|
||||
border-radius: 2px;
|
||||
background: rgba(30, 26, 22, 0.7);
|
||||
color: rgba(245, 243, 239, 0.6);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---------- ورود نرم کارت پاپآپ و حالت تمامصفحه ---------- */
|
||||
@keyframes luxPopupIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes luxFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import type { ProjectsMapAllProps } from './ProjectsMapAll'
|
||||
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
|
||||
|
||||
/**
|
||||
* بارگیری تنبل نقشهها — بستهی سنگین مپلیبر فقط وقتی دانلود میشود که
|
||||
* واقعاً نقشهای روی صفحه باشد؛ صفحههای بدون نقشه (مثل خانه) جریمهی حجم
|
||||
* نمیپردازند. Import زیر فقط نوع است و باندل را سنگین نمیکند.
|
||||
*/
|
||||
const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({ default: module.ProjectsMapAll })))
|
||||
const ProjectsMapSingle = lazy(() =>
|
||||
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
|
||||
)
|
||||
|
||||
/** جاینگهدار هماندازهی نقشه تا چیدمان هنگام بارگیری نلرزد */
|
||||
function MapSkeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
|
||||
}
|
||||
|
||||
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
|
||||
return (
|
||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||
<ProjectsMapAll {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
|
||||
return (
|
||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||
<ProjectsMapSingle {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import { MdClose, MdFullscreen } from 'react-icons/md'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import type { MapStatus } from './types'
|
||||
|
||||
type MapViewportProps = {
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>
|
||||
/** نمونهی نقشه — فقط برای همگامسازی اندازه؛ مالکیتش با useMapLibre است */
|
||||
map: maplibregl.Map | null
|
||||
status: MapStatus
|
||||
onRetry: () => void
|
||||
/** برچسب دسترسپذیری ناحیهی نقشه */
|
||||
label: string
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||
className?: string
|
||||
/** محتوای شناور روی نقشه (پورتال پاپآپ) */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* پوستهی نقشه — قاب شیشهای، حالت تمامصفحه، کنترل بزرگنمایی و لایههای
|
||||
* بارگذاری/خطا.
|
||||
*
|
||||
* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد مینشیند (نه absolute)؛
|
||||
* استایل خود مپلیبر بعد از تیلویند بار میشود و position: relative میدهد،
|
||||
* پس کلاس absolute بیاثر میشد و ظرف صفر میماند.
|
||||
*
|
||||
* در تمامصفحه فقط شفافیت و گوشهها انیمیت میشوند تا بافر بوم هرگز کشیده
|
||||
* و تار نشود؛ تغییر اندازه در همان فریم اعمال میشود.
|
||||
*/
|
||||
export function MapViewport({
|
||||
containerRef,
|
||||
map,
|
||||
status,
|
||||
onRetry,
|
||||
label,
|
||||
className = '',
|
||||
children,
|
||||
}: MapViewportProps) {
|
||||
const { t } = useLang()
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
/** تمامصفحه: Esc برای خروج و قفل اسکرول پسزمینه */
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsFullscreen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
/** پس از پایان انتقال، بوم را همگام کن (مپلیبر خودش هم ResizeObserver دارد) */
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const timer = window.setTimeout(() => map?.resize(), 340)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isFullscreen, map])
|
||||
|
||||
const shellClass = isFullscreen
|
||||
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
|
||||
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
aria-label={label}
|
||||
className={`overflow-hidden bg-char font-pinar transition-all duration-300 ease-lux ${shellClass}`}
|
||||
>
|
||||
{/* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد است؛ کلاس relative صریح
|
||||
است تا هم بستر موقعیتیابی فرزندان مپلیبر باشد و هم هشدار
|
||||
«container has a non-static position» و محاسبهی اشتباه آفست اسکرول
|
||||
رخ ندهد — ترتیب بار شدن CSS هیچوقت تعیینکننده نباشد */}
|
||||
<div ref={containerRef} className="relative h-full w-full" />
|
||||
|
||||
{isFullscreen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
aria-label={t('mapExitFullscreen')}
|
||||
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<MdClose className="h-4 w-4" aria-hidden="true" />
|
||||
{t('mapExitFullscreen')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
aria-label={t('mapFullscreen')}
|
||||
title={t('mapFullscreen')}
|
||||
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
>
|
||||
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status !== 'ready' && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
|
||||
{status === 'loading' ? (
|
||||
<>
|
||||
<Loader2Icon className="h-7 w-7 animate-spin text-copper" aria-hidden="true" />
|
||||
<p className="text-xs font-light tracking-wide text-ivory/60">{t('mapLoading')}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="max-w-xs text-center text-xs font-light leading-6 text-ivory/60">
|
||||
{t('mapErrorText')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="border border-copper/50 px-6 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, type MouseEvent, type PointerEvent } from 'react'
|
||||
import { MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||
import { ProjectImagePlaceholder } from '../components/ProjectImagePlaceholder'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
type ProjectImageSliderProps = {
|
||||
/** همان آرایهٔ تصاویر صفحهٔ جزئیات پروژه — در نبودش جاینگهدار میآید */
|
||||
images: string[]
|
||||
alt: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* اسلایدر تصویر لبهتالبهٔ بالای کارت پاپآپ (سبک Zillow): فلشهای چپ/راست،
|
||||
* شمارندهٔ اسلاید و گوشههای گرد هماندازهٔ کارت. همهٔ رویدادهای اشاره روی
|
||||
* اسلایدر متوقف میشوند تا کلیک/کشیدن آن، نقشه را جابهجا یا پاپآپ را نبندد.
|
||||
*/
|
||||
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
|
||||
const { t } = useLang()
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
/** هیچ رویدادی از اسلایدر به نقشهٔ زیرین نمیرسد */
|
||||
const stop = (event: MouseEvent | PointerEvent) => event.stopPropagation()
|
||||
|
||||
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
|
||||
|
||||
if (!images.length) {
|
||||
return (
|
||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||
<ProjectImagePlaceholder />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const current = Math.min(index, images.length - 1)
|
||||
const step = (delta: number) => setIndex((value) => (value + delta + images.length) % images.length)
|
||||
|
||||
const arrow =
|
||||
'absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/85 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char'
|
||||
|
||||
return (
|
||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('prevImage')}
|
||||
title={t('prevImage')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className={`${arrow} left-2`}
|
||||
>
|
||||
<MdChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nextImage')}
|
||||
title={t('nextImage')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className={`${arrow} right-2`}
|
||||
>
|
||||
<MdChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<span className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-ink/75 px-2.5 py-0.5 text-[10px] font-light text-ivory/85 backdrop-blur-md">
|
||||
{toPersianDigits(current + 1)} / {toPersianDigits(images.length)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MdLocationOn } from 'react-icons/md'
|
||||
|
||||
type ProjectPinProps = {
|
||||
label: string
|
||||
/** پروژهی انتخابشده — هالهی مسی پایدار */
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* سنجاق پروژه روی نقشه — آیکون MdLocationOn در رنگ مسی برند با هالهی
|
||||
* نورانی در هاور، کلیک و حالت انتخابشده. این کامپوننت داخل المان نشانگر
|
||||
* مپلیبر رندر میشود، پس خودش هیچ چیدمان بیرونی ندارد.
|
||||
*/
|
||||
export function ProjectPin({ label, active = false, onClick }: ProjectPinProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className="group flex cursor-pointer items-end justify-center bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
>
|
||||
<MdLocationOn
|
||||
className={`h-9 w-9 text-[#d5874c] transition-all duration-300 ease-lux group-hover:scale-125 group-hover:drop-shadow-[0_0_14px_rgba(213,135,76,0.95)] group-active:scale-110 group-active:drop-shadow-[0_0_18px_rgba(213,135,76,1)] ${
|
||||
active
|
||||
? 'scale-110 drop-shadow-[0_0_16px_rgba(213,135,76,0.95)]'
|
||||
: 'drop-shadow-[0_3px_6px_rgba(0,0,0,0.65)]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { MdClose } from 'react-icons/md'
|
||||
import type { MapProject } from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { ProjectImageSlider } from './ProjectImageSlider'
|
||||
|
||||
/** دکمههای کارت — تمامعرض، همخانوادهٔ دکمههای مسی سایت (در ستون هم درست مینشینند) */
|
||||
export const POPUP_PRIMARY_CLASS =
|
||||
'flex w-full items-center justify-center gap-1.5 rounded-xl bg-copper px-3 py-2.5 text-[12px] font-bold text-char transition-all duration-300 ease-lux hover:scale-[1.03] hover:shadow-[0_8px_22px_rgba(213,135,76,0.35)] active:scale-95'
|
||||
|
||||
export const POPUP_SECONDARY_CLASS =
|
||||
'flex w-full items-center justify-center gap-1.5 rounded-xl border border-ivory/20 px-3 py-2.5 text-[12px] font-light text-ivory/80 transition-colors duration-300 ease-lux hover:border-copper hover:text-copper'
|
||||
|
||||
type ProjectPopupCardProps = {
|
||||
project: MapProject
|
||||
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جاینگهدار میآید */
|
||||
images?: string[]
|
||||
onClose: () => void
|
||||
/** دکمههای پایهٔ کارت — هر نما اکشنهای خودش را میدهد (عمودی روی هم) */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* کارت پاپآپ پروژه به سبک Zillow — اسلایدر لبهتالبه در بالاترین بخش، سپس
|
||||
* عنوان و توضیح، و در پایه دکمههای عمودی. راستبهچپ، شیشهای تیره و
|
||||
* هماندازهٔ کارتهای سایت. سال پروژه عمداً نمایش داده نمیشود.
|
||||
*/
|
||||
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
|
||||
const { t } = useLang()
|
||||
const description = project.description.trim() || t('mapProjectDescription')
|
||||
|
||||
return (
|
||||
<article
|
||||
dir="rtl"
|
||||
className="w-[17rem] max-w-[calc(100vw-2.5rem)] animate-[luxPopupIn_0.22s_ease-out] overflow-hidden rounded-2xl border border-ivory/10 bg-[#27221c]/95 font-pinar text-ivory shadow-[0_24px_60px_-15px_rgba(0,0,0,0.9)] backdrop-blur-xl sm:w-72"
|
||||
>
|
||||
{/* اسلایدر تصویر: لبهتالبه، بدون حاشیه، با فلشهای چپ/راست */}
|
||||
<div className="relative">
|
||||
<ProjectImageSlider images={images} alt={project.name} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
className="absolute left-2.5 top-2.5 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char"
|
||||
>
|
||||
<MdClose className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{project.isNew && (
|
||||
<span className="absolute right-2.5 top-2.5 z-20 rounded-full bg-copper px-2.5 py-1 text-[10px] font-bold text-char shadow-[0_4px_14px_rgba(213,135,76,0.4)]">
|
||||
{t('mapNewBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1 pt-3.5">
|
||||
<h3 className="min-w-0 truncate text-base font-bold text-ivory">{project.name}</h3>
|
||||
<p className="mt-1.5 line-clamp-2 text-[11px] font-light leading-6 text-ivory/60">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 pt-2.5">{children}</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
|
||||
import {
|
||||
ISFAHAN_CENTER,
|
||||
MAP_PROJECTS,
|
||||
boundsForProjects,
|
||||
buildNeshanUrl,
|
||||
type MapProject,
|
||||
} from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { MapViewport } from './MapViewport'
|
||||
import { POPUP_PRIMARY_CLASS, POPUP_SECONDARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||
import { useMapLibre } from './useMapLibre'
|
||||
import { useProjectMarkers } from './useProjectMarkers'
|
||||
|
||||
export type ProjectsMapAllProps = {
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||
className?: string
|
||||
/** فهرست پروژهها — پیشفرض همهی ردیفهای شیت */
|
||||
projects?: MapProject[]
|
||||
/**
|
||||
* نشانی صفحهی جزئیات هر پروژه. اگر تطبیقی پیدا نشود، دکمهی «مشاهده پروژه»
|
||||
* به فهرست پروژهها میرود تا هیچ لینکی بیمقصد نماند.
|
||||
*/
|
||||
resolveProjectHref?: (project: MapProject) => string | undefined
|
||||
/** آرایهٔ تصاویر هر پروژه (همان آرایهٔ صفحهٔ جزئیات) — در نبودش جاینگهدار */
|
||||
imagesFor?: (project: MapProject) => string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای «همهی پروژهها» — همهی مختصات شیت روی یک نقشهی تمامعرض با
|
||||
* سنجاقهای مسی، پاپآپ کارتی و دو اکشن عمودی: صفحهی پروژه و آدرس نشان.
|
||||
* پاپآپ فقط با کلیک کاربر باز میشود و جابهجایی نقشه به محدودهٔ پروژهها
|
||||
* بسته است.
|
||||
*/
|
||||
export function ProjectsMapAll({
|
||||
className = '',
|
||||
projects = MAP_PROJECTS,
|
||||
resolveProjectHref,
|
||||
imagesFor,
|
||||
}: ProjectsMapAllProps) {
|
||||
const { t, href } = useLang()
|
||||
const bounds = useMemo(() => boundsForProjects(projects), [projects])
|
||||
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
|
||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||
|
||||
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
|
||||
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
|
||||
|
||||
return (
|
||||
<MapViewport
|
||||
containerRef={containerRef}
|
||||
map={map}
|
||||
status={status}
|
||||
onRetry={retry}
|
||||
label={t('mapAria')}
|
||||
className={className}
|
||||
>
|
||||
{popupHost &&
|
||||
activeProject &&
|
||||
createPortal(
|
||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
|
||||
{t('viewProject')}
|
||||
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{/* منطق دکمهٔ آدرس دستنخورده — فقط در ستون عمودی نشسته است */}
|
||||
<a
|
||||
href={buildNeshanUrl(activeProject)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={POPUP_SECONDARY_CLASS}
|
||||
>
|
||||
{t('viewAddress')}
|
||||
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</ProjectPopupCard>,
|
||||
popupHost,
|
||||
)}
|
||||
</MapViewport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MdOpenInNew } from 'react-icons/md'
|
||||
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { MapViewport } from './MapViewport'
|
||||
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||
import { useMapLibre } from './useMapLibre'
|
||||
import { useProjectMarkers } from './useProjectMarkers'
|
||||
|
||||
export type ProjectsMapSingleProps = {
|
||||
/** همان پروژهی شیت — یا محل جایگزین ساختهشده با createPlaceholderProject */
|
||||
location: MapProject
|
||||
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات (تصویر شاخص + گالری) */
|
||||
images?: string[]
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-80» */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای «تکپروژه» — یک سنجاق دقیق روی مختصات همان پروژه و پاپآپی که فقط با
|
||||
* کلیک کاربر باز میشود. تنها اکشن کارت، مشاهدهٔ آدرس پروژه در نشان است.
|
||||
* جابهجایی نقشه به محدودهٔ همین پروژه بسته شده تا به بیرون نرود.
|
||||
*/
|
||||
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
|
||||
const { t } = useLang()
|
||||
const projects = useMemo(() => [location], [location])
|
||||
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
|
||||
const { containerRef, map, status, retry } = useMapLibre({
|
||||
center: [location.lng, location.lat],
|
||||
zoom: 15,
|
||||
maxBounds: bounds,
|
||||
})
|
||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||
|
||||
return (
|
||||
<MapViewport
|
||||
containerRef={containerRef}
|
||||
map={map}
|
||||
status={status}
|
||||
onRetry={retry}
|
||||
label={t('mapAria')}
|
||||
className={className}
|
||||
>
|
||||
{popupHost &&
|
||||
activeProject &&
|
||||
createPortal(
|
||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||
{/* منطق دکمهٔ آدرس دستنخورده است — تنها اکشن این کارت */}
|
||||
<a
|
||||
href={buildNeshanUrl(activeProject)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={POPUP_PRIMARY_CLASS}
|
||||
>
|
||||
{t('viewAddress')}
|
||||
<MdOpenInNew className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</ProjectPopupCard>,
|
||||
popupHost,
|
||||
)}
|
||||
</MapViewport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { Protocol } from 'pmtiles'
|
||||
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||
|
||||
let setup: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* آمادهسازی وبورکر نقشه — یکبار برای همه نقشهها.
|
||||
*
|
||||
* ۱) نشانی وبورکر مپلیبر صریح تعیین میشود: مپلیبر بهصورت پیشفرض
|
||||
* «./maplibre-gl-worker.mjs» را کنار باندل جستوجو میکند که در خروجی ویت
|
||||
* وجود ندارد و ورکر ۴۰۴ میشود؛ نسخهی ساختهشدهی ویت را با setWorkerUrl
|
||||
* میدهیم (نشانی مطلق، چون پایهی ورکر میتواند Blob باشد).
|
||||
* ۲) پروتکل pmtiles در نخ اصلی ثبت میشود.
|
||||
* ۳) همان پروتکل با importScriptInWorkers داخل وبورکر هم ثبت میشود —
|
||||
* کاشیهای برداری آنجا بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||
* جداست. اسکریپت وبورکر، خروجی خودبسندهی public/map/pmtiles-worker.mjs
|
||||
* است (ساختهشده از src/map/pmtiles.worker.ts با npm run build:map-worker).
|
||||
*
|
||||
* تا پایان بارگیری اسکریپت صبر میکنیم تا نخستین درخواستهای کاشی در وبورکر
|
||||
* بیپاسخ نمانند.
|
||||
*/
|
||||
export function ensureMapWorker(): Promise<void> {
|
||||
if (!setup) {
|
||||
maplibregl.setWorkerUrl(new URL(maplibreWorkerUrl, window.location.href).href)
|
||||
maplibregl.addProtocol('pmtiles', new Protocol().tile)
|
||||
|
||||
const protocolScript = new URL(
|
||||
`${import.meta.env.BASE_URL}map/pmtiles-worker.mjs`,
|
||||
window.location.href,
|
||||
).href
|
||||
setup = maplibregl.importScriptInWorkers(protocolScript)
|
||||
}
|
||||
return setup
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Protocol } from 'pmtiles'
|
||||
|
||||
/**
|
||||
* اسکریپت سمت وبورکر مپلیبر — با importScriptInWorkers داخل وبورکر اجرا
|
||||
* میشود و مپلیبر تابع addProtocol را روی self وبورکر گذاشته است.
|
||||
*
|
||||
* کاشیهای برداری در وبورکر بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||
* جداست؛ بدون این ثبت، درخواستهای pmtiles در وبورکر بیپاسخ میمانند و
|
||||
* نقشه فقط پسزمینه را نشان میدهد.
|
||||
*/
|
||||
const workerScope = self as unknown as {
|
||||
addProtocol: (name: string, loadFn: unknown) => void
|
||||
}
|
||||
|
||||
workerScope.addProtocol('pmtiles', new Protocol().tile)
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SourceSpecification, StyleSpecification } from 'maplibre-gl'
|
||||
import { MAP_BASE_URL } from '../config'
|
||||
|
||||
const PMTILES_SCHEME = 'pmtiles://'
|
||||
/** نشانیهای مطلق — دستنخورده میمانند */
|
||||
const EXTERNAL = /^(https?:|data:|blob:)/i
|
||||
/** تنها آرشیو برداری باکت — منبع برداری سبک همیشه به همین جا میرسد */
|
||||
const PMTILES_ARCHIVE = 'iran.pmtiles'
|
||||
/** نام منبع برداری در سبکهای استاندارد OpenMapTiles */
|
||||
const OPENMAPTILES_KEY = 'openmaptiles'
|
||||
/** پوشه فونتهای گلیف در باکت MinIO — سبک خام فقط «{fontstack}/{range}.pbf» دارد */
|
||||
const GLYPHS_FOLDER = 'v2.0'
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
/** نشانی آرشیو برداری روی MinIO — تنها منبع کاشی این پروژه */
|
||||
function archiveUrl(base: string): string {
|
||||
return `${PMTILES_SCHEME}${joinUrl(base, PMTILES_ARCHIVE)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگذاری جانگهدارهای سبک — «{styleJsonFolder}» به آدرس پایه MinIO تبدیل
|
||||
* میشود. جانگهدارهای {fontstack} و {range} قانونی خود مپلیبرند و دستنخورده
|
||||
* میمانند. جانگهدار تازهای در سبکهای بعدی دیده شد، به همین نگاشت اضافه شود.
|
||||
*/
|
||||
function resolvePlaceholders(url: string, base: string): string {
|
||||
return url.replace(/\{styleJsonFolder\}/g, base)
|
||||
}
|
||||
|
||||
/** مطلقسازی نشانیهای عمومی (گلیف، اسپرایت، منابع غیربرداری) */
|
||||
function absolutize(resource: string, base: string): string {
|
||||
const url = resolvePlaceholders(resource.trim(), base)
|
||||
if (EXTERNAL.test(url)) return url
|
||||
|
||||
if (url.startsWith(PMTILES_SCHEME)) {
|
||||
const target = url.slice(PMTILES_SCHEME.length).trim()
|
||||
return EXTERNAL.test(target) ? url : PMTILES_SCHEME + joinUrl(base, target)
|
||||
}
|
||||
|
||||
return joinUrl(base, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* نشانی گلیفها — سبک خام فقط الگوی «{fontstack}/{range}.pbf» را دارد و پوشه
|
||||
* فونتها را نمیآورد، اما فونتهای باکت زیر پوشه v2.0 هستند؛ پس الگو همیشه
|
||||
* به همان پوشه میرسد و جانگهدارهای {fontstack} و {range} دستنخورده میمانند.
|
||||
*/
|
||||
function resolveGlyphsUrl(url: string, base: string): string {
|
||||
const resolved = resolvePlaceholders(url.trim(), base)
|
||||
if (EXTERNAL.test(resolved)) return resolved
|
||||
|
||||
if (resolved.includes('{fontstack}')) {
|
||||
const template = resolved.slice(resolved.indexOf('{fontstack}'))
|
||||
return joinUrl(base, joinUrl(GLYPHS_FOLDER, template))
|
||||
}
|
||||
|
||||
return joinUrl(base, resolved)
|
||||
}
|
||||
|
||||
/** منبع برداری سبک — با نوع vector یا نام شناختهشده openmaptiles */
|
||||
function isVectorSource(key: string, source: SourceSpecification): boolean {
|
||||
return source.type === 'vector' || key === OPENMAPTILES_KEY
|
||||
}
|
||||
|
||||
/**
|
||||
* بازنویسی نشانی منبع برداری. سبکهای ساختهشده برای سرور MBTiles (مثل
|
||||
* «mbtiles://{v3}» در tileserver-gl) کاشی برداری را با طرح mbtiles آدرس
|
||||
* میدهند که مپلیبر نمیشناسد؛ هر شکلی از نشانی اینجا به آرشیو iran.pmtiles
|
||||
* روی MinIO هدایت میشود تا با پروتکل pmtiles رندر شود.
|
||||
*/
|
||||
function resolveVectorUrl(url: string, base: string): string {
|
||||
const resolved = resolvePlaceholders(url.trim(), base)
|
||||
|
||||
if (resolved.startsWith(PMTILES_SCHEME)) {
|
||||
const target = resolved.slice(PMTILES_SCHEME.length).trim()
|
||||
return EXTERNAL.test(target) ? resolved : PMTILES_SCHEME + joinUrl(base, target)
|
||||
}
|
||||
|
||||
return archiveUrl(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* پالایش سبک نقشه پیش از تحویل به مپلیبر — style.json خام MinIO با
|
||||
* جانگهدار ({styleJsonFolder}) و نشانی MBTiles نوشته شده است؛ این تابع با یک
|
||||
* پاس، گلیف/اسپرایت را به آدرس مطلق MinIO و منبع برداری را به آرشیو
|
||||
* iran.pmtiles تبدیل میکند تا مستقیم به «new Map({ style })» داده شود.
|
||||
*/
|
||||
export function rewriteStyleUrls(
|
||||
style: StyleSpecification,
|
||||
base: string = MAP_BASE_URL,
|
||||
): StyleSpecification {
|
||||
const next: StyleSpecification = { ...style }
|
||||
|
||||
if (typeof next.glyphs === 'string') {
|
||||
next.glyphs = resolveGlyphsUrl(next.glyphs, base)
|
||||
}
|
||||
|
||||
// اسپرایت در نسخه ۸ مشخصات سبک، رشته یا فهرست {id, url} است
|
||||
if (typeof next.sprite === 'string') {
|
||||
next.sprite = absolutize(next.sprite, base)
|
||||
} else if (Array.isArray(next.sprite)) {
|
||||
next.sprite = next.sprite.map((sprite) => ({ ...sprite, url: absolutize(sprite.url, base) }))
|
||||
}
|
||||
|
||||
const sources: StyleSpecification['sources'] = {}
|
||||
for (const [key, source] of Object.entries(next.sources ?? {})) {
|
||||
const rewritten = { ...source } as SourceSpecification & { url?: unknown; tiles?: unknown }
|
||||
|
||||
if (typeof rewritten.url === 'string') {
|
||||
const raw = rewritten.url.trim()
|
||||
if (isVectorSource(key, source)) {
|
||||
rewritten.url = resolveVectorUrl(raw, base)
|
||||
} else {
|
||||
// رندر سختگیرانهی برداری: منابع غیربرداری (مثل raster) هرگز به آرشیو
|
||||
// برداری هدایت نمیشوند — کاشی MVT زیر منبع raster یعنی خروجی خراب.
|
||||
if (import.meta.env.DEV && source.type === 'raster') {
|
||||
console.warn(`[map] منبع غیربرداری «${key}» در سبک هست؛ افزایش رزولوشن برداری برای آن اعمال نمیشود`)
|
||||
}
|
||||
rewritten.url = absolutize(raw, base)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rewritten.tiles)) {
|
||||
rewritten.tiles = rewritten.tiles.map((tile) =>
|
||||
typeof tile === 'string' ? absolutize(tile, base) : tile,
|
||||
)
|
||||
}
|
||||
sources[key] = rewritten
|
||||
}
|
||||
next.sources = sources
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** بارگیری style.json از MinIO و بازگرداندن سبک پالایششده برای «new Map({ style })» */
|
||||
export async function loadMapStyle(signal?: AbortSignal): Promise<StyleSpecification> {
|
||||
const res = await fetch(joinUrl(MAP_BASE_URL, 'style.json'), { signal })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return rewriteStyleUrls((await res.json()) as StyleSpecification)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RequestTransformFunction } from 'maplibre-gl'
|
||||
|
||||
/** دو بخش آخر مسیر گلیف: پوشهی پشتهی فونت و فایل بازه */
|
||||
const GLYPH_PATH = /\/([^/?#]+)\/([^/?#]+\.pbf)$/
|
||||
|
||||
/**
|
||||
* نشانی گلیف با تنها فونت نخستِ پشته.
|
||||
*
|
||||
* مپلیبر برای هر لایه، تمام `text-font` را با کاما در {fontstack} میگذارد
|
||||
* (مثل «Metropolis Regular,Noto Sans Regular»)؛ سرورهای عمومی فونت، پوشهی
|
||||
* همین پشتهی ترکیبی را دارند اما MinIO این پروژه فونتها را در پوشههای
|
||||
* تکفونتی نگه میدارد. نتیجهی آن درخواستها ۴۰۴ بود، متنها رندر نمیشدند و
|
||||
* سیل خطا اپ را قفل میکرد. با این بازنویسی فقط فونت نخست — همان ترجیح اول
|
||||
* سبک — درخواست میشود و بقیهی مسیر (پوشهی v2.0 و فایل بازه) دستنخورده
|
||||
* میماند. نشانی بدون پشتهی ترکیبی یا ناشناس، بدون تغییر برمیگردد.
|
||||
*/
|
||||
export function singleFontGlyphUrl(url: string): string {
|
||||
const queryIndex = url.search(/[?#]/)
|
||||
const path = queryIndex === -1 ? url : url.slice(0, queryIndex)
|
||||
const suffix = queryIndex === -1 ? '' : url.slice(queryIndex)
|
||||
|
||||
const match = path.match(GLYPH_PATH)
|
||||
if (!match) return url
|
||||
|
||||
let fonts: string[]
|
||||
try {
|
||||
fonts = match[1].split(',').map((font) => decodeURIComponent(font).trim())
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
if (fonts.length < 2 || !fonts[0]) return url
|
||||
|
||||
return `${path.slice(0, match.index)}/${encodeURIComponent(fonts[0])}/${match[2]}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* قلاب تبدیل درخواستهای نقشه — فقط گلیفها بازنویسی میشوند و هر درخواست
|
||||
* دیگری دستنخورده میماند تا رفتار پیشفرض مپلیبر حفظ شود.
|
||||
*/
|
||||
export const mapTransformRequest: RequestTransformFunction = (url, resourceType) =>
|
||||
resourceType === 'Glyphs' ? { url: singleFontGlyphUrl(url) } : { url }
|
||||
@@ -0,0 +1,2 @@
|
||||
/** وضعیت چرخهی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
|
||||
export type MapStatus = 'loading' | 'ready' | 'error'
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ensureMapWorker } from './mapWorker'
|
||||
import { loadMapStyle } from './styleLoader'
|
||||
import { mapTransformRequest } from './transformRequest'
|
||||
import type { MapStatus } from './types'
|
||||
|
||||
type UseMapLibreOptions = {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
/** بزرگنمایی با چرخ ماوس و پینچ لمسی — پیشفرض فعال */
|
||||
scrollZoom?: boolean
|
||||
/** محدودهٔ مجاز جابهجایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژهها */
|
||||
maxBounds?: maplibregl.LngLatBoundsLike
|
||||
}
|
||||
|
||||
/** سقف انتظار سبک نقشه — سرور کاشی بیپاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بیپایان */
|
||||
const STYLE_TIMEOUT_MS = 10_000
|
||||
/** اگر رویداد style.load دیر برسد، وضعیت آماده با این مهلت نمایش داده میشود */
|
||||
const READY_FALLBACK_MS = 2_000
|
||||
|
||||
/**
|
||||
* سقف بزرگنمایی = maxzoom آرشیو + همین مقدار سطح overzoom.
|
||||
*
|
||||
* سنجش روی تایلهای واقعی z14 اصفهان (۲۶٫۸۷۴ پارهخط جاده): میانهی وتر ۲۰
|
||||
* متر و صدک ۷۵ برابر ۳۶ متر است. با سه سطح overzoom (زوم ۱۷، ۰٫۵ متر بر
|
||||
* پیکسل) میانه ۴۰ و صدک ۷۵ حدود ۷۲ پیکسل میشود که خمها را هنوز صاف نشان
|
||||
* میدهد؛ از ۱۷٫۵ به بعد وترها به ۱۰۰ پیکسل و بیشتر میرسند و چندضلعیشدن
|
||||
* خمها دیده میشود. متن و آیکون هرگز تار نمیشوند (متن SDF و آیکون اسپرایت
|
||||
* است)؛ افت کیفیت فقط از هندسهی سادهشده میآید. با بازتولید آرشیو در z16
|
||||
* این سقف خودکار به ۱۹ میرسد.
|
||||
*/
|
||||
const OVERZOOM_LEVELS = 3
|
||||
/** سقف مطلق زوم — بالاتر از آن حتی با دادهی دقیقتر هم نمایش مرورگر بیمعناست */
|
||||
const ABSOLUTE_MAX_ZOOM = 22
|
||||
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
|
||||
const FALLBACK_MAX_ZOOM = 17
|
||||
|
||||
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [map, setMap] = useState<maplibregl.Map | null>(null)
|
||||
const [status, setStatus] = useState<MapStatus>('loading')
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let disposed = false
|
||||
let instance: maplibregl.Map | null = null
|
||||
let readyFallback: number | undefined
|
||||
let dprQuery: MediaQueryList | null = null
|
||||
let handleDprChange: (() => void) | null = null
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
|
||||
|
||||
setStatus('loading')
|
||||
setMap(null)
|
||||
|
||||
// وبورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
|
||||
// وگرنه نخستین درخواستهای کاشی در وبورکر بیپاسخ میمانند
|
||||
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
|
||||
.then(([style]) => {
|
||||
if (disposed) return
|
||||
instance = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center,
|
||||
zoom,
|
||||
maxBounds,
|
||||
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق میکند
|
||||
maxZoom: FALLBACK_MAX_ZOOM,
|
||||
renderWorldCopies: false,
|
||||
// لبههای نرمتر خطوط و چندضلعیها — حس لوکس رندر برداری
|
||||
canvasContextAttributes: { antialias: true },
|
||||
// کیفیت حداکثری در بزرگنماییهای عمیق: هیچ سطحی با مقیاسکردن سطح
|
||||
// بالاتر رندر نمیشود و همهی سطحها تا maxZoom از تقسیم هندسه
|
||||
// میآیند (پیشفرض ۴ سطح آخر را overscale میکند). مبادله: کار
|
||||
// بیشتر پردازنده در زومهای عمیق.
|
||||
zoomLevelsToOverscale: 0,
|
||||
// چرخ ماوس، پینچ لمسی و دکمههای کنترل — مطابق درخواست کاربر
|
||||
scrollZoom,
|
||||
attributionControl: { compact: true },
|
||||
// MinIO فونتها را در پوشههای تکفونتی دارد، پس پشتهی ترکیبی
|
||||
// text-font («Metropolis Regular,Noto Sans Regular») به فونت نخست
|
||||
// بازنویسی میشود — توضیح کامل در transformRequest.ts
|
||||
transformRequest: mapTransformRequest,
|
||||
})
|
||||
|
||||
/**
|
||||
* سقف بزرگنمایی را از maxzoom واقعی آرشیو میسازد؛ با دادهی دقیقتر
|
||||
* (بازتولید آرشیو در z بالاتر) خودکار بالاتر میرود و کیفیت روی صفحه
|
||||
* ثابت میماند. زوم جاری هم اگر از سقف بیشتر باشد به آن محدود میشود.
|
||||
*/
|
||||
const applyZoomLimit = () => {
|
||||
if (!instance) return
|
||||
const source = instance.getSource('openmaptiles') as { maxzoom?: number } | undefined
|
||||
const sourceMaxZoom = typeof source?.maxzoom === 'number' ? source.maxzoom : null
|
||||
instance.setMaxZoom(
|
||||
sourceMaxZoom === null
|
||||
? FALLBACK_MAX_ZOOM
|
||||
: Math.min(sourceMaxZoom + OVERZOOM_LEVELS, ABSOLUTE_MAX_ZOOM),
|
||||
)
|
||||
}
|
||||
|
||||
instance.on('style.load', () => {
|
||||
if (disposed) return
|
||||
setStatus('ready')
|
||||
window.setTimeout(() => instance?.resize(), 150)
|
||||
})
|
||||
|
||||
/**
|
||||
* کنترل بزرگنمایی همراه خود نقشه ساخته میشود و با map.remove() پایین
|
||||
* میآید — map.remove بهتنهایی onRemove همهی کنترلها را صدا میزند،
|
||||
* پس removeControl دستی پس از آن، onRemove را دوباره اجرا میکند و با
|
||||
* «reading 'off' of undefined» کرش میکند. مالکیت کنترل همینجا، کنار
|
||||
* مالکیت نقشه میماند تا ترتیب پاکسازی هیچوقت به React وابسته نباشد.
|
||||
*/
|
||||
instance.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right')
|
||||
|
||||
// maxzoom منبع همراه متادیتای TileJSON میرسد — همان لحظه سقف را قطعی کن
|
||||
instance.on('sourcedata', (e) => {
|
||||
if (e.sourceDataType === 'metadata' && e.sourceId === 'openmaptiles') applyZoomLimit()
|
||||
})
|
||||
instance.on('load', applyZoomLimit)
|
||||
applyZoomLimit()
|
||||
|
||||
// تایمر فالبک امنیتی
|
||||
readyFallback = window.setTimeout(() => {
|
||||
if (disposed) return
|
||||
setStatus('ready')
|
||||
instance?.resize()
|
||||
}, READY_FALLBACK_MS)
|
||||
|
||||
instance.on('error', (e) => {
|
||||
console.warn('MapLibre minor error:', e)
|
||||
})
|
||||
|
||||
/**
|
||||
* نسبت پیکسل دستگاه منبع حقیقت بافر بوم است؛ با زوم مرورگر
|
||||
* (Ctrl+zoom) یا جابهجایی پنجره روی مانیتور با چگالی دیگر عوض
|
||||
* میشود. اگر بافر با نسبت تازه همگام نشود، نقشه تا رفرش بعدی تار
|
||||
* میماند. matchMedia هر تغییر را دقیقاً همان لحظه گزارش میدهد و
|
||||
* پس از هر تغییر دوباره مسلح میشود.
|
||||
*/
|
||||
function syncPixelRatio() {
|
||||
if (!instance) return
|
||||
if (instance.getPixelRatio() !== window.devicePixelRatio) {
|
||||
instance.setPixelRatio(window.devicePixelRatio)
|
||||
}
|
||||
instance.resize()
|
||||
}
|
||||
|
||||
function watchPixelRatio() {
|
||||
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
|
||||
handleDprChange = () => {
|
||||
syncPixelRatio()
|
||||
watchPixelRatio()
|
||||
}
|
||||
dprQuery.addEventListener('change', handleDprChange)
|
||||
}
|
||||
|
||||
watchPixelRatio()
|
||||
|
||||
// دسترسی اشکالزدایی در محیط توسعه — در بیلد تولیدی وجود ندارد
|
||||
if (import.meta.env.DEV) {
|
||||
(window as unknown as { __map?: maplibregl.Map }).__map = instance
|
||||
}
|
||||
|
||||
setMap(instance)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setStatus('error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
window.clearTimeout(timeoutId)
|
||||
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
|
||||
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||
controller.abort()
|
||||
instance?.remove()
|
||||
}
|
||||
// مرکز و بزرگنمایی فقط موقعیت اولیه نقشهاند؛ فهرست وابستگی عمداً فقط reloadKey است
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadKey])
|
||||
|
||||
const retry = useCallback(() => setReloadKey((key) => key + 1), [])
|
||||
|
||||
return { containerRef, map, status, retry }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { MapProject } from '../data/mapProjects'
|
||||
import { ProjectPin } from './ProjectPin'
|
||||
|
||||
type PinHandle = { project: MapProject; marker: maplibregl.Marker; root: Root }
|
||||
|
||||
/** حاشیهٔ امن پاپآپ از لبههای نقشه — بالای صفحه بازتر، برای نوار برچسب و کنترلها */
|
||||
const AUTO_PAN_PADDING = { top: 150, bottom: 20, left: 20, right: 20 }
|
||||
|
||||
/**
|
||||
* نگهداشتن پاپآپ داخل کادر دید — معادل رفتار autoPan که مپلیبر (برخلاف
|
||||
* مپباکس) گزینهاش را ندارد: اگر کارت از حاشیهٔ امن بیرون بزند، نقشه نرم
|
||||
* جابهجا میشود تا کارت کامل دیده شود.
|
||||
*/
|
||||
function ensurePopupVisible(instance: maplibregl.Map, popup: maplibregl.Popup): void {
|
||||
const element = popup.getElement()
|
||||
const container = instance.getContainer()
|
||||
if (!element || !container) return
|
||||
|
||||
// چیدمان پاپآپ در همان فریم addTo کامل میشود، پس اندازهگیری به فریم بعد موکول است
|
||||
window.requestAnimationFrame(() => {
|
||||
const popupRect = element.getBoundingClientRect()
|
||||
const viewRect = container.getBoundingClientRect()
|
||||
|
||||
const excessTop = viewRect.top + AUTO_PAN_PADDING.top - popupRect.top
|
||||
const excessBottom = popupRect.bottom - (viewRect.bottom - AUTO_PAN_PADDING.bottom)
|
||||
const excessLeft = viewRect.left + AUTO_PAN_PADDING.left - popupRect.left
|
||||
const excessRight = popupRect.right - (viewRect.right - AUTO_PAN_PADDING.right)
|
||||
|
||||
// کمبود افقی و عمودی؛ دوربین به اندازهٔ بیرونزدگی جابهجا میشود
|
||||
const dx = Math.max(0, excessLeft) - Math.max(0, excessRight)
|
||||
const dy = Math.max(0, excessTop) - Math.max(0, excessBottom)
|
||||
if (!dx && !dy) return
|
||||
|
||||
instance.panBy([-dx, -dy], { duration: 320 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* لایهی نشانگرهای پروژه — ساخت نشانگرها با React (هر نشانگر یک ریشهی
|
||||
* کوچک)، یک پاپآپ مشترک برای همه، و وضعیت پروژهی فعال.
|
||||
*
|
||||
* پاپآپ هیچوقت خودکار باز نمیشود؛ فقط با کلیک کاربر روی سنجاق. پاپآپ یک
|
||||
* المان میزبان دارد که بیرون از React ساخته میشود؛ مصرفکننده محتوای React را
|
||||
* با createPortal داخل همان میزبان میریزد تا Link روتر، حالت هاور و
|
||||
* انیمیشنها همه در درخت React بمانند.
|
||||
*/
|
||||
export function useProjectMarkers(map: maplibregl.Map | null, projects: MapProject[]) {
|
||||
const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null)
|
||||
const [activeProject, setActiveProject] = useState<MapProject | null>(null)
|
||||
|
||||
const popupRef = useRef<maplibregl.Popup | null>(null)
|
||||
const pinsRef = useRef<PinHandle[]>([])
|
||||
const activeRef = useRef<MapProject | null>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
mapRef.current = map
|
||||
|
||||
const openProject = useCallback((project: MapProject) => {
|
||||
const popup = popupRef.current
|
||||
const instance = mapRef.current
|
||||
if (!popup || !instance) return
|
||||
activeRef.current = project
|
||||
popup.setLngLat([project.lng, project.lat]).addTo(instance)
|
||||
ensurePopupVisible(instance, popup)
|
||||
setActiveProject(project)
|
||||
}, [])
|
||||
|
||||
const closePopup = useCallback(() => {
|
||||
activeRef.current = null
|
||||
const popup = popupRef.current
|
||||
// remove() فقط روی پاپآپ باز صدا زده میشود؛ پاپآپ بسته مالک مرورگر است
|
||||
if (popup?.isOpen()) popup.remove()
|
||||
setActiveProject(null)
|
||||
}, [])
|
||||
|
||||
/** پاپآپ مشترک + المان میزبان محتوا — یکبار برای هر نمونه نقشه */
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const host = document.createElement('div')
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: true,
|
||||
offset: 46,
|
||||
maxWidth: 'none',
|
||||
className: 'lux-map-popup',
|
||||
}).setDOMContent(host)
|
||||
|
||||
// بستن با کلیک روی نقشه یا دکمهی کارت، وضعیت فعال را هم پاک میکند
|
||||
popup.on('close', () => {
|
||||
activeRef.current = null
|
||||
setActiveProject(null)
|
||||
})
|
||||
|
||||
popupRef.current = popup
|
||||
setPopupHost(host)
|
||||
|
||||
return () => {
|
||||
// فقط پاپآپِ باز پایین آورده میشود؛ popup.remove خودش هم به رویداد
|
||||
// remove نقشه گوش میدهد، پس این پاکسازی تکراری و بیخطر است
|
||||
if (popup.isOpen()) popup.remove()
|
||||
popupRef.current = null
|
||||
setPopupHost(null)
|
||||
}
|
||||
}, [map])
|
||||
|
||||
/** نشانگرها — با تغییر فهرست پروژهها یا نمونهی نقشه بازساخته میشوند */
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const pins: PinHandle[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const element = document.createElement('div')
|
||||
const marker = new maplibregl.Marker({ element, anchor: 'bottom' })
|
||||
.setLngLat([project.lng, project.lat])
|
||||
.addTo(map)
|
||||
const root = createRoot(element)
|
||||
root.render(<ProjectPin label={project.name} active={false} onClick={() => openProject(project)} />)
|
||||
pins.push({ project, marker, root })
|
||||
}
|
||||
|
||||
pinsRef.current = pins
|
||||
|
||||
return () => {
|
||||
for (const pin of pins) {
|
||||
// نشانگر خودش پاکسازی امن دارد (حذف شنوندهها + برداشتن المان)
|
||||
pin.marker.remove()
|
||||
/**
|
||||
* unmount ریشهی React هنگام جدا شدن والد، وسط چرخهی رندر رخ میدهد و
|
||||
* React 18 برای unmount همگام ریشهی تودرتو هشدار میدهد
|
||||
* («Attempted to synchronously unmount a root while React was already
|
||||
* rendering»). پس به میکروتسک بعد از پایان کامیت موکول میشود؛ المان
|
||||
* نشانگر تا آن لحظه از DOM جدا شده و unmount روی ظرف جدا امن است.
|
||||
*/
|
||||
queueMicrotask(() => pin.root.unmount())
|
||||
}
|
||||
pinsRef.current = []
|
||||
}
|
||||
}, [map, projects, openProject])
|
||||
|
||||
/** با تغییر پروژهی فعال، فقط هالهی سنجاقها بهروز میشود (بازسازی نمیشوند) */
|
||||
useEffect(() => {
|
||||
for (const pin of pinsRef.current) {
|
||||
pin.root.render(
|
||||
<ProjectPin
|
||||
label={pin.project.name}
|
||||
active={activeProject?.id === pin.project.id}
|
||||
onClick={() => openProject(pin.project)}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}, [activeProject, openProject])
|
||||
|
||||
return { popupHost, activeProject, openProject, closePopup }
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { ButtonLink } from '../components/Button';
|
||||
import { PremiumButton } from '../components/PremiumButton';
|
||||
import { ArticleCard } from '../components/ArticleCard';
|
||||
import { ArticleMeta } from '../components/ArticleMeta';
|
||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||
@@ -74,10 +74,9 @@ export function ArticleDetail() {
|
||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
|
||||
<ButtonLink to={href('/blog')} variant="copper" className="mt-8">
|
||||
<PremiumButton to={href('/blog')} className="mt-8">
|
||||
{t('backToBlog')}
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
@@ -14,25 +14,8 @@ export function ContactPage() {
|
||||
<>
|
||||
<PageHero pageKey="contact" />
|
||||
|
||||
<section className="bg-ink py-24" aria-label={t('officesAria')}>
|
||||
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
|
||||
{offices.map((office, i) => (
|
||||
<Reveal key={office.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="offices" item={office} className="h-full">
|
||||
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
||||
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
|
||||
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
|
||||
<div className="mt-auto pt-10">
|
||||
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
|
||||
{office.hours}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="offices" />
|
||||
</div>
|
||||
<section className="bg-ink py-2" aria-label={t('officesAria')}>
|
||||
|
||||
</section>
|
||||
|
||||
<ContactSection />
|
||||
|
||||
@@ -19,8 +19,8 @@ export function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<Story />
|
||||
<Stats />
|
||||
<Story />
|
||||
<Services />
|
||||
<BeforeAfter />
|
||||
<FeaturedProjects />
|
||||
|
||||
@@ -1,74 +1,98 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { Reveal } from '../components/Reveal'
|
||||
import { ButtonLink } from '../components/Button'
|
||||
import { PremiumButton } from '../components/PremiumButton'
|
||||
import { ProjectCard } from '../components/ProjectCard'
|
||||
import { ContactSection } from '../components/ContactSection'
|
||||
import { Lightbox } from '../components/Lightbox'
|
||||
import { useEffect } from 'react';
|
||||
import { LazyProjectsMapSingle } from '../map/LazyMaps';
|
||||
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { applySeo } from '../seo';
|
||||
import { projectsData } from '../data/projectsData';
|
||||
|
||||
export function ProjectDetail() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const { projects, brand } = useSite()
|
||||
const { t, href, lang } = useLang()
|
||||
const project = projects.find((p) => p.slug === slug)
|
||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||
|
||||
const currentProject = projectsData.find((p) => p.slug === slug)
|
||||
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
|
||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
applySeo({
|
||||
title: `${project.title} | ${brand.name}`,
|
||||
description: project.summary,
|
||||
path: href(`/projects/${project.slug}`),
|
||||
image: project.image,
|
||||
lang,
|
||||
});
|
||||
}, [project?.id, lang, href]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
{t('projectNotFoundText')}
|
||||
</p>
|
||||
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
|
||||
{t('backToProjects')}
|
||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
||||
</ButtonLink>
|
||||
</section>
|
||||
)
|
||||
if (!currentProject) {
|
||||
return <div className="text-center py-20 text-ivory">Project not found</div>
|
||||
}
|
||||
|
||||
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
|
||||
const backendProject = projects.find((p) => p.slug === slug)
|
||||
|
||||
// Dynamic bilingual text from projectsData
|
||||
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
|
||||
const displayLocation = currentProject.location[lang as 'en' | 'fa'] || currentProject.location.en;
|
||||
const displayArea = currentProject.area[lang as 'en' | 'fa'] || currentProject.area.en;
|
||||
const displayStatus = currentProject.status;
|
||||
|
||||
const statusLabel = useStatusLabel(displayStatus)
|
||||
|
||||
// Fallbacks for layout structure fields missing in projectsData
|
||||
const summary = backendProject?.summary || (lang === 'en' ? 'A modern and luxurious architectural project built with high-quality materials and smart design.' : 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ.');
|
||||
const body = backendProject?.body || (lang === 'en'
|
||||
? ['Located in the heart of the city, this project offers unparalleled access to major urban hubs.', 'It features state-of-the-art facilities, green spaces, and a sustainable approach to modern living.']
|
||||
: ['لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است.', 'چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.']);
|
||||
const gallery = backendProject?.gallery?.length ? backendProject.gallery : [
|
||||
'/seed-media/2026/09/1.png',
|
||||
'/seed-media/2026/09/2.png',
|
||||
'/seed-media/2026/09/3.png',
|
||||
'/seed-media/2026/09/4.png'
|
||||
];
|
||||
const image = backendProject?.image || '/seed-media/2026/09/1.png';
|
||||
const year = backendProject?.year || (lang === 'en' ? '2024' : '1402');
|
||||
const floors = backendProject?.floors || '5';
|
||||
const units = backendProject?.units || '20';
|
||||
const client = backendProject?.client || (lang === 'en' ? 'Aramesh Group' : 'گروه آرامش');
|
||||
|
||||
// تصاویر پاپآپ نقشه
|
||||
const mapImages = useMemo(
|
||||
() => [image, ...gallery].filter(Boolean),
|
||||
[image, gallery]
|
||||
)
|
||||
|
||||
// مختصات همین پروژه
|
||||
const mapLocation = useMemo(
|
||||
() => findMapProjectByName(displayTitle) ?? createPlaceholderProject(displayTitle, displayLocation),
|
||||
[displayTitle, displayLocation]
|
||||
)
|
||||
|
||||
// SEO
|
||||
useEffect(() => {
|
||||
applySeo({
|
||||
title: `${displayTitle} | ${brand.name}`,
|
||||
description: summary,
|
||||
path: href(`/projects/${currentProject.slug}`),
|
||||
image: image,
|
||||
lang,
|
||||
});
|
||||
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]);
|
||||
|
||||
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3)
|
||||
|
||||
const facts = [
|
||||
{ label: t('specLocation'), value: project.location },
|
||||
{ label: t('specYear'), value: project.year },
|
||||
{ label: t('specArea'), value: project.area },
|
||||
{ label: t('specFloors'), value: project.floors },
|
||||
{ label: t('specUnits'), value: project.units },
|
||||
{ label: t('specClient'), value: project.client },
|
||||
{ label: t('specLocation'), value: displayLocation },
|
||||
{ label: t('specYear'), value: year },
|
||||
{ label: t('specArea'), value: displayArea },
|
||||
{ label: t('specFloors'), value: floors },
|
||||
{ label: t('specUnits'), value: units },
|
||||
{ label: t('specClient'), value: client },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
||||
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}>
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
|
||||
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
@@ -77,75 +101,80 @@ export function ProjectDetail() {
|
||||
{t('projects')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<span className="text-copper">{project.title}</span>
|
||||
<span className="text-copper">{displayTitle}</span>
|
||||
</nav>
|
||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
||||
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
|
||||
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
||||
|
||||
<Reveal className="order-1">
|
||||
<span className="flex items-center gap-3 text-sm font-light text-copper">
|
||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
|
||||
</div>
|
||||
|
||||
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]">
|
||||
|
||||
{project.body.map((para) => (
|
||||
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
|
||||
{para}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
|
||||
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
|
||||
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-10">
|
||||
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
|
||||
{t('similarProjectCta')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="order-2">
|
||||
<button
|
||||
{/* Main large image */}
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-2 lg:col-span-1 h-[250px] md:h-[400px] lg:h-full relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block"
|
||||
onClick={() => setLightbox(0)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
>
|
||||
<img
|
||||
src={project.gallery[0] || project.image}
|
||||
alt=""
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
||||
</button>
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
{project.gallery.slice(1).map((img, i) => (
|
||||
<button
|
||||
key={img}
|
||||
|
||||
{/* 2x2 Small images */}
|
||||
<div className="col-span-2 lg:col-span-1 grid grid-cols-2 gap-2 md:gap-3 lg:grid-rows-2 lg:h-full">
|
||||
{mapImages.slice(1, 5).map((img, i) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
className={`relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block h-[120px] md:h-[200px] lg:h-full ${i >= 2 ? 'hidden md:block' : ''}`}
|
||||
onClick={() => setLightbox(i + 1)}
|
||||
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Smart Content Layout */}
|
||||
<div className="max-w-4xl mx-auto mt-16 lg:mt-24 flex flex-col gap-16 lg:gap-20">
|
||||
|
||||
{/* Description */}
|
||||
<Reveal>
|
||||
<span className="flex items-center gap-3 text-sm font-light text-copper mb-6">
|
||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
|
||||
|
||||
<p className="mt-8 text-xl font-medium leading-9 text-ivory/90">{summary}</p>
|
||||
{body.map((para) => (
|
||||
<p key={para} className="mt-6 text-base font-light leading-9 text-ivory/70">
|
||||
{para}
|
||||
</p>
|
||||
))}
|
||||
</Reveal>
|
||||
|
||||
{/* Specifications Table - Perfectly center-aligned */}
|
||||
<Reveal>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-px border border-ivory/10 bg-ivory/10 rounded-xl overflow-hidden">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label} className="bg-ink p-6 md:p-8 flex flex-col items-center justify-center text-center">
|
||||
<dt className="text-xs font-light text-ivory/50 mb-2">{f.label}</dt>
|
||||
<dd className="text-base font-bold text-ivory">{f.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Reveal>
|
||||
|
||||
{/* Map (بدون Reveal) */}
|
||||
{mapLocation && (
|
||||
<div className="h-80 md:h-[400px] rounded-xl overflow-hidden border border-white/10 shadow-2xl">
|
||||
<LazyProjectsMapSingle location={mapLocation} images={mapImages} className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -154,17 +183,24 @@ export function ProjectDetail() {
|
||||
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{related.map((item, i) => (
|
||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||
<ProjectCard project={item} />
|
||||
</Reveal>
|
||||
))}
|
||||
{related.map((item, i) => {
|
||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||
return (
|
||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||
<ProjectCard
|
||||
project={item}
|
||||
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
|
||||
detailHref={href(`/projects/${item.slug}`)}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Lightbox
|
||||
images={project.gallery}
|
||||
images={gallery}
|
||||
index={lightbox}
|
||||
onClose={() => setLightbox(null)}
|
||||
onChange={setLightbox}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { PageHero } from '../components/PageHero';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { ProjectCard } from '../components/ProjectCard';
|
||||
import { PremiumButton } from '../components/PremiumButton';
|
||||
import { Gallery } from '../components/home/Gallery';
|
||||
import { ContactSection } from '../components/ContactSection';
|
||||
import type { ProjectStatus } from '../data/site';
|
||||
import { useSite, useStatusLabel } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../admin/Editable';
|
||||
import { projectsData } from '../data/projectsData';
|
||||
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
||||
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
||||
|
||||
export function Projects() {
|
||||
const { projects, status_labels } = useSite();
|
||||
const { status_labels } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -27,27 +29,79 @@ export function Projects() {
|
||||
|
||||
const doneLabel = useStatusLabel('done');
|
||||
const progressLabel = useStatusLabel('progress');
|
||||
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
||||
const FILTERS: { key: 'done' | 'progress'; label: string; }[] = [
|
||||
{ key: 'done', label: doneLabel },
|
||||
{ key: 'progress', label: progressLabel }
|
||||
];
|
||||
const [status, setStatus] = useState<ProjectStatus>('done');
|
||||
const list = projects.filter((p) => p.status === status);
|
||||
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
||||
|
||||
// Filtering and Pagination State
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const itemsPerPage = 12;
|
||||
|
||||
// Filter projects by status
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projectsData.filter((p) => p.status === status);
|
||||
}, [status]);
|
||||
|
||||
// Currently displayed projects
|
||||
const displayedProjects = useMemo(() => {
|
||||
return filteredProjects.slice(0, page * itemsPerPage);
|
||||
}, [filteredProjects, page]);
|
||||
|
||||
// Handle Tab Switch
|
||||
const handleTabSwitch = (newStatus: 'done' | 'progress') => {
|
||||
setStatus(newStatus);
|
||||
setPage(1); // Reset pagination on tab switch
|
||||
};
|
||||
|
||||
// Handle Load More
|
||||
const handleLoadMore = () => {
|
||||
setIsLoading(true);
|
||||
// Brief loading state
|
||||
setTimeout(() => {
|
||||
setPage((prev) => prev + 1);
|
||||
setIsLoading(false);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||
|
||||
// Resolve a project href from a MapProject entry by matching against projectsData slugs
|
||||
const resolveProjectHref = (mapProject: MapProject): string | undefined => {
|
||||
const match = projectsData.find(
|
||||
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name
|
||||
);
|
||||
return match ? `/projects/${match.slug}` : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero pageKey="projects" />
|
||||
|
||||
|
||||
{/* نقشهٔ محدودشده — همهٔ پروژهها با سنجاق مسی */}
|
||||
<section className="bg-char py-10" aria-label={t('mapAria')}>
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
<div className="w-full max-w-full overflow-hidden rounded-2xl border border-white/10">
|
||||
<LazyProjectsMapAll
|
||||
projects={MAP_PROJECTS}
|
||||
resolveProjectHref={resolveProjectHref}
|
||||
className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
|
||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||
|
||||
{/* دکمههای فیلتر مینیمال و شیشهای (وسطچین و بدون کادر) */}
|
||||
{/* دکمههای فیلتر */}
|
||||
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||
{FILTERS.map((filter, index) => {
|
||||
const isSelected = status === filter.key;
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
@@ -56,7 +110,7 @@ export function Projects() {
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setStatus(filter.key)}
|
||||
onClick={() => handleTabSwitch(filter.key)}
|
||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
||||
isSelected
|
||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||
@@ -76,15 +130,34 @@ export function Projects() {
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||
{list.map((project, i) => (
|
||||
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} imageClass="h-80" />
|
||||
</EditableItem>
|
||||
</Reveal>
|
||||
))}
|
||||
<AddItemButton resource="projects" />
|
||||
{displayedProjects.map((project, index) => {
|
||||
const dummyImageName = `${(index % 6) + 1}.png`;
|
||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||
|
||||
return (
|
||||
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
||||
<ProjectCard
|
||||
project={project}
|
||||
imageUrl={imageUrl}
|
||||
detailHref={`/projects/${project.slug}`}
|
||||
/>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{displayedProjects.length < filteredProjects.length && (
|
||||
<div className="mt-16 flex justify-center">
|
||||
<PremiumButton
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoading}
|
||||
className="px-10 py-4 w-48"
|
||||
>
|
||||
{isLoading ? 'در حال بارگذاری...' : 'مشاهده بیشتر'}
|
||||
</PremiumButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -92,4 +165,4 @@ export function Projects() {
|
||||
<ContactSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const fs = require('fs');
|
||||
const file = fs.readFileSync('src/data/projectsData.ts', 'utf8');
|
||||
const lines = file.split('\n');
|
||||
const newLines = [];
|
||||
let insideArray = false;
|
||||
for (const line of lines) {
|
||||
if (line.includes('export const projectsData')) {
|
||||
insideArray = true;
|
||||
newLines.push(line);
|
||||
continue;
|
||||
}
|
||||
if (insideArray && line.includes('{ id:')) {
|
||||
const match = line.match(/{ id: '(\d+)', name: '(.*?)', status: '(.*?)' }/);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
const nameFa = match[2];
|
||||
const status = match[3];
|
||||
const nameEn = 'Project ' + id;
|
||||
const slug = 'project-' + id;
|
||||
newLines.push(` { id: '${id}', slug: '${slug}', name: { en: '${nameEn}', fa: '${nameFa}' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: '${status}' },`);
|
||||
} else {
|
||||
newLines.push(line);
|
||||
}
|
||||
} else {
|
||||
if (line.includes('export type StaticProject') || line.includes('id: string;') || line.includes('name: string;') || line.includes('status:') || line === '};') {
|
||||
continue;
|
||||
}
|
||||
newLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const header = `export type BilingualText = { en: string; fa: string };
|
||||
|
||||
export type StaticProject = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: BilingualText;
|
||||
location: BilingualText;
|
||||
area: BilingualText;
|
||||
status: 'done' | 'progress';
|
||||
};
|
||||
|
||||
`;
|
||||
|
||||
fs.writeFileSync('src/data/projectsData.ts', header + newLines.join('\n'));
|
||||
@@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// وبورکر مپلیبر از node_modules ساخته و بهعنوان دارایی خروجی منتشر میشود؛
|
||||
// در src/map/mapWorker.ts با setWorkerUrl به آن اشاره میکنیم (نسخهی
|
||||
// پیشفرض مپلیبر کنار باندل است و در خروجی ویت وجود ندارد).
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
server: {
|
||||
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||
|
||||