Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853ad67db5 | ||
|
|
805f4594d4 | ||
|
|
7a0defc3d4 | ||
|
|
fe6957bf83 | ||
|
|
049291c109 |
@@ -3,20 +3,37 @@
|
|||||||
|
|
||||||
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
|
||||||
|
|
||||||
## Typography
|
## Typography & Smart Bidi Text
|
||||||
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
* **Dynamic TOC & Layout**:
|
||||||
|
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
|
||||||
|
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
|
||||||
|
|
||||||
## CMS, SEO & Editor Features
|
## CMS, SEO & Editor Features
|
||||||
|
|
||||||
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities:
|
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
|
||||||
|
|
||||||
* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility.
|
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
|
||||||
* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags.
|
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
|
||||||
* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title.
|
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
|
||||||
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
|
||||||
* **Custom Tiptap WYSIWYG Editor:**
|
* **Custom Tiptap WYSIWYG Editor:**
|
||||||
* Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px).
|
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
|
||||||
* A custom feature to wrap English text blocks in LTR styling (`<span dir="ltr" class="english-text">` at 12px) for perfect bi-directional text alignment.
|
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
|
||||||
|
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
|
||||||
* **Full Jalali (Shamsi) Calendar Integration:**
|
* **Full Jalali (Shamsi) Calendar Integration:**
|
||||||
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
|
||||||
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
|
||||||
@@ -50,4 +67,10 @@ npm run build
|
|||||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
* `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.
|
* `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,8 +17,12 @@
|
|||||||
"@tiptap/static-renderer": "^3.31.3",
|
"@tiptap/static-renderer": "^3.31.3",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
|
"maplibre-gl": "^6.10.0",
|
||||||
|
"pmtiles": "^4.5.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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-multi-date-picker": "^4.5.2",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
@@ -1040,6 +1044,92 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
@@ -1972,6 +2062,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -2449,6 +2545,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -2782,6 +2887,12 @@
|
|||||||
"csstype": "^3.0.2"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.407",
|
"version": "1.5.407",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||||
@@ -3156,6 +3267,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -3309,6 +3426,12 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "7.2.3",
|
"version": "7.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||||
@@ -3636,6 +3759,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -3649,6 +3778,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -3752,6 +3887,39 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/memoize-one": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
@@ -3795,6 +3963,15 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/motion-dom": {
|
||||||
"version": "11.18.1",
|
"version": "11.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||||
@@ -3816,6 +3993,12 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -4041,6 +4224,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -4080,6 +4275,15 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
@@ -4252,6 +4456,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -4412,6 +4622,12 @@
|
|||||||
"prosemirror-transform": "^1.1.0"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -4443,6 +4659,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -4484,6 +4706,31 @@
|
|||||||
"react-dom": ">=16.8.0"
|
"react-dom": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.88.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
|
||||||
|
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -4606,6 +4853,15 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -4636,6 +4892,15 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -5029,6 +5294,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx vite",
|
"dev": "npx vite",
|
||||||
"build": "npx vite build",
|
"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",
|
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||||
"preview": "npx vite preview"
|
"preview": "npx vite preview"
|
||||||
},
|
},
|
||||||
@@ -19,8 +20,12 @@
|
|||||||
"@tiptap/static-renderer": "^3.31.3",
|
"@tiptap/static-renderer": "^3.31.3",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
|
"maplibre-gl": "^6.10.0",
|
||||||
|
"pmtiles": "^4.5.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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-multi-date-picker": "^4.5.2",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"react-select": "^5.10.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 |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 283 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
|
||||||
|
})
|
||||||
@@ -115,8 +115,10 @@ function AppShell() {
|
|||||||
const { data, error, reload } = useSiteData();
|
const { data, error, reload } = useSiteData();
|
||||||
const { t, dir, lang } = useLang();
|
const { t, dir, lang } = useLang();
|
||||||
|
|
||||||
|
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||||
|
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||||
return (
|
return (
|
||||||
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
|
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||||
<Preloader />
|
<Preloader />
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
|
|||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
import { useSiteData } 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'
|
import { ENTITIES, isTranslatable } from './entities'
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||||
@@ -129,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => document.removeEventListener('click', onClick, true)
|
return () => document.removeEventListener('click', onClick, true)
|
||||||
}, [isEditMode])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (saveStatus !== 'saving') return
|
if (saveStatus !== 'saving') return
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Controller, useController, useForm } from 'react-hook-form'
|
||||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
@@ -9,8 +10,12 @@ import { ArticleEditor } from '../components/admin/ArticleEditor'
|
|||||||
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
import { SeoChecklist } from '../components/admin/SeoChecklist'
|
||||||
import { ImageField } from '../components/admin/ImageField'
|
import { ImageField } from '../components/admin/ImageField'
|
||||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||||
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||||
|
import type { CategoryOption } from '../components/admin/LuxeSelect'
|
||||||
import { ARTICLE_CATEGORIES } from './entities'
|
import { ARTICLE_CATEGORIES } from './entities'
|
||||||
|
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||||
|
import { formatReadingTime } from '../utils/readingTime'
|
||||||
|
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||||
import type { Article, ArticleBodyDoc } from '../data/site'
|
import type { Article, ArticleBodyDoc } from '../data/site'
|
||||||
|
|
||||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||||
@@ -18,6 +23,30 @@ const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown a
|
|||||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||||
|
* عنوان (مبنای Alt پویا)، دستهبندی و متادیتای تصویر شاخص.
|
||||||
|
* `category` در فرم یک شیء تکمقداری {value,label} یا null است
|
||||||
|
* که هنگام ذخیره به رشته (ستون category در بکاند) نگاشت میشود.
|
||||||
|
*/
|
||||||
|
type ArticleMetaForm = {
|
||||||
|
title: string
|
||||||
|
category: CategoryOption | null
|
||||||
|
image_alt: string
|
||||||
|
image_title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مقدار رشتهای ذخیرهشده → گزینه react-select (یا null وقتی خالی است) */
|
||||||
|
function toCategoryOption(value: string): CategoryOption | null {
|
||||||
|
const category = (value ?? '').trim()
|
||||||
|
return category ? { label: category, value: category } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گزینه react-select → مقدار رشتهای ذخیرهسازی در بکاند */
|
||||||
|
function categoryValue(option: CategoryOption | null): string {
|
||||||
|
return option?.value.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
/** دادههای قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
|
||||||
function normalizeTags(value: unknown): string[] {
|
function normalizeTags(value: unknown): string[] {
|
||||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
|
||||||
@@ -57,6 +86,7 @@ export function ArticleWritePage() {
|
|||||||
tags: [],
|
tags: [],
|
||||||
image: '',
|
image: '',
|
||||||
image_alt: '',
|
image_alt: '',
|
||||||
|
image_title: '',
|
||||||
author: '',
|
author: '',
|
||||||
meta_description: '',
|
meta_description: '',
|
||||||
read: '۵ دقیقه',
|
read: '۵ دقیقه',
|
||||||
@@ -68,7 +98,31 @@ export function ArticleWritePage() {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
seo_keyphrase: '',
|
seo_keyphrase: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
|
||||||
|
* هر تغییر، هم فرم و هم draft (مبنای ذخیرهسازی خودکار) را بهروز میکند.
|
||||||
|
*/
|
||||||
|
const { control, watch, reset } = useForm<ArticleMetaForm>({
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
|
||||||
|
image_alt: '',
|
||||||
|
image_title: '',
|
||||||
|
},
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
|
||||||
|
const watchedTitle = watch('title')
|
||||||
|
const { field: imageAltField } = useController({ name: 'image_alt', control })
|
||||||
|
const { field: imageTitleField } = useController({ name: 'image_title', control })
|
||||||
|
|
||||||
const [createdId, setCreatedId] = useState<number | null>(null)
|
const [createdId, setCreatedId] = useState<number | null>(null)
|
||||||
|
/**
|
||||||
|
* شناسه محتوای بارگذاریشده — با رسیدن مقاله از سرور عوض میشود تا
|
||||||
|
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
|
||||||
|
*/
|
||||||
|
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const draftRef = useRef(draft)
|
const draftRef = useRef(draft)
|
||||||
draftRef.current = draft
|
draftRef.current = draft
|
||||||
@@ -103,13 +157,23 @@ export function ArticleWritePage() {
|
|||||||
setDraft({
|
setDraft({
|
||||||
...(article as unknown as Draft),
|
...(article as unknown as Draft),
|
||||||
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||||||
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
|
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||||
|
body: toArticleBodyDoc(article.body),
|
||||||
body_en:
|
body_en:
|
||||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||||
? (rawEnBody as ArticleBodyDoc)
|
? (rawEnBody as ArticleBodyDoc)
|
||||||
: EMPTY_DOC,
|
: EMPTY_DOC,
|
||||||
})
|
})
|
||||||
setCreatedId(article.id)
|
setCreatedId(article.id)
|
||||||
|
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||||
|
setLoadedContentKey(article.id)
|
||||||
|
// فرم متادیتا هم با مقادیر مقاله بارگذاریشده همگام میشود
|
||||||
|
reset({
|
||||||
|
title: article.title ?? '',
|
||||||
|
category: toCategoryOption(article.category ?? ''),
|
||||||
|
image_alt: article.image_alt ?? '',
|
||||||
|
image_title: article.image_title ?? '',
|
||||||
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [article?.id])
|
}, [article?.id])
|
||||||
|
|
||||||
@@ -209,6 +273,11 @@ export function ArticleWritePage() {
|
|||||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||||
|
|
||||||
|
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||||
|
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' - ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
|
||||||
{/* نوار بالا */}
|
{/* نوار بالا */}
|
||||||
@@ -301,48 +370,64 @@ export function ArticleWritePage() {
|
|||||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جاینگهدار میماند */}
|
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||||||
<input
|
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||||||
type="text"
|
<Controller
|
||||||
{...textField(suf('title'))}
|
name="title"
|
||||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
control={control}
|
||||||
aria-label="عنوان مقاله"
|
render={({ field }) => (
|
||||||
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
<input
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value)
|
||||||
|
queueSave({ [suf('title')]: event.target.value })
|
||||||
|
}}
|
||||||
|
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||||
|
aria-label="عنوان مقاله"
|
||||||
|
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="mt-4 h-px w-28 bg-copper" />
|
<div className="mt-4 h-px w-28 bg-copper" />
|
||||||
|
|
||||||
{/* تصویر شاخص + متن جایگزین */}
|
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصلاند) */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ImageField
|
<ImageField
|
||||||
label="تصویر شاخص مقاله"
|
label="تصویر شاخص مقاله"
|
||||||
value={draft.image}
|
value={draft.image}
|
||||||
alt={draft.image_alt ?? ''}
|
alt={imageAltField.value}
|
||||||
altFallback={draft.title}
|
altFallback={imageAltFallback}
|
||||||
|
title={imageTitleField.value}
|
||||||
onSave={async (url) => {
|
onSave={async (url) => {
|
||||||
queueSave({ image: url })
|
queueSave({ image: url })
|
||||||
return true
|
return true
|
||||||
}}
|
}}
|
||||||
onSaveAlt={async (alt) => {
|
onSaveAlt={async (alt) => {
|
||||||
|
imageAltField.onChange(alt)
|
||||||
queueSave({ image_alt: alt })
|
queueSave({ image_alt: alt })
|
||||||
return true
|
return true
|
||||||
}}
|
}}
|
||||||
|
onSaveTitle={async (imageTitle) => {
|
||||||
|
imageTitleField.onChange(imageTitle)
|
||||||
|
queueSave({ image_title: imageTitle })
|
||||||
|
return true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ArticleEditor
|
<ArticleEditor
|
||||||
|
contentKey={loadedContentKey}
|
||||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||||
onChange={(json) => {
|
onChange={(json) => {
|
||||||
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن — به زبان جاری
|
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||||
const textContent = JSON.stringify(json)
|
const minutes = formatReadingTime(json)
|
||||||
const wordCount = textContent.split(/\s+/).length
|
|
||||||
if (editLang === 'en') {
|
if (editLang === 'en') {
|
||||||
const minutes = Math.max(1, Math.ceil(wordCount / 200))
|
|
||||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||||
} else {
|
} else {
|
||||||
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
|
queueSave({ body: json, read: `${minutes} دقیقه` })
|
||||||
queueSave({ body: json, read: calculatedReadTime })
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -424,11 +509,20 @@ export function ArticleWritePage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
<label className="text-xs font-light text-ivory/50">دستهبندی مقاله (Category)</label>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<LuxeSelect
|
{/* تکانتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
|
||||||
value={draft.category}
|
<Controller
|
||||||
options={ARTICLE_CATEGORIES}
|
name="category"
|
||||||
onChange={(category) => queueSave({ category })}
|
control={control}
|
||||||
isClearable={false}
|
render={({ field }) => (
|
||||||
|
<CategorySelect
|
||||||
|
value={field.value}
|
||||||
|
options={ARTICLE_CATEGORIES}
|
||||||
|
onChange={(option) => {
|
||||||
|
field.onChange(option)
|
||||||
|
queueSave({ category: categoryValue(option) })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { useSiteData } from '../api'
|
|||||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||||
import { adminApi } from './adminClient'
|
import { adminApi } from './adminClient'
|
||||||
import { editableFields } from './entities'
|
import { editableFields } from './entities'
|
||||||
|
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||||
import type { EntityConfig, FieldConfig } from './entities'
|
import type { EntityConfig, FieldConfig } from './entities'
|
||||||
import { ImageField } from '../components/admin/ImageField'
|
import { ImageField } from '../components/admin/ImageField'
|
||||||
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
|
||||||
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
|
||||||
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
import { ArticleEditor } from '../components/admin/ArticleEditor'
|
||||||
|
|
||||||
type EntityDrawerProps = {
|
type EntityDrawerProps = {
|
||||||
@@ -120,13 +121,21 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
|||||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
{fields.map((field) => {
|
{fields.map((field) => {
|
||||||
|
const isImage = field.type === 'image'
|
||||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||||
const altKey =
|
const altKey = isImage
|
||||||
field.type === 'image'
|
? field.name === 'src' && 'alt' in item
|
||||||
? field.name === 'src' && 'alt' in item
|
? 'alt'
|
||||||
? 'alt'
|
: `${field.name}_alt`
|
||||||
: `${field.name}_alt`
|
: ''
|
||||||
: ''
|
const imageTitleKey = isImage ? `${field.name}_title` : ''
|
||||||
|
// پیشنهاد Alt از عنوان رکورد ساخته میشود (هرگز از URL)
|
||||||
|
const imageAltFallback = [
|
||||||
|
String(item[config.titleKey] ?? '').trim(),
|
||||||
|
field.altFallbackSuffix,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' - ')
|
||||||
return (
|
return (
|
||||||
<FieldControl
|
<FieldControl
|
||||||
key={field.name}
|
key={field.name}
|
||||||
@@ -134,9 +143,13 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
|||||||
value={item[field.name]}
|
value={item[field.name]}
|
||||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||||
altFallback={String(item[config.titleKey] ?? '')}
|
altFallback={imageAltFallback}
|
||||||
|
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||||
onSave={(value) => saveField(field.name, value)}
|
onSave={(value) => saveField(field.name, value)}
|
||||||
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
|
||||||
|
onSaveImageTitle={
|
||||||
|
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
|
||||||
|
}
|
||||||
registerFlush={
|
registerFlush={
|
||||||
field.type === 'wysiwyg'
|
field.type === 'wysiwyg'
|
||||||
? (fn) => {
|
? (fn) => {
|
||||||
@@ -174,8 +187,10 @@ function FieldControl({
|
|||||||
altValue,
|
altValue,
|
||||||
altFallback,
|
altFallback,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
imageTitleValue,
|
||||||
onSave,
|
onSave,
|
||||||
onSaveAlt,
|
onSaveAlt,
|
||||||
|
onSaveImageTitle,
|
||||||
registerFlush,
|
registerFlush,
|
||||||
}: {
|
}: {
|
||||||
field: FieldConfig
|
field: FieldConfig
|
||||||
@@ -185,12 +200,20 @@ function FieldControl({
|
|||||||
altFallback?: string
|
altFallback?: string
|
||||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||||
placeholder?: unknown
|
placeholder?: unknown
|
||||||
|
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||||
|
imageTitleValue?: string
|
||||||
onSave: (value: unknown) => Promise<boolean>
|
onSave: (value: unknown) => Promise<boolean>
|
||||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||||
|
onSaveImageTitle?: (title: string) => Promise<boolean>
|
||||||
registerFlush?: (fn: () => void) => void
|
registerFlush?: (fn: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
const [text, setText] = useState<string>(String(value ?? ''))
|
const [text, setText] = useState<string>(String(value ?? ''))
|
||||||
|
|
||||||
|
// همگامسازی مقدار محلی با دادهای که از بیرون میآید (بارگذاری/بهروزرسانی خوشبینانه)
|
||||||
|
useEffect(() => {
|
||||||
|
setText(String(value ?? ''))
|
||||||
|
}, [value])
|
||||||
|
|
||||||
const commitText = async () => {
|
const commitText = async () => {
|
||||||
const next = field.type === 'number' ? Number(text) || 0 : text
|
const next = field.type === 'number' ? Number(text) || 0 : text
|
||||||
if (String(value ?? '') === String(next)) return
|
if (String(value ?? '') === String(next)) return
|
||||||
@@ -204,26 +227,34 @@ function FieldControl({
|
|||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
alt={altValue}
|
alt={altValue}
|
||||||
altFallback={altFallback}
|
altFallback={altFallback}
|
||||||
|
title={imageTitleValue}
|
||||||
onSave={(url) => onSave(url)}
|
onSave={(url) => onSave(url)}
|
||||||
onSaveAlt={onSaveAlt}
|
onSaveAlt={onSaveAlt}
|
||||||
|
onSaveTitle={onSaveImageTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'select') {
|
if (field.type === 'select') {
|
||||||
|
// مقدار نمایشی از وضعیت محلی میآید تا افزودن/حذف گزینه فوراً دیده شود
|
||||||
|
const selectProps = {
|
||||||
|
value: text,
|
||||||
|
options: field.options ?? [],
|
||||||
|
onChange: (next: string) => {
|
||||||
|
setText(next)
|
||||||
|
void onSave(next)
|
||||||
|
},
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
|
||||||
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<LuxeSelect
|
{field.creatable ? (
|
||||||
value={String(value ?? '')}
|
<LuxeCreatableSelect {...selectProps} />
|
||||||
options={field.options ?? []}
|
) : (
|
||||||
onChange={(next) => {
|
<LuxeSelect {...selectProps} />
|
||||||
setText(next)
|
)}
|
||||||
void onSave(next)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -408,10 +439,9 @@ function WysiwygField({
|
|||||||
registerFlush?: (fn: () => void) => void
|
registerFlush?: (fn: () => void) => void
|
||||||
hint?: string
|
hint?: string
|
||||||
}) {
|
}) {
|
||||||
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
|
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||||
value && typeof value === 'object' && !Array.isArray(value)
|
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||||
? (value as Record<string, unknown>)
|
() => toArticleBodyDoc(value),
|
||||||
: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
||||||
)
|
)
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const pending = useRef<Record<string, unknown> | null>(null)
|
const pending = useRef<Record<string, unknown> | null>(null)
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
|
|||||||
|
|
||||||
const TOKEN_KEY = 'aramesh_admin_token'
|
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 getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||||
export const setToken = (token: string | null): void => {
|
export const setToken = (token: string | null): void => {
|
||||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||||
else localStorage.removeItem(TOKEN_KEY)
|
else localStorage.removeItem(TOKEN_KEY)
|
||||||
|
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
|
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
|
||||||
|
|
||||||
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
|
||||||
|
|
||||||
@@ -9,10 +10,14 @@ export type FieldConfig = {
|
|||||||
hint?: string
|
hint?: string
|
||||||
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
/** فقط برای type === 'select' — گزینههای ثابت react-select */
|
||||||
options?: string[]
|
options?: string[]
|
||||||
|
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
|
||||||
|
creatable?: boolean
|
||||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||||
ui?: 'lines' | 'tags'
|
ui?: 'lines' | 'tags'
|
||||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||||
shared?: boolean
|
shared?: boolean
|
||||||
|
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||||
|
altFallbackSuffix?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EntityConfig = {
|
export type EntityConfig = {
|
||||||
@@ -77,8 +82,8 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
F('body', 'متن مقاله', 'wysiwyg'),
|
F('body', 'متن مقاله', 'wysiwyg'),
|
||||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
||||||
F('image', 'تصویر شاخص', 'image', undefined, true),
|
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||||
F('category', 'دستهبندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES, shared: true }),
|
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||||
F('author', 'نویسنده', 'text', undefined, true),
|
F('author', 'نویسنده', 'text', undefined, true),
|
||||||
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
|
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
|
||||||
@@ -92,6 +97,8 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
|||||||
category: ARTICLE_CATEGORIES[0],
|
category: ARTICLE_CATEGORIES[0],
|
||||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||||
image: '',
|
image: '',
|
||||||
|
image_alt: '',
|
||||||
|
image_title: '',
|
||||||
author: '',
|
author: '',
|
||||||
meta_description: '',
|
meta_description: '',
|
||||||
read: '۵ دقیقه',
|
read: '۵ دقیقه',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Article, Project, Service } from './data/site'
|
import type { Article, Project, Service } from './data/site'
|
||||||
import { API_URL } from './config'
|
import { API_URL } from './config'
|
||||||
import { getToken } from './admin/adminClient'
|
import { getToken, setToken } from './admin/adminClient'
|
||||||
import { localizeSite } from './i18n/localize'
|
import { localizeSite } from './i18n/localize'
|
||||||
import { useLang } from './i18n/lang'
|
import { useLang } from './i18n/lang'
|
||||||
|
|
||||||
@@ -230,10 +230,16 @@ interface SiteContextValue {
|
|||||||
const SiteContext = createContext<SiteContextValue>({
|
const SiteContext = createContext<SiteContextValue>({
|
||||||
data: null,
|
data: null,
|
||||||
error: false,
|
error: false,
|
||||||
|
// پیشفرضهای بیاثر — مصرفکننده بیرون از Provider هم بدون خطا رندر میشود
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
reload: () => {},
|
reload: () => {},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
updateSite: () => {},
|
updateSite: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** سقف انتظار داده سایت — سرور بیپاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بیپایان */
|
||||||
|
const SITE_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||||
const [data, setData] = useState<SiteData | null>(null)
|
const [data, setData] = useState<SiteData | null>(null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
@@ -241,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setError(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) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json() as Promise<SiteData>
|
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`, {
|
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
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 all = (await res.json()) as Article[]
|
||||||
const published = new Set(json.articles.map((a) => a.id))
|
const published = new Set(json.articles.map((a) => a.id))
|
||||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||||
json.articles = [...json.articles, ...drafts]
|
json.articles = [...json.articles, ...drafts]
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* بدون پیشنویسها ادامه بده */
|
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -275,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
|||||||
import type { Article } from '../data/site'
|
import type { Article } from '../data/site'
|
||||||
import { formatJalali } from '../utils/date'
|
import { formatJalali } from '../utils/date'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
|
import { toPersianDigits } from '../utils/digits'
|
||||||
|
|
||||||
export function ArticleCard({ article }: { article: Article }) {
|
export function ArticleCard({ article }: { article: Article }) {
|
||||||
const { t, href } = useLang()
|
const { t, href } = useLang()
|
||||||
@@ -25,28 +26,28 @@ export function ArticleCard({ article }: { article: Article }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-7">
|
<div className="flex flex-1 flex-col p-8">
|
||||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
<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}
|
{article.title}
|
||||||
</h3>
|
</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}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
<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-3">
|
<span className="flex items-center gap-4">
|
||||||
<span>{formatJalali(article.date)}</span>
|
<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" />
|
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||||
{article.read}
|
{toPersianDigits(article.read)}
|
||||||
</span>
|
</span>
|
||||||
</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')}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
import { CalendarIcon, ClockIcon } from 'lucide-react'
|
||||||
import type { Article } from '../data/site'
|
import type { Article } from '../data/site'
|
||||||
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
|
||||||
|
import { toPersianDigits } from '../utils/digits'
|
||||||
|
|
||||||
type ArticleMetaProps = {
|
type ArticleMetaProps = {
|
||||||
article: Article
|
article: Article
|
||||||
@@ -41,11 +42,11 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
|
|||||||
{dateLabel}
|
{dateLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* زمان مطالعه */}
|
{/* زمان مطالعه — با ارقام فارسی */}
|
||||||
{article.read && (
|
{article.read && (
|
||||||
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
|
||||||
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
|
||||||
{article.read}
|
{toPersianDigits(article.read)}
|
||||||
</span>
|
</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 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 Swal from 'sweetalert2'
|
||||||
import { Reveal } from './Reveal'
|
import { Reveal } from './Reveal'
|
||||||
import { Button } from './Button'
|
import { PremiumButton } from './PremiumButton'
|
||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { useFieldSaver } from '../admin/AdminContext'
|
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">
|
<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)} />
|
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<PremiumButton
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-8 font-light"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSent(false)
|
setSent(false)
|
||||||
setForm({ name: '', phone: '', note: '' })
|
setForm({ name: '', phone: '', note: '' })
|
||||||
}}
|
}}
|
||||||
|
className="mt-8 font-light"
|
||||||
>
|
>
|
||||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
</Button>
|
</PremiumButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||||
@@ -253,21 +251,17 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex justify-start">
|
<div className="md:col-span-2 flex justify-start">
|
||||||
<Button
|
<PremiumButton
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="copper"
|
|
||||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
content.sending_label
|
content.sending_label
|
||||||
) : (
|
) : (
|
||||||
<>
|
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||||
<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" />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</PremiumButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { LanguagesIcon } from 'lucide-react'
|
|
||||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
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 انگلیسی همان صفحه جابهجا میشود */
|
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||||
export function LangToggle({ className = '' }: { className?: string }) {
|
export function LangToggle({ className = '' }: { className?: string }) {
|
||||||
const { lang, t } = useLang()
|
const { lang, t } = useLang()
|
||||||
@@ -22,10 +33,61 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
|||||||
onClick={switchLang}
|
onClick={switchLang}
|
||||||
aria-label={t('switchLangAria')}
|
aria-label={t('switchLangAria')}
|
||||||
title={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" />
|
{/* ── Inner Track ───────────────────────────────────────────────
|
||||||
<span>{t('switchLang')}</span>
|
این دیو داخلی باعث میشود پدینگ دکمه اصلی روی محاسبات 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>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect } from 'react'
|
import React, { useCallback, useEffect } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
images: string[]
|
images: string[]
|
||||||
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
|||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
if (e.key === 'ArrowRight') prev()
|
if (e.key === 'ArrowRight') next()
|
||||||
if (e.key === 'ArrowLeft') next()
|
if (e.key === 'ArrowLeft') prev()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
|||||||
<motion.div
|
<motion.div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
>
|
>
|
||||||
|
{/* Solid Brand Background */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
|
className="absolute inset-0 bg-[#27221c]"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Close Button at Top Right */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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>
|
</button>
|
||||||
|
|
||||||
<motion.img
|
{/* Main Content Area */}
|
||||||
key={index}
|
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
|
||||||
src={images[index as number]}
|
|
||||||
alt=""
|
{/* Prev Button */}
|
||||||
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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={prev}
|
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>
|
</button>
|
||||||
<span className="text-sm font-light text-ivory/70" dir="ltr">
|
|
||||||
{(index as number) + 1} / {images.length}
|
{/* Image */}
|
||||||
</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={next}
|
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>
|
</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>
|
</div>
|
||||||
</motion.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 { Link } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { useStatusLabel } from '../api'
|
import { useStatusLabel } from '../api'
|
||||||
import { useLang } from '../i18n/lang'
|
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 isOngoing = project.status === 'progress'
|
||||||
const statusText = useStatusLabel(project.status)
|
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 dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
|
||||||
|
{/* Fixed height image wrapper */}
|
||||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
<div className="relative w-full overflow-hidden">
|
||||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
{finalImageUrl && (
|
||||||
<img
|
<img
|
||||||
src={project.image}
|
src={finalImageUrl}
|
||||||
alt={project.title}
|
alt={title}
|
||||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
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)]' : ''}`} />
|
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||||
{statusText}
|
{statusText}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-7">
|
<div className="flex flex-1 flex-col p-7 pb-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||||
{project.title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
|
||||||
{project.location}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-6 h-px w-full bg-white/10" />
|
{area && (
|
||||||
|
<>
|
||||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
<div className="my-6 h-px w-full bg-white/10" />
|
||||||
<span>{t('yearLabel')} {project.year}</span>
|
<div className="mt-auto flex items-center justify-end text-sm font-light text-ivory/60">
|
||||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* پایهٔ کارت: یک دکمه واحد */}
|
||||||
|
<div className="flex flex-col gap-2 px-7 pb-7">
|
||||||
|
<PremiumButton to={finalDetailHref} className="w-full">
|
||||||
|
{t('viewProject')}
|
||||||
|
</PremiumButton>
|
||||||
|
</div>
|
||||||
</article>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
|
|||||||
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
export type TocItem = { id: string; text: string; level: 2 | 3 }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر میگیرند
|
* متن یک تیتر از HTML — همه گرههای تودرتو پیمایش میشوند و <br> به فاصله تبدیل
|
||||||
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
|
* میشود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
|
||||||
*/
|
*/
|
||||||
export function extractToc(html: string): { items: TocItem[]; html: string } {
|
function headingText(element: Element): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
element.childNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
|
||||||
|
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const child = node as Element
|
||||||
|
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return parts.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
|
||||||
|
* (حتی داخل نقلقول) شناسه لنگر میگیرند؛ متن فارسی در شناسه حفظ میشود و تیترهای
|
||||||
|
* تکراری پسوند عددی میگیرند. HTML برگشتی همان است که باید رندر شود.
|
||||||
|
*/
|
||||||
|
export function extractToc(
|
||||||
|
html: string,
|
||||||
|
transform?: (root: HTMLElement) => void,
|
||||||
|
): { items: TocItem[]; html: string } {
|
||||||
if (!html || typeof window === 'undefined') return { items: [], html }
|
if (!html || typeof window === 'undefined') return { items: [], html }
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
const used = new Map<string, number>()
|
const used = new Map<string, number>()
|
||||||
const items: TocItem[] = []
|
const items: TocItem[] = []
|
||||||
|
|
||||||
doc.body.querySelectorAll('h2, h3').forEach((el) => {
|
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
|
||||||
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
const text = headingText(element).replace(/\s+/g, ' ').trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
const id = uniqueSlug(slugifyFa(text), used)
|
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی میگیرد تا لنگرش بینام نماند
|
||||||
el.setAttribute('id', id)
|
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
|
||||||
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
|
element.setAttribute('id', id)
|
||||||
|
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// تبدیل اختیاری (بستهبندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا میشود
|
||||||
|
transform?.(doc.body)
|
||||||
|
|
||||||
return { items, html: doc.body.innerHTML }
|
return { items, html: doc.body.innerHTML }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
|
/**
|
||||||
|
* فهرست مطالب کلیکپذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
|
||||||
|
* همه اندازهها و ترازبندیها منطقیاند (start/end) تا در چیدمان راستبهچپ و
|
||||||
|
* چپبهراست — بر اساس جهت مقاله — درست بنشیند.
|
||||||
|
*/
|
||||||
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
|
||||||
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
|
||||||
|
|
||||||
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
|||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
|
||||||
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3">
|
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود میشود تا بریده نشود */}
|
||||||
|
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isActive = item.id === activeId
|
const isActive = item.id === activeId
|
||||||
return (
|
return (
|
||||||
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => go(event, item.id)}
|
onClick={(event) => go(event, item.id)}
|
||||||
aria-current={isActive ? 'location' : undefined}
|
aria-current={isActive ? 'location' : undefined}
|
||||||
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
|
||||||
item.level === 3 ? 'pr-4 text-[13px]' : ''
|
item.level === 3 ? 'ps-4 text-[13px]' : ''
|
||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'font-normal text-copper'
|
? 'font-normal text-copper'
|
||||||
|
|||||||
@@ -1,61 +1,107 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Image from '@tiptap/extension-image'
|
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||||
import {
|
import {
|
||||||
|
AlignLeftIcon,
|
||||||
|
AlignRightIcon,
|
||||||
BoldIcon,
|
BoldIcon,
|
||||||
ItalicIcon,
|
|
||||||
Heading1Icon,
|
Heading1Icon,
|
||||||
Heading2Icon,
|
Heading2Icon,
|
||||||
Heading3Icon,
|
Heading3Icon,
|
||||||
ImagePlusIcon,
|
ImagePlusIcon,
|
||||||
LanguagesIcon,
|
ItalicIcon,
|
||||||
Link2Icon,
|
Link2Icon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
ListOrderedIcon,
|
ListOrderedIcon,
|
||||||
|
Loader2Icon,
|
||||||
PilcrowIcon,
|
PilcrowIcon,
|
||||||
QuoteIcon,
|
QuoteIcon,
|
||||||
Redo2Icon,
|
Redo2Icon,
|
||||||
StrikethroughIcon,
|
StrikethroughIcon,
|
||||||
UnderlineIcon,
|
UnderlineIcon,
|
||||||
Undo2Icon,
|
Undo2Icon,
|
||||||
Loader2Icon,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
|
||||||
import { useAdmin } from '../../admin/AdminContext'
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
import { adminApi } from '../../admin/adminClient'
|
import { adminApi } from '../../admin/adminClient'
|
||||||
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
|
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
|
||||||
|
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
|
||||||
|
import { ArticleImage } from './ArticleImage'
|
||||||
|
import { BidiTypography, bidiPluginKey } from './BidiTypography'
|
||||||
|
import { EnglishText } from './EnglishText'
|
||||||
|
import { LinkModal } from './LinkModal'
|
||||||
|
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
|
||||||
|
import type { LinkModalValue } from './LinkModal'
|
||||||
|
|
||||||
const SmallSizeMark = Mark.create({
|
/**
|
||||||
|
* مارکهای قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آنها از نوار ابزار برداشته شده و
|
||||||
|
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شدهاند.
|
||||||
|
*/
|
||||||
|
const LegacySmallSizeMark = Mark.create({
|
||||||
name: 'smallSize',
|
name: 'smallSize',
|
||||||
parseHTML() { return [{ tag: 'span[data-small]' }] },
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-small]' }]
|
||||||
|
},
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** اکستنشنهای مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
|
||||||
export const ARTICLE_EXTENSIONS = [
|
export const ARTICLE_EXTENSIONS = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
link: false,
|
link: false,
|
||||||
heading: { levels: [1, 2, 3] },
|
heading: { levels: [1, 2, 3] },
|
||||||
}),
|
}),
|
||||||
Image.configure({ allowBase64: false }),
|
ArticleImage.configure({ allowBase64: false }),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||||
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
|
isAllowedUri: (uri: string) => isAllowedHref(uri),
|
||||||
}),
|
}),
|
||||||
SmallSizeMark,
|
LegacySmallSizeMark,
|
||||||
EnglishText,
|
EnglishText,
|
||||||
|
BidiTypography,
|
||||||
]
|
]
|
||||||
|
|
||||||
type ArticleEditorProps = {
|
type ArticleEditorProps = {
|
||||||
doc: Record<string, unknown>
|
doc: Record<string, unknown>
|
||||||
onChange: (json: Record<string, unknown>) => void
|
onChange: (json: Record<string, unknown>) => void
|
||||||
|
/**
|
||||||
|
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری میشود
|
||||||
|
* (مثلاً وقتی مقاله از سرور میرسد). بدون این prop محتوا فقط یکبار مقداردهی میشود.
|
||||||
|
*/
|
||||||
|
contentKey?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمیگرداند */
|
||||||
|
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
|
||||||
|
|
||||||
|
type TextDirection = 'rtl' | 'ltr'
|
||||||
|
|
||||||
|
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپشده */
|
||||||
|
type LinkApplyMode = 'selection' | 'insert' | 'range'
|
||||||
|
|
||||||
|
type LinkModalState = {
|
||||||
|
open: boolean
|
||||||
|
editing: boolean
|
||||||
|
mode: LinkApplyMode
|
||||||
|
range: { from: number; to: number } | null
|
||||||
|
initial: LinkModalValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSED_LINK_MODAL: LinkModalState = {
|
||||||
|
open: false,
|
||||||
|
editing: false,
|
||||||
|
mode: 'selection',
|
||||||
|
range: null,
|
||||||
|
initial: { href: '', newTab: true, nofollow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||||
|
const URL_LOOKBACK = 300
|
||||||
|
|
||||||
function ToolButton({
|
function ToolButton({
|
||||||
onClick,
|
onClick,
|
||||||
active = false,
|
active = false,
|
||||||
@@ -72,6 +118,7 @@ function ToolButton({
|
|||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
|
||||||
@@ -87,10 +134,16 @@ function ToolButton({
|
|||||||
|
|
||||||
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
|
||||||
|
|
||||||
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
/**
|
||||||
|
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشندار، مودال لینک سئو-محور،
|
||||||
|
* کلید راستچین/چپچین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
|
||||||
|
*/
|
||||||
|
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
|
||||||
const { runSave } = useAdmin()
|
const { runSave } = useAdmin()
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||||
|
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: ARTICLE_EXTENSIONS,
|
extensions: ARTICLE_EXTENSIONS,
|
||||||
@@ -99,16 +152,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
|||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
dir: 'rtl',
|
dir: 'rtl',
|
||||||
|
lang: 'fa',
|
||||||
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
|
||||||
'data-wysiwyg': 'true',
|
'data-wysiwyg': 'true',
|
||||||
},
|
},
|
||||||
handlePaste: (_event_ignored, event) => {
|
handlePaste: (_view, event, _slice) => {
|
||||||
const file = event.clipboardData?.files?.[0]
|
const file = event.clipboardData?.files?.[0]
|
||||||
if (file && file.type.startsWith('image/')) {
|
if (file && file.type.startsWith('image/')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||||
|
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||||
|
if (isUrlLike(text)) {
|
||||||
|
event.preventDefault()
|
||||||
|
setLinkModal({
|
||||||
|
open: true,
|
||||||
|
editing: false,
|
||||||
|
mode: 'insert',
|
||||||
|
range: null,
|
||||||
|
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
handleDrop: (_view, event, _slice, moved) => {
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
@@ -121,176 +188,245 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||||
|
handleTextInput: (view, from, to, text) => {
|
||||||
|
if (text !== ' ' || from !== to) return false
|
||||||
|
const start = Math.max(0, from - URL_LOOKBACK)
|
||||||
|
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
|
||||||
|
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
|
||||||
|
if (!isUrlLike(lastToken)) return false
|
||||||
|
setLinkModal({
|
||||||
|
open: true,
|
||||||
|
editing: false,
|
||||||
|
mode: 'range',
|
||||||
|
range: { from: from - lastToken.length, to: from },
|
||||||
|
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// Enter داخل تیتر، بلوک تازه را پاراگراف میکند تا قالب تیتر سرایت نکند
|
||||||
|
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// جهت متن فقط روی ناحیه ویرایش اعمال میشود؛ نوار ابزار همیشه راستچین میماند
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
editor.view.dom.setAttribute('dir', direction)
|
||||||
|
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
|
||||||
|
// تزئینهای دوزبانه (اندازه ۲ پیکسل کوچکتر متن لاتین) با جهت تازه بازمحاسبه میشوند
|
||||||
|
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
|
||||||
|
}, [editor, direction])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
|
||||||
|
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
|
||||||
|
*/
|
||||||
|
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
|
||||||
|
|
||||||
|
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساختهشدن نمونه ویرایشگر
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || contentKey === undefined) return
|
||||||
|
const loaded = loadedContentRef.current
|
||||||
|
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
|
||||||
|
const incoming = doc && Object.keys(doc).length ? doc : null
|
||||||
|
if (!incoming) return
|
||||||
|
loadedContentRef.current = { editor, key: contentKey }
|
||||||
|
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بیمورد را فعال نکند
|
||||||
|
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||||
|
}, [editor, contentKey, doc])
|
||||||
|
|
||||||
async function uploadAndInsert(file: File) {
|
async function uploadAndInsert(file: File) {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
|
|
||||||
// استفاده از ریگکس یونیکد اصلاحشده برای نام فایل و Alt
|
|
||||||
const originalName = file.name.replace(/\.[^.]+$/, '')
|
|
||||||
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
|
|
||||||
|
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file)
|
const { url } = await adminApi.upload(file)
|
||||||
editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
|
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||||
|
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
|
||||||
|
.run()
|
||||||
})
|
})
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setLink = () => {
|
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
|
||||||
|
const openLinkModalForSelection = () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
if (editor.isActive('link')) {
|
const attrs = editor.getAttributes('link') as {
|
||||||
editor.chain().focus().unsetLink().run()
|
href?: string | null
|
||||||
return
|
target?: string | null
|
||||||
|
rel?: string | null
|
||||||
}
|
}
|
||||||
void Swal.fire({
|
const editing = Boolean(attrs.href)
|
||||||
title: 'نشانی لینک',
|
setLinkModal({
|
||||||
input: 'text',
|
open: true,
|
||||||
inputValue: 'https://',
|
editing,
|
||||||
background: '#1e1a16',
|
mode: 'selection',
|
||||||
color: '#F5F3EF',
|
range: null,
|
||||||
showCancelButton: true,
|
initial: {
|
||||||
confirmButtonText: 'ثبت',
|
href: attrs.href ?? '',
|
||||||
cancelButtonText: 'انصراف',
|
newTab: editing ? attrs.target === '_blank' : true,
|
||||||
buttonsStyling: false,
|
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
|
||||||
customClass: {
|
|
||||||
popup: 'font-pinar border border-ivory/10',
|
|
||||||
title: 'font-pinar font-bold text-ivory',
|
|
||||||
input: 'font-pinar',
|
|
||||||
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
|
|
||||||
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
|
|
||||||
},
|
},
|
||||||
}).then((result) => {
|
|
||||||
if (!result.isConfirmed || !result.value) return
|
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
|
||||||
|
if (!editor) return
|
||||||
|
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
|
||||||
|
const chain = editor.chain().focus()
|
||||||
|
|
||||||
|
if (linkModal.mode === 'insert') {
|
||||||
|
// نشانی چسباندهشده: متن لینک تازه درج میشود
|
||||||
|
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
|
||||||
|
} else if (linkModal.mode === 'range' && linkModal.range) {
|
||||||
|
// نشانی تایپشده: همان محدوده به لینک تبدیل میشود
|
||||||
|
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
|
||||||
|
} else {
|
||||||
|
chain.extendMarkRange('link').setLink(linkAttrs).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinkModal((state) => ({ ...state, open: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLink = () => {
|
||||||
|
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
setLinkModal((state) => ({ ...state, open: false }))
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor) return null
|
if (!editor) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-ivory/15 bg-char">
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-1 border-b border-ivory/10 px-2 py-1.5">
|
<div className="border border-ivory/15 bg-char" data-dir={direction}>
|
||||||
|
{/* نوار ابزار: همیشه راستچین و در بالای ویرایشگر ثابت میماند */}
|
||||||
{/* قالببندی بلوک — پاراگراف و تیترها با سایز مشخص */}
|
<div
|
||||||
<ToolButton
|
dir="rtl"
|
||||||
label="پاراگراف (۱۴ پیکسل)"
|
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
|
||||||
active={editor.isActive('paragraph')}
|
|
||||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
|
||||||
>
|
>
|
||||||
<PilcrowIcon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="پاراگراف"
|
||||||
<ToolButton
|
active={editor.isActive('paragraph')}
|
||||||
label="تیتر ۱ (۱۸ پیکسل)"
|
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
|
||||||
active={editor.isActive('heading', { level: 1 })}
|
>
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
<PilcrowIcon className="h-4 w-4" />
|
||||||
>
|
</ToolButton>
|
||||||
<Heading1Icon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="تیتر ۱"
|
||||||
<ToolButton
|
active={editor.isActive('heading', { level: 1 })}
|
||||||
label="تیتر ۲ (۱۶ پیکسل)"
|
onClick={() => applyBlockFormat(editor.view, 1)}
|
||||||
active={editor.isActive('heading', { level: 2 })}
|
>
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
<Heading1Icon className="h-4 w-4" />
|
||||||
>
|
</ToolButton>
|
||||||
<Heading2Icon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="تیتر ۲"
|
||||||
<ToolButton
|
active={editor.isActive('heading', { level: 2 })}
|
||||||
label="تیتر ۳ (۱۴ پیکسل)"
|
onClick={() => applyBlockFormat(editor.view, 2)}
|
||||||
active={editor.isActive('heading', { level: 3 })}
|
>
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
<Heading2Icon className="h-4 w-4" />
|
||||||
>
|
</ToolButton>
|
||||||
<Heading3Icon className="h-4 w-4" />
|
<ToolButton
|
||||||
</ToolButton>
|
label="تیتر ۳"
|
||||||
|
active={editor.isActive('heading', { level: 3 })}
|
||||||
|
onClick={() => applyBlockFormat(editor.view, 3)}
|
||||||
|
>
|
||||||
|
<Heading3Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<select
|
{/* جهت متن — فقط محتوای ویرایشگر را عوض میکند */}
|
||||||
className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
|
<ToolButton
|
||||||
value={editor.isActive('smallSize') ? '12' : '14'}
|
label="راستچین (فارسی)"
|
||||||
|
active={direction === 'rtl'}
|
||||||
|
onClick={() => setDirection('rtl')}
|
||||||
|
>
|
||||||
|
<AlignRightIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label="چپچین (انگلیسی)"
|
||||||
|
active={direction === 'ltr'}
|
||||||
|
onClick={() => setDirection('ltr')}
|
||||||
|
>
|
||||||
|
<AlignLeftIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
|
<BoldIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
|
<ItalicIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
||||||
|
<UnderlineIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||||
|
<StrikethroughIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||||
|
<ListIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||||
|
<ListOrderedIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||||
|
<QuoteIcon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
|
||||||
|
<Link2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
||||||
|
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
||||||
|
</ToolButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
||||||
|
<Undo2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
||||||
|
<Redo2Icon className="h-4 w-4" />
|
||||||
|
</ToolButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
data-editor-file-input
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.value === '12') {
|
const file = e.target.files?.[0]
|
||||||
editor.chain().focus().setMark('smallSize').run()
|
if (file) void uploadAndInsert(file)
|
||||||
} else {
|
e.target.value = ''
|
||||||
editor.chain().focus().unsetMark('smallSize').run()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="14">سایز ۱۴ (فارسی)</option>
|
|
||||||
<option value="12">سایز ۱۲ (انگلیسی)</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
|
|
||||||
<ToolButton
|
|
||||||
label="متن انگلیسی (۱۲ پیکسل، چپبهراست)"
|
|
||||||
active={editor.isActive(ENGLISH_TEXT_MARK)}
|
|
||||||
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
|
|
||||||
>
|
|
||||||
<LanguagesIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
|
||||||
<BoldIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
|
||||||
<ItalicIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
|
||||||
<UnderlineIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton label="خطخورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
|
||||||
<StrikethroughIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<ToolButton label="لیست نقطهای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
|
||||||
<ListIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton label="لیست شمارهای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
|
||||||
<ListOrderedIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton label="نقلقول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
|
||||||
<QuoteIcon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
|
|
||||||
<Link2Icon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
|
|
||||||
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
|
|
||||||
</ToolButton>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
|
|
||||||
<Undo2Icon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
|
|
||||||
<Redo2Icon className="h-4 w-4" />
|
|
||||||
</ToolButton>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorContent editor={editor} />
|
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||||
|
{linkModal.open && (
|
||||||
<input
|
<LinkModal
|
||||||
ref={fileRef}
|
initial={linkModal.initial}
|
||||||
type="file"
|
editing={linkModal.editing}
|
||||||
data-editor-file-input
|
onApply={applyLink}
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
onRemove={removeLink}
|
||||||
className="hidden"
|
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
|
||||||
onChange={(e) => {
|
/>
|
||||||
const file = e.target.files?.[0]
|
)}
|
||||||
if (file) void uploadAndInsert(file)
|
</>
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import { mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
import { ImageNodeView } from './ImageNodeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تصویر مقاله با کپشن.
|
||||||
|
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
|
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده میشود.
|
||||||
|
*/
|
||||||
|
export const ArticleImage = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
// ویژگیهای استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
|
||||||
|
...this.parent?.(),
|
||||||
|
caption: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'figure',
|
||||||
|
priority: 60,
|
||||||
|
getAttrs: (element) => {
|
||||||
|
if (!(element instanceof HTMLElement)) return false
|
||||||
|
const img = element.querySelector('img')
|
||||||
|
if (!img) return false
|
||||||
|
return {
|
||||||
|
src: img.getAttribute('src'),
|
||||||
|
alt: img.getAttribute('alt'),
|
||||||
|
title: img.getAttribute('title'),
|
||||||
|
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// تصاویر بدون figure (محتوای قدیمی یا چسباندهشده)
|
||||||
|
{ tag: 'img[src]:not([src^="data:"])' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const { caption, ...imageAttributes } = HTMLAttributes
|
||||||
|
const image: [string, Record<string, unknown>] = [
|
||||||
|
'img',
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
|
||||||
|
]
|
||||||
|
|
||||||
|
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||||
|
return caption
|
||||||
|
? [
|
||||||
|
'figure',
|
||||||
|
{ class: 'article-figure' },
|
||||||
|
image,
|
||||||
|
['figcaption', { class: 'article-figure__caption' }, caption],
|
||||||
|
]
|
||||||
|
: ['figure', { class: 'article-figure' }, image]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ImageNodeView)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
|
||||||
|
|
||||||
|
/** نام مارکهای قدیمی که خودشان اندازه/جهت متن را تعیین میکنند */
|
||||||
|
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
|
||||||
|
|
||||||
|
type BidiState = { dir: 'rtl' | 'ltr' }
|
||||||
|
|
||||||
|
/** کلید پلاگین — جهت متن با متای همین کلید بهروز میشود */
|
||||||
|
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
|
||||||
|
* span جهتدار نشانهگذاری میشود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچکتر»
|
||||||
|
* خروجی سایت را نشان دهد. تزئینها سند را تغییر نمیدهند و ذخیره نمیشوند.
|
||||||
|
*/
|
||||||
|
export const BidiTypography = Extension.create({
|
||||||
|
name: 'bidiTypography',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<BidiState>({
|
||||||
|
key: bidiPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ dir: 'rtl' }),
|
||||||
|
apply: (tr, value) => {
|
||||||
|
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
|
||||||
|
return dir && dir !== value.dir ? { dir } : value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations: (state) =>
|
||||||
|
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
|
||||||
|
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||||
|
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||||
|
const decorations: Decoration[] = []
|
||||||
|
const { doc, schema } = state
|
||||||
|
|
||||||
|
doc.descendants((node, pos, parent) => {
|
||||||
|
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
|
||||||
|
if (!node.isText || !node.text) return
|
||||||
|
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of findScriptRuns(node.text, dir)) {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations)
|
||||||
|
}
|
||||||
@@ -1,126 +1,168 @@
|
|||||||
import { useEffect, useId, useRef, useState } from 'react'
|
import { useId, useRef, useState } from 'react'
|
||||||
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
|
||||||
|
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
|
||||||
import { adminApi } from '../../admin/adminClient'
|
import { adminApi } from '../../admin/adminClient'
|
||||||
import { useAdmin } from '../../admin/AdminContext'
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
import { useSmartTextField } from './useSmartTextField'
|
||||||
|
|
||||||
type ImageFieldProps = {
|
type ImageFieldProps = {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onSave: (url: string) => Promise<boolean>
|
onSave: (url: string) => Promise<boolean>
|
||||||
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده میشود */
|
/** متن جایگزین (Alt) */
|
||||||
alt?: string
|
alt?: string
|
||||||
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیشنمایش و پیشنهاد استفاده میشود */
|
|
||||||
onSaveAlt?: (alt: string) => Promise<boolean>
|
onSaveAlt?: (alt: string) => Promise<boolean>
|
||||||
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
|
/** عنوان تصویر (Title) */
|
||||||
|
title?: string
|
||||||
|
onSaveTitle?: (title: string) => Promise<boolean>
|
||||||
|
/**
|
||||||
|
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
|
||||||
|
* هرگز از URL یا نام فایل ساخته نمیشود.
|
||||||
|
*/
|
||||||
altFallback?: string
|
altFallback?: string
|
||||||
|
/** پیشنهاد عنوان تصویر — پیشفرض «Photo 1» */
|
||||||
|
titleFallback?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const META_FIELD_CLASS =
|
||||||
|
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
|
* انتخاب و آپلود تصویر به MinIO با پیشنمایش بزرگ.
|
||||||
* پس از آپلود موفق، URL نسبی /media/... ذخیره میشود.
|
* ترتیب فیلدها: ۱) عنوان تصویر (پیشفرض «Photo 1») ۲) متن جایگزین (پیشفرض «{عنوان} - تصویر شاخص»).
|
||||||
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته میشود.
|
* هر دو «دستی یا پیشفرض» هستند و Alt هرگز از URL ساخته نمیشود.
|
||||||
|
* پیشفرض خودکار با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمیگردد.
|
||||||
*/
|
*/
|
||||||
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
|
export function ImageField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
alt,
|
||||||
|
onSaveAlt,
|
||||||
|
title,
|
||||||
|
onSaveTitle,
|
||||||
|
altFallback = '',
|
||||||
|
titleFallback = '',
|
||||||
|
}: ImageFieldProps) {
|
||||||
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
const { runSave, isAuthenticated, isEditMode } = useAdmin()
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const altInputId = useId()
|
const titleId = useId()
|
||||||
|
const altId = useId()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [altText, setAltText] = useState(alt ?? '')
|
|
||||||
|
const showTitle = title !== undefined || !!onSaveTitle
|
||||||
const showAlt = alt !== undefined || !!onSaveAlt
|
const showAlt = alt !== undefined || !!onSaveAlt
|
||||||
|
|
||||||
// همگامسازی با مقدار ذخیرهشده (پس از بهروزرسانی خوشبینانه در درخت داده)
|
const titleField = useSmartTextField({
|
||||||
useEffect(() => {
|
value: title ?? '',
|
||||||
setAltText(alt ?? '')
|
fallback: titleFallback,
|
||||||
}, [alt])
|
hardFallback: DEFAULT_IMAGE_TITLE,
|
||||||
|
onCommit: onSaveTitle,
|
||||||
|
})
|
||||||
|
const altField = useSmartTextField({
|
||||||
|
value: alt ?? '',
|
||||||
|
fallback: altFallback,
|
||||||
|
hardFallback: label,
|
||||||
|
onCommit: onSaveAlt,
|
||||||
|
})
|
||||||
|
|
||||||
if (!isAuthenticated || !isEditMode) return null
|
if (!isAuthenticated || !isEditMode) return null
|
||||||
|
|
||||||
/** متن جایگزین نهایی: ورودی کاربر → عنوان موجودیت → نام فایل */
|
|
||||||
const resolveAlt = (fileName: string): string => {
|
|
||||||
const typed = altText.trim()
|
|
||||||
if (typed) return typed
|
|
||||||
const fromEntity = altFallback.trim()
|
|
||||||
if (fromEntity) return fromEntity
|
|
||||||
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || label
|
|
||||||
}
|
|
||||||
|
|
||||||
const pick = async (file: File | undefined) => {
|
const pick = async (file: File | undefined) => {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
const generatedAlt = resolveAlt(file.name)
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file)
|
const { url } = await adminApi.upload(file)
|
||||||
const ok = await onSave(url)
|
const ok = await onSave(url)
|
||||||
if (!ok) throw new Error('ذخیره نشد')
|
if (!ok) throw new Error('ذخیره نشد')
|
||||||
// Alt ساختهشده هم ذخیره میشود تا تصویر بدون متن جایگزین نماند
|
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||||
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
|
if (showTitle) await titleField.commit()
|
||||||
setAltText(generatedAlt)
|
if (showAlt) await altField.commit()
|
||||||
if (onSaveAlt) await onSaveAlt(generatedAlt)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitAlt = () => {
|
|
||||||
const next = altText.trim()
|
|
||||||
if (!onSaveAlt || next === (alt ?? '').trim()) return
|
|
||||||
void onSaveAlt(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
<p className="text-xs font-bold text-ivory/80">{label}</p>
|
||||||
<div className="mt-2 flex items-center gap-3">
|
|
||||||
|
{/* پیشنمایش بزرگ تصویر */}
|
||||||
|
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
|
||||||
{value ? (
|
{value ? (
|
||||||
<img
|
<img
|
||||||
src={value}
|
src={value}
|
||||||
alt={altText || label}
|
alt={altField.display || label}
|
||||||
className="h-16 w-24 border border-ivory/15 object-cover"
|
title={titleField.display || undefined}
|
||||||
|
className="h-[27rem] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
|
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
|
||||||
—
|
<ImageIcon className="h-7 w-7" aria-hidden="true" />
|
||||||
|
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{uploading ? (
|
|
||||||
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ImagePlusIcon className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
|
|
||||||
</button>
|
|
||||||
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAlt && (
|
<div className="mt-3">
|
||||||
<div className="mt-3">
|
<button
|
||||||
<div className="flex items-center justify-between gap-3">
|
type="button"
|
||||||
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
|
onClick={() => inputRef.current?.click()}
|
||||||
متن جایگزین (Alt)
|
disabled={uploading}
|
||||||
</label>
|
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
|
||||||
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحهخوانها</span>
|
>
|
||||||
</div>
|
{uploading ? (
|
||||||
<input
|
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
|
||||||
id={altInputId}
|
) : (
|
||||||
type="text"
|
<ImagePlusIcon className="h-3.5 w-3.5" />
|
||||||
dir="auto"
|
)}
|
||||||
value={altText}
|
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||||
onChange={(e) => setAltText(e.target.value)}
|
</button>
|
||||||
onBlur={commitAlt}
|
</div>
|
||||||
placeholder={altFallback ? `پیشفرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
|
|
||||||
className="mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
|
{(showTitle || showAlt) && (
|
||||||
/>
|
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
|
||||||
|
{showTitle && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
عنوان تصویر (Title)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={titleId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={titleField.display}
|
||||||
|
onChange={(event) => titleField.onChange(event.target.value)}
|
||||||
|
onFocus={titleField.onFocus}
|
||||||
|
onBlur={titleField.onBlur}
|
||||||
|
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
|
||||||
|
className={META_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAlt && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
متن جایگزین (Alt)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={altId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={altField.display}
|
||||||
|
onChange={(event) => altField.onChange(event.target.value)}
|
||||||
|
onFocus={altField.onFocus}
|
||||||
|
onBlur={altField.onBlur}
|
||||||
|
placeholder={
|
||||||
|
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحهخوانها'
|
||||||
|
}
|
||||||
|
className={META_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -129,9 +171,9 @@ export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback =
|
|||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(event) => {
|
||||||
void pick(e.target.files?.[0])
|
void pick(event.target.files?.[0])
|
||||||
e.target.value = ''
|
event.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import type { NodeViewProps } from '@tiptap/react'
|
||||||
|
import { PlusIcon, XIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
type ImageAttrs = {
|
||||||
|
src: string
|
||||||
|
alt: string | null
|
||||||
|
title: string | null
|
||||||
|
caption: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELETE_BUTTON_CLASS =
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
|
||||||
|
|
||||||
|
const INLINE_FIELD_CLASS =
|
||||||
|
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
const ADD_CAPTION_CLASS =
|
||||||
|
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||||
|
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
|
*
|
||||||
|
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||||
|
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||||
|
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
||||||
|
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
||||||
|
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||||
|
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
||||||
|
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||||
|
*/
|
||||||
|
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||||
|
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||||
|
const hasCaption = Boolean((caption ?? '').trim())
|
||||||
|
const [captionOpen, setCaptionOpen] = useState(true)
|
||||||
|
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const focusOnOpen = useRef(false)
|
||||||
|
const showCaption = captionOpen || hasCaption
|
||||||
|
|
||||||
|
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusOnOpen.current) return
|
||||||
|
focusOnOpen.current = false
|
||||||
|
captionInputRef.current?.focus()
|
||||||
|
}, [captionOpen])
|
||||||
|
|
||||||
|
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||||
|
const removeCaption = () => {
|
||||||
|
updateAttributes({ caption: '' })
|
||||||
|
setCaptionOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCaption = () => {
|
||||||
|
focusOnOpen.current = true
|
||||||
|
setCaptionOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||||
|
data-drag-handle
|
||||||
|
>
|
||||||
|
<figure className="article-figure relative">
|
||||||
|
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
|
||||||
|
|
||||||
|
{/* حذف کل تصویر */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={() => deleteNode()}
|
||||||
|
aria-label="حذف تصویر"
|
||||||
|
title="حذف تصویر"
|
||||||
|
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCaption ? (
|
||||||
|
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||||
|
<input
|
||||||
|
ref={captionInputRef}
|
||||||
|
type="text"
|
||||||
|
value={caption ?? ''}
|
||||||
|
onChange={(event) => updateAttributes({ caption: event.target.value })}
|
||||||
|
placeholder="توضیح تصویر (کپشن)…"
|
||||||
|
aria-label="کپشن تصویر"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeCaption}
|
||||||
|
aria-label="حذف کپشن"
|
||||||
|
title="حذف کپشن (تصویر حذف نمیشود)"
|
||||||
|
className={DELETE_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</figcaption>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={addCaption}
|
||||||
|
className={ADD_CAPTION_CLASS}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
افزودن کپشن
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
|
||||||
|
import { buildLinkRel, normalizeHref } from '../../utils/link'
|
||||||
|
|
||||||
|
export type LinkModalValue = {
|
||||||
|
href: string
|
||||||
|
newTab: boolean
|
||||||
|
nofollow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkModalProps = {
|
||||||
|
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
|
||||||
|
initial: LinkModalValue
|
||||||
|
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده میشود) */
|
||||||
|
editing: boolean
|
||||||
|
onApply: (value: LinkModalValue) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_CLASS =
|
||||||
|
'mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
|
||||||
|
|
||||||
|
/** مدت انیمیشن ورود/خروج — با duration-300 کلاسها هماهنگ است */
|
||||||
|
const TRANSITION_MS = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاسهای Tailwind.
|
||||||
|
* ورود با یک فریم تاخیر (تا کلاسهای بسته اعمال شوند) و خروج پیش از بستن مودال اجرا میشود.
|
||||||
|
* جایگزین پنجره پیشفرض مرورگر برای لینک شده است.
|
||||||
|
*/
|
||||||
|
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
|
||||||
|
const [href, setHref] = useState(initial.href)
|
||||||
|
const [newTab, setNewTab] = useState(initial.newTab)
|
||||||
|
const [nofollow, setNofollow] = useState(initial.nofollow)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
|
||||||
|
// انیمیشن ورود: ابتدا حالت بسته رندر میشود، سپس به حالت باز میرود
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** بستن نرم: اول انیمیشن خروج کامل میشود، بعد عملیات و unmount */
|
||||||
|
const closeWith = (action?: () => void) => {
|
||||||
|
if (closing) return
|
||||||
|
setClosing(true)
|
||||||
|
setVisible(false)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
action?.()
|
||||||
|
onClose()
|
||||||
|
}, TRANSITION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const normalized = normalizeHref(href)
|
||||||
|
if (!normalized) {
|
||||||
|
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const relPreview = buildLinkRel({ newTab, nofollow })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="ثبت لینک"
|
||||||
|
>
|
||||||
|
{/* پسزمینه تار — fade نرم */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
|
||||||
|
visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* کارت مودال — fade + scale نرم */}
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
dir="rtl"
|
||||||
|
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
|
||||||
|
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
|
||||||
|
<Link2Icon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
aria-label="بستن"
|
||||||
|
className="text-ivory/50 transition-colors hover:text-ivory"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* نشانی */}
|
||||||
|
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
|
||||||
|
نشانی پیوند (URL)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="link-href"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
autoFocus
|
||||||
|
value={href}
|
||||||
|
onChange={(event) => {
|
||||||
|
setHref(event.target.value)
|
||||||
|
if (error) setError('')
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className={FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* باز شدن در تب جدید */}
|
||||||
|
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newTab}
|
||||||
|
onChange={(event) => setNewTab(event.target.checked)}
|
||||||
|
className="h-4 w-4 accent-copper"
|
||||||
|
/>
|
||||||
|
باز شدن در تب جدید
|
||||||
|
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target="_blank"</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* نوع لینک از نظر سئو */}
|
||||||
|
<fieldset className="mt-5">
|
||||||
|
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
|
||||||
|
<div className="mt-3 grid gap-2.5">
|
||||||
|
{[
|
||||||
|
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل میشود', value: false },
|
||||||
|
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
|
||||||
|
].map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.key}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
|
||||||
|
nofollow === option.value
|
||||||
|
? 'border-copper/60 bg-copper/10'
|
||||||
|
: 'border-ivory/12 hover:border-ivory/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="link-rel"
|
||||||
|
checked={nofollow === option.value}
|
||||||
|
onChange={() => setNofollow(option.value)}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-copper"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="text-sm font-light text-ivory">{option.label}</span>
|
||||||
|
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{relPreview && (
|
||||||
|
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
|
||||||
|
rel="{relPreview}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* دکمهها */}
|
||||||
|
<div className="mt-7 flex items-center justify-between gap-3">
|
||||||
|
{editing ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith(onRemove)}
|
||||||
|
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
حذف لینک
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => closeWith()}
|
||||||
|
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
|
||||||
|
>
|
||||||
|
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,89 +1,82 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import Select from 'react-select'
|
import Select from 'react-select'
|
||||||
import CreatableSelect from 'react-select/creatable'
|
import CreatableSelect from 'react-select/creatable'
|
||||||
import type { StylesConfig } from 'react-select'
|
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
|
||||||
export type LuxeOption = { label: string; value: string }
|
export type LuxeOption = { label: string; value: string }
|
||||||
|
|
||||||
const COPPER = '#d5874c'
|
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
|
||||||
const IVORY = '#F5F3EF'
|
LuxeOption,
|
||||||
const SURFACE = '#1e1a16'
|
IsMulti,
|
||||||
const BORDER = 'rgba(245,243,239,0.15)'
|
GroupBase<LuxeOption>
|
||||||
|
>
|
||||||
|
|
||||||
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
|
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
|
||||||
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
|
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
|
||||||
|
isFocused
|
||||||
|
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
|
||||||
|
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
|
||||||
|
}`
|
||||||
|
|
||||||
|
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
|
||||||
|
`!cursor-pointer !rounded-none !text-[13px] ${
|
||||||
|
isSelected
|
||||||
|
? '!bg-copper/25 !font-bold !text-copper'
|
||||||
|
: isFocused
|
||||||
|
? '!bg-copper/15 !text-ivory'
|
||||||
|
: '!bg-transparent !text-ivory/85'
|
||||||
|
}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کلاسهای Tailwind برای همه اسلاتهای react-select — تم تیره پروژه.
|
||||||
|
* react-select برای هر اسلات تابع میخواهد، پس همه با تابع برگردانده میشوند.
|
||||||
|
*/
|
||||||
|
function makeClassNames<IsMulti extends boolean>(
|
||||||
|
extras?: Partial<LuxeClassNames<IsMulti>>,
|
||||||
|
): LuxeClassNames<IsMulti> {
|
||||||
return {
|
return {
|
||||||
control: (base, state) => ({
|
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
|
||||||
...base,
|
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
|
||||||
minHeight: 42,
|
valueContainer: () => '!gap-1 !px-3 !py-0.5',
|
||||||
backgroundColor: SURFACE,
|
placeholder: () => '!text-[13px] !text-ivory/35',
|
||||||
borderColor: state.isFocused ? COPPER : BORDER,
|
singleValue: () => '!text-[13px] !text-ivory',
|
||||||
borderRadius: 0,
|
input: () => '!text-[13px] !text-ivory',
|
||||||
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
|
indicatorsContainer: () => '!text-ivory/40',
|
||||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||||
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
|
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
|
||||||
}),
|
indicatorSeparator: () => '!bg-ivory/15',
|
||||||
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
|
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
|
||||||
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
|
menuList: () => '!p-1',
|
||||||
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
noOptionsMessage: () => '!text-xs !text-ivory/45',
|
||||||
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
|
loadingMessage: () => '!text-xs !text-ivory/45',
|
||||||
menu: (base) => ({
|
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
|
||||||
...base,
|
multiValueLabel: () => '!text-xs !text-copper',
|
||||||
backgroundColor: SURFACE,
|
...extras,
|
||||||
border: `1px solid ${BORDER}`,
|
|
||||||
borderRadius: 0,
|
|
||||||
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
zIndex: 60,
|
|
||||||
}),
|
|
||||||
menuList: (base) => ({ ...base, padding: 4 }),
|
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 200 }),
|
|
||||||
option: (base, state) => ({
|
|
||||||
...base,
|
|
||||||
backgroundColor: state.isSelected
|
|
||||||
? 'rgba(213,135,76,0.25)'
|
|
||||||
: state.isFocused
|
|
||||||
? 'rgba(213,135,76,0.12)'
|
|
||||||
: 'transparent',
|
|
||||||
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: state.isSelected ? 700 : 400,
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: 0,
|
|
||||||
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
|
|
||||||
}),
|
|
||||||
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
|
|
||||||
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
|
|
||||||
dropdownIndicator: (base, state) => ({
|
|
||||||
...base,
|
|
||||||
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
|
|
||||||
':hover': { color: COPPER },
|
|
||||||
}),
|
|
||||||
clearIndicator: (base) => ({
|
|
||||||
...base,
|
|
||||||
color: 'rgba(245,243,239,0.4)',
|
|
||||||
':hover': { color: COPPER },
|
|
||||||
}),
|
|
||||||
multiValue: (base) => ({
|
|
||||||
...base,
|
|
||||||
backgroundColor: 'rgba(213,135,76,0.15)',
|
|
||||||
border: '1px solid rgba(213,135,76,0.4)',
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingInline: 2,
|
|
||||||
}),
|
|
||||||
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
|
|
||||||
multiValueRemove: (base) => ({
|
|
||||||
...base,
|
|
||||||
color: COPPER,
|
|
||||||
borderRadius: 999,
|
|
||||||
':hover': { backgroundColor: COPPER, color: SURFACE },
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** دکمه ضربدر حذف برچسب انتخابشده — همیشه واضح و قابل کلیک */
|
||||||
|
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...innerProps}
|
||||||
|
role="button"
|
||||||
|
aria-label="حذف برچسب"
|
||||||
|
title="حذف برچسب"
|
||||||
|
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const portalTarget = (): HTMLElement | undefined =>
|
const portalTarget = (): HTMLElement | undefined =>
|
||||||
typeof document === 'undefined' ? undefined : document.body
|
typeof document === 'undefined' ? undefined : document.body
|
||||||
|
|
||||||
|
const uniqueOptions = (values: string[]): LuxeOption[] =>
|
||||||
|
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
|
||||||
|
|
||||||
type LuxeSelectProps = {
|
type LuxeSelectProps = {
|
||||||
value: string
|
value: string
|
||||||
options: string[]
|
options: string[]
|
||||||
@@ -94,7 +87,7 @@ type LuxeSelectProps = {
|
|||||||
inputId?: string
|
inputId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ میشود */
|
||||||
export function LuxeSelect({
|
export function LuxeSelect({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
@@ -104,9 +97,10 @@ export function LuxeSelect({
|
|||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
inputId,
|
inputId,
|
||||||
}: LuxeSelectProps) {
|
}: LuxeSelectProps) {
|
||||||
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map(
|
const selectOptions = useMemo(
|
||||||
(item) => ({ label: item, value: item }),
|
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||||
), [options, value])
|
[options, value],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select<LuxeOption, false>
|
<Select<LuxeOption, false>
|
||||||
@@ -119,7 +113,141 @@ export function LuxeSelect({
|
|||||||
isClearable={isClearable}
|
isClearable={isClearable}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
noOptionsMessage={() => 'موردی یافت نشد'}
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
styles={makeStyles<false>()}
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LuxeCreatableSelectProps = {
|
||||||
|
value: string
|
||||||
|
options: string[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
inputId?: string
|
||||||
|
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
|
||||||
|
createLabel?: (input: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه میسازد */
|
||||||
|
export function LuxeCreatableSelect({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'انتخاب یا نوشتن مورد جدید…',
|
||||||
|
inputId,
|
||||||
|
createLabel = (input) => `افزودن «${input}»`,
|
||||||
|
}: LuxeCreatableSelectProps) {
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...(value ? [value] : [])]),
|
||||||
|
[options, value],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<LuxeOption, false>
|
||||||
|
inputId={inputId}
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
options={selectOptions}
|
||||||
|
value={selectOptions.find((option) => option.value === value) ?? null}
|
||||||
|
onChange={(option) => onChange(option?.value?.trim() ?? '')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
formatCreateLabel={(input) => createLabel(input)}
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
|
menuPosition="fixed"
|
||||||
|
menuPortalTarget={portalTarget()}
|
||||||
|
isClearable
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryOption = { label: string; value: string }
|
||||||
|
|
||||||
|
/** کلید نگهداری دستههای ساختهشده در مرورگر */
|
||||||
|
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
|
||||||
|
|
||||||
|
/** دستههایی که ادمین ساخته است — برای مقالههای بعدی هم در فهرست میمانند */
|
||||||
|
function readCreatedCategories(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : []
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item): item is string => typeof item === 'string')
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeCreatedCategory(category: string): void {
|
||||||
|
try {
|
||||||
|
const current = readCreatedCategories()
|
||||||
|
if (current.includes(category)) return
|
||||||
|
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
|
||||||
|
} catch {
|
||||||
|
/* حافظه مرورگر در دسترس نیست — بیصدا رد شو */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategorySelectProps = {
|
||||||
|
/** مقدار انتخابشده — یک شیء {value,label} یا null */
|
||||||
|
value: CategoryOption | null
|
||||||
|
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
|
||||||
|
onChange: (option: CategoryOption | null) => void
|
||||||
|
/** دستههای پایه */
|
||||||
|
options: string[]
|
||||||
|
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
|
||||||
|
createLabel?: (input: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتخابگر تکمقداری دستهبندی (react-select Creatable).
|
||||||
|
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک میکند و گزینهها دستنخورده میمانند.
|
||||||
|
* - ساخت دسته جدید: به انتهای فهرست گزینهها اضافه و برای مقاله جاری انتخاب میشود و
|
||||||
|
* در همین مرورگر برای مقالههای بعدی نگه داشته میشود.
|
||||||
|
*/
|
||||||
|
export function CategorySelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
|
||||||
|
}: CategorySelectProps) {
|
||||||
|
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
|
||||||
|
|
||||||
|
// گزینههای پایه + دستههای ساختهشده (همیشه در انتهای فهرست)
|
||||||
|
const selectOptions = useMemo(
|
||||||
|
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
|
||||||
|
[options, createdOptions],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
|
||||||
|
const handleCreate = (input: string) => {
|
||||||
|
const category = input.trim()
|
||||||
|
if (!category) return
|
||||||
|
setCreatedOptions((previous) =>
|
||||||
|
previous.includes(category) ? previous : [...previous, category],
|
||||||
|
)
|
||||||
|
storeCreatedCategory(category)
|
||||||
|
onChange({ label: category, value: category })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreatableSelect<CategoryOption, false>
|
||||||
|
classNamePrefix="luxe"
|
||||||
|
isMulti={false}
|
||||||
|
isClearable
|
||||||
|
options={selectOptions}
|
||||||
|
value={value}
|
||||||
|
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
|
||||||
|
onCreateOption={handleCreate}
|
||||||
|
formatCreateLabel={(input) => createLabel(input)}
|
||||||
|
placeholder="انتخاب یا نوشتن دسته جدید…"
|
||||||
|
noOptionsMessage={() => 'موردی یافت نشد'}
|
||||||
|
classNames={makeClassNames<false>()}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
menuPosition="fixed"
|
menuPosition="fixed"
|
||||||
menuPortalTarget={portalTarget()}
|
menuPortalTarget={portalTarget()}
|
||||||
/>
|
/>
|
||||||
@@ -130,18 +258,19 @@ type LuxeTagsSelectProps = {
|
|||||||
value: string[]
|
value: string[]
|
||||||
onChange: (value: string[]) => void
|
onChange: (value: string[]) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}/** هشتگها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
|
||||||
|
|
||||||
/** هشتگها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه میشود */
|
|
||||||
export function LuxeTagsSelect({
|
export function LuxeTagsSelect({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
placeholder = 'هشتگ بنویسید و Enter بزنید…',
|
||||||
}: LuxeTagsSelectProps) {
|
}: LuxeTagsSelectProps) {
|
||||||
const options = useMemo(
|
const options = useMemo(() => uniqueOptions(value), [value])
|
||||||
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
|
|
||||||
[value],
|
/** پاکسازی و حذف تکراریها هنگام افزودن یا حذف برچسب */
|
||||||
)
|
const applyTags = (next: readonly LuxeOption[]) => {
|
||||||
|
const tags = next.map((option) => option.value.trim()).filter(Boolean)
|
||||||
|
onChange([...new Set(tags)])
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreatableSelect<LuxeOption, true>
|
<CreatableSelect<LuxeOption, true>
|
||||||
@@ -149,11 +278,14 @@ export function LuxeTagsSelect({
|
|||||||
isMulti
|
isMulti
|
||||||
options={options}
|
options={options}
|
||||||
value={options}
|
value={options}
|
||||||
onChange={(next) => onChange(next.map((option) => option.value))}
|
onChange={applyTags}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
formatCreateLabel={(input) => `افزودن «${input}»`}
|
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
|
||||||
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
noOptionsMessage={() => 'هشتگی یافت نشد'}
|
||||||
styles={makeStyles<true>()}
|
closeMenuOnSelect={false}
|
||||||
|
components={{ MultiValueRemove: TagRemoveButton }}
|
||||||
|
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
|
||||||
|
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
|
||||||
menuPosition="fixed"
|
menuPosition="fixed"
|
||||||
menuPortalTarget={portalTarget()}
|
menuPortalTarget={portalTarget()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
||||||
import type { ArticleBodyDoc } from '../../data/site'
|
import type { ArticleBodyDoc } from '../../data/site'
|
||||||
|
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
||||||
|
|
||||||
export type SeoInput = {
|
export type SeoInput = {
|
||||||
title: string
|
title: string
|
||||||
@@ -13,78 +14,6 @@ export type SeoInput = {
|
|||||||
type Status = 'good' | 'ok' | 'bad'
|
type Status = 'good' | 'ok' | 'bad'
|
||||||
type Check = { label: string; status: Status; hint: string }
|
type Check = { label: string; status: Status; hint: string }
|
||||||
|
|
||||||
// ---------- استخراج متن/ساختار از سند TipTap ----------
|
|
||||||
|
|
||||||
function docToText(body: SeoInput['body']): string {
|
|
||||||
if (Array.isArray(body)) return body.join(' ')
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; text?: string; content?: unknown[] }
|
|
||||||
if (n.type === 'text' && n.text) out.push(n.text)
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function docHeadings(body: SeoInput['body']): string[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
|
|
||||||
if (n.type === 'heading' && n.attrs?.level === 2) {
|
|
||||||
const texts: string[] = []
|
|
||||||
const collect = (inner: unknown) => {
|
|
||||||
if (!inner || typeof inner !== 'object') return
|
|
||||||
const t = inner as { type?: string; text?: string; content?: unknown[] }
|
|
||||||
if (t.type === 'text' && t.text) texts.push(t.text)
|
|
||||||
for (const c of t.content ?? []) collect(c)
|
|
||||||
}
|
|
||||||
collect(n)
|
|
||||||
out.push(texts.join(''))
|
|
||||||
}
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: { src: string; alt: string }[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
|
|
||||||
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function docLinks(body: SeoInput['body']): string[] {
|
|
||||||
if (Array.isArray(body)) return []
|
|
||||||
const out: string[] = []
|
|
||||||
const walk = (node: unknown) => {
|
|
||||||
if (!node || typeof node !== 'object') return
|
|
||||||
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
|
|
||||||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
|
|
||||||
for (const c of n.content ?? []) walk(c)
|
|
||||||
}
|
|
||||||
walk(body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstParagraph(body: SeoInput['body']): string {
|
|
||||||
if (Array.isArray(body)) return body[0] ?? ''
|
|
||||||
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
|
|
||||||
(c) => c.type === 'paragraph',
|
|
||||||
)
|
|
||||||
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- محاسبه بررسیها ----------
|
// ---------- محاسبه بررسیها ----------
|
||||||
|
|
||||||
export function useSeoChecks(input: SeoInput): Check[] {
|
export function useSeoChecks(input: SeoInput): Check[] {
|
||||||
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
|
||||||
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
|
||||||
)
|
)
|
||||||
const h2s = docHeadings(input.body)
|
const h2s = docHeadings(input.body, 2)
|
||||||
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
|
||||||
else
|
else
|
||||||
checks.push(
|
checks.push(
|
||||||
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
|||||||
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
|
||||||
|
|
||||||
// تیتر H2
|
// تیتر H2
|
||||||
const h2s = docHeadings(input.body)
|
const h2s = docHeadings(input.body, 2)
|
||||||
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
|
||||||
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخشبندی کنید.' })
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
|
||||||
|
import type { EditorView } from '@tiptap/pm/view'
|
||||||
|
import type { ResolvedPos } from '@tiptap/pm/model'
|
||||||
|
|
||||||
|
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
|
||||||
|
export type BlockFormat = 'paragraph' | 1 | 2 | 3
|
||||||
|
|
||||||
|
type LineBounds = {
|
||||||
|
/** ابتدای بلوک جاری */
|
||||||
|
start: number
|
||||||
|
/** پایان بلوک جاری */
|
||||||
|
end: number
|
||||||
|
/** ابتدای خط جاری (پس از نزدیکترین شکست نرم قبل از نشانگر) */
|
||||||
|
lineStart: number
|
||||||
|
/** پایان خط جاری (نزدیکترین شکست نرم پس از نشانگر) */
|
||||||
|
lineEnd: number
|
||||||
|
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
|
||||||
|
before: number | null
|
||||||
|
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
|
||||||
|
after: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شدهاند */
|
||||||
|
function lineBounds($from: ResolvedPos): LineBounds {
|
||||||
|
const start = $from.start()
|
||||||
|
const end = $from.end()
|
||||||
|
let lineStart = start
|
||||||
|
let lineEnd = end
|
||||||
|
let before: number | null = null
|
||||||
|
let after: number | null = null
|
||||||
|
|
||||||
|
$from.parent.forEach((node, offset) => {
|
||||||
|
if (node.type.name !== 'hardBreak') return
|
||||||
|
const pos = start + offset
|
||||||
|
if (pos < $from.pos) {
|
||||||
|
lineStart = pos + 1
|
||||||
|
before = pos
|
||||||
|
} else if (after === null) {
|
||||||
|
lineEnd = pos
|
||||||
|
after = pos
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { start, end, lineStart, lineEnd, before, after }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسباندهشده از Word).
|
||||||
|
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض میکند.
|
||||||
|
* اگر گزینش از خط عبور کند (قالببندی چندخطیِ عمدی) دستنخورده میماند.
|
||||||
|
*/
|
||||||
|
function isolateCurrentLine(view: EditorView): boolean {
|
||||||
|
const { state } = view
|
||||||
|
const { selection } = state
|
||||||
|
const bounds = lineBounds(selection.$from)
|
||||||
|
|
||||||
|
if (bounds.before === null && bounds.after === null) return false
|
||||||
|
|
||||||
|
const selectionInsideLine =
|
||||||
|
selection.empty ||
|
||||||
|
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
|
||||||
|
if (!selectionInsideLine) return false
|
||||||
|
|
||||||
|
const { tr } = state
|
||||||
|
if (bounds.after !== null) {
|
||||||
|
tr.delete(bounds.after, bounds.after + 1)
|
||||||
|
tr.split(bounds.after)
|
||||||
|
}
|
||||||
|
if (bounds.before !== null) {
|
||||||
|
tr.delete(bounds.before, bounds.before + 1)
|
||||||
|
tr.split(bounds.before)
|
||||||
|
}
|
||||||
|
if (!tr.docChanged) return false
|
||||||
|
|
||||||
|
view.dispatch(tr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قالببندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
|
||||||
|
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
|
||||||
|
* پاراگراف برمیگرداند (رفتار toggle).
|
||||||
|
*/
|
||||||
|
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
|
||||||
|
view.focus()
|
||||||
|
isolateCurrentLine(view)
|
||||||
|
|
||||||
|
const { state } = view
|
||||||
|
const paragraph = state.schema.nodes.paragraph
|
||||||
|
const heading = state.schema.nodes.heading
|
||||||
|
if (!paragraph || !heading) return false
|
||||||
|
|
||||||
|
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
|
||||||
|
|
||||||
|
const current = state.selection.$from.parent
|
||||||
|
const sameHeading = current.type === heading && current.attrs.level === format
|
||||||
|
return sameHeading
|
||||||
|
? setBlockType(paragraph)(state, view.dispatch, view)
|
||||||
|
: setBlockType(heading, { level: format })(state, view.dispatch, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter داخل تیتر: بلوک تازه پاراگراف میشود (نه تیتر تازه) تا قالب تیتر به خط
|
||||||
|
* بعدی سرایت نکند و مرز بلوکها تمیز بماند.
|
||||||
|
* این هندلر پیش از keymap پیشفرض اجرا میشود و فقط همین حالت را میگیرد.
|
||||||
|
*/
|
||||||
|
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
|
||||||
|
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = view
|
||||||
|
const { selection } = state
|
||||||
|
const paragraph = state.schema.nodes.paragraph
|
||||||
|
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
|
||||||
|
|
||||||
|
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل میشود
|
||||||
|
if (!splitBlock(state, view.dispatch, view)) return false
|
||||||
|
setBlockType(paragraph)(view.state, view.dispatch, view)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type SmartTextFieldOptions = {
|
||||||
|
/** مقدار ذخیرهشده در بیرون (منبع حقیقت) */
|
||||||
|
value: string
|
||||||
|
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره میشود */
|
||||||
|
fallback?: string
|
||||||
|
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
|
||||||
|
hardFallback?: string
|
||||||
|
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیرهشده تغییر کرده باشد */
|
||||||
|
onCommit?: (next: string) => void | Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلد متنی با «پیشفرض هوشمند» برای Alt/Title تصویر:
|
||||||
|
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی میماند؛ در حین تایپ نه trim میشود و نه از
|
||||||
|
* بیرون بازنویسی میشود، پس فاصله انتهای متن و جای نشانگر حفظ میشود.
|
||||||
|
* - با فوکوس، پیشفرض خودکار پاک میشود تا کاربر بلافاصله تایپ کند.
|
||||||
|
* - با بلور، اگر فیلد خالی بماند پیشفرض خودکار برمیگردد و مقدار نهایی ذخیره میشود.
|
||||||
|
*/
|
||||||
|
export function useSmartTextField({
|
||||||
|
value,
|
||||||
|
fallback = '',
|
||||||
|
hardFallback = '',
|
||||||
|
onCommit,
|
||||||
|
}: SmartTextFieldOptions) {
|
||||||
|
const [text, setText] = useState(value)
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
|
// تازهترین مقادیر در ref نگه داشته میشوند تا هندلرها به closure قدیمی وابسته نباشند
|
||||||
|
const textRef = useRef(text)
|
||||||
|
textRef.current = text
|
||||||
|
const focusedRef = useRef(focused)
|
||||||
|
focusedRef.current = focused
|
||||||
|
const fallbackRef = useRef(fallback)
|
||||||
|
fallbackRef.current = fallback
|
||||||
|
const hardFallbackRef = useRef(hardFallback)
|
||||||
|
hardFallbackRef.current = hardFallback
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
valueRef.current = value
|
||||||
|
|
||||||
|
// همگامسازی با مقدار ذخیرهشده از بیرون (بارگذاری مقاله، بهروزرسانی خوشبینانه)
|
||||||
|
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر میپرد و فاصلهها میخورند
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedRef.current) return
|
||||||
|
setText(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیشفرض ثابت */
|
||||||
|
const resolve = useCallback((): string => {
|
||||||
|
const current = textRef.current.trim()
|
||||||
|
if (current) return current
|
||||||
|
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commit = useCallback(async (): Promise<void> => {
|
||||||
|
const next = resolve()
|
||||||
|
setText(next)
|
||||||
|
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
|
||||||
|
}, [onCommit, resolve])
|
||||||
|
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
setFocused(true)
|
||||||
|
// پیشفرض خودکار متن واقعی نیست؛ با فوکوس پاک میشود تا کاربر بلافاصله تایپ کند
|
||||||
|
const current = textRef.current.trim()
|
||||||
|
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
|
||||||
|
if (!current || current === auto) setText('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onBlur = useCallback(() => {
|
||||||
|
setFocused(false)
|
||||||
|
void commit()
|
||||||
|
}, [commit])
|
||||||
|
|
||||||
|
const onChange = useCallback((next: string) => {
|
||||||
|
setText(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده میشود */
|
||||||
|
const display = focused ? text : text !== '' ? text : fallback
|
||||||
|
|
||||||
|
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ArrowLeftIcon } from 'lucide-react';
|
|
||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { ButtonLink } from '../Button';
|
import { PremiumButton } from '../PremiumButton';
|
||||||
import { ArticleCard } from '../ArticleCard';
|
import { ArticleCard } from '../ArticleCard';
|
||||||
import { useSectionHeading, useSite } from '../../api';
|
import { useSectionHeading, useSite } from '../../api';
|
||||||
import { useLang } from '../../i18n/lang';
|
import { useLang } from '../../i18n/lang';
|
||||||
@@ -53,10 +53,9 @@ export function Articles() {
|
|||||||
{heading.cta_label && heading.cta_href && (
|
{heading.cta_label && heading.cta_href && (
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<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)} />
|
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
</PremiumButton>
|
||||||
</ButtonLink>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,20 +18,22 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
<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 && (
|
{!hideHeading && (
|
||||||
<div className="lg:col-span-4">
|
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackFaqTitle')}
|
title={t('fallbackFaqTitle')}
|
||||||
lead={t('fallbackFaqLead')}
|
lead={t('fallbackFaqLead')}
|
||||||
headingKey="faq"
|
headingKey="faq"
|
||||||
|
align="center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
{/* Accordion list — constrained width, direction-aware */}
|
||||||
|
<div className="w-full max-w-3xl mx-auto">
|
||||||
<ul>
|
<ul>
|
||||||
{faqs.map((faq, i) => {
|
{faqs.map((faq, i) => {
|
||||||
const isOpen = open === i
|
const isOpen = open === i
|
||||||
@@ -43,9 +45,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(isOpen ? null : i)}
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
aria-expanded={isOpen}
|
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}
|
{faq.question}
|
||||||
</span>
|
</span>
|
||||||
<PlusIcon
|
<PlusIcon
|
||||||
@@ -63,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="overflow-hidden"
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ButtonLink } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { ProjectCard } from '../ProjectCard'
|
import { ProjectCard } from '../ProjectCard'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { InlineEdit } from '../admin/InlineEdit'
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
|
||||||
import type { ProjectStatus } from '../../data/site'
|
import type { ProjectStatus } from '../../data/site'
|
||||||
|
import { projectsData } from '../../data/projectsData'
|
||||||
|
|
||||||
export function FeaturedProjects() {
|
export function FeaturedProjects() {
|
||||||
const { projects, status_labels } = useSite()
|
const { status_labels } = useSite()
|
||||||
const { t, href } = useLang()
|
const { t, href } = useLang()
|
||||||
const heading = useSectionHeading('featured_projects')
|
const heading = useSectionHeading('featured_projects')
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
|
|
||||||
const saveStatusLabel = (key: string, value: string) => {
|
const saveStatusLabel = (key: string, value: string) => {
|
||||||
const label = status_labels.find((s) => s.key === key)
|
const label = status_labels.find((s) => s.key === key)
|
||||||
if (!label) return
|
if (!label) return
|
||||||
@@ -30,8 +31,12 @@ export function FeaturedProjects() {
|
|||||||
{ key: 'done', label: useStatusLabel('done') },
|
{ key: 'done', label: useStatusLabel('done') },
|
||||||
{ key: 'progress', label: useStatusLabel('progress') },
|
{ key: 'progress', label: useStatusLabel('progress') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
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 (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
<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
|
const isSelected = status === filter.key
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={filter.key}>
|
<React.Fragment key={filter.key}>
|
||||||
{/* خط جداکننده بین دکمهها */}
|
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
<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] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="grid gap-6 lg:grid-cols-3"
|
className="grid gap-6 lg:grid-cols-3"
|
||||||
>
|
>
|
||||||
{list.map((project, i) => (
|
{list.map((project, i) => {
|
||||||
<motion.div
|
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||||
key={project.id}
|
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
return (
|
||||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
<motion.div
|
||||||
className="h-full"
|
key={project.id}
|
||||||
>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<EditableItem resource="projects" item={project} className="h-full">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<ProjectCard project={project} />
|
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||||
</EditableItem>
|
className="h-full"
|
||||||
</motion.div>
|
>
|
||||||
))}
|
<ProjectCard
|
||||||
|
project={project}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
detailHref={`/projects/${project.slug}`}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,11 +114,7 @@ export function FeaturedProjects() {
|
|||||||
{heading.cta_label && heading.cta_href && (
|
{heading.cta_label && heading.cta_href && (
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<div className="mt-12 flex justify-center">
|
||||||
<ButtonLink
|
<PremiumButton to={href(heading.cta_href)}>
|
||||||
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)]"
|
|
||||||
>
|
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={heading.cta_label}
|
value={heading.cta_label}
|
||||||
onSave={(v) =>
|
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">
|
</PremiumButton>
|
||||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
|
|
||||||
</span>
|
|
||||||
</ButtonLink>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function Gallery() {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{active !== null && gallery[active] && (
|
{active !== null && gallery[active] && (
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -74,49 +74,71 @@ export function Gallery() {
|
|||||||
aria-label={t('viewImage')}
|
aria-label={t('viewImage')}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
>
|
>
|
||||||
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
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')}
|
aria-label={t('close')}
|
||||||
>
|
>
|
||||||
<XIcon className="h-5 w-5" />
|
<XIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{/* Flex Container for Prev, Image, Next */}
|
||||||
type="button"
|
<div
|
||||||
onClick={(e) => {
|
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
|
||||||
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)]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
import { ChevronDownIcon } from 'lucide-react'
|
||||||
import { ButtonLink } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
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] }}
|
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-10 flex flex-wrap items-center gap-4"
|
className="mt-10 flex flex-wrap items-center gap-4"
|
||||||
>
|
>
|
||||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
{/* دکمه اصلی */}
|
||||||
<ButtonLink
|
<PremiumButton to={href(hero.cta_primary_href)}>
|
||||||
to={href(hero.cta_primary_href)}
|
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||||
variant="copper"
|
</PremiumButton>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
{/* دکمه ثانویه */}
|
||||||
<ButtonLink
|
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||||
</ButtonLink>
|
</PremiumButton>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</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>
|
</motion.div>
|
||||||
|
|
||||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { CheckIcon, SendIcon } from 'lucide-react'
|
import { CheckIcon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { Button } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
@@ -98,7 +98,7 @@ export function Newsletter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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" />
|
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||||
|
|
||||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||||
@@ -142,19 +142,18 @@ export function Newsletter() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="dark"
|
|
||||||
disabled={sending}
|
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 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>
|
<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 : (
|
{sending ? newsletter.sending_label : (
|
||||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,68 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
import { useLang, formatNumber } from '../../i18n/lang';
|
import { useLang, formatNumber } from '../../i18n/lang';
|
||||||
import { useFieldSaver } from '../../admin/AdminContext';
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
import { InlineEdit } from '../admin/InlineEdit';
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
|
|
||||||
function Counter({ value }: {value: number;}) {
|
function useCounter(targetNumber: number, duration: number = 2000) {
|
||||||
const { lang } = useLang();
|
const [count, setCount] = useState(0);
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
|
||||||
const [display, setDisplay] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inView) return;
|
const element = ref.current;
|
||||||
let frame = 0;
|
if (!element) return;
|
||||||
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]);
|
|
||||||
|
|
||||||
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() {
|
export function Stats() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ButtonLink } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
@@ -89,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
|
|
||||||
{showLink && (
|
{showLink && (
|
||||||
<Reveal delay={0.22}>
|
<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)} />
|
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
</PremiumButton>
|
||||||
</ButtonLink>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export function Team() {
|
|||||||
const { team } = useSite()
|
const { team } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
<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">
|
<div className="mx-auto max-w-site px-6 lg:px-10 ">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackTeamTitle')}
|
title={t('fallbackTeamTitle')}
|
||||||
lead={t('fallbackTeamLead')}
|
lead={t('fallbackTeamLead')}
|
||||||
@@ -22,7 +22,8 @@ export function Team() {
|
|||||||
{team.map((member, i) => (
|
{team.map((member, i) => (
|
||||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||||
<EditableItem resource="team" item={member} 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">
|
<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)}
|
{member.name.trim().charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,4 +41,4 @@ export function Team() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||||
|
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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 ${
|
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}>
|
<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="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" />
|
<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>
|
<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>
|
||||||
|
|
||||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
||||||
|
{/* خط پسزمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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
|
<motion.span
|
||||||
aria-hidden="true"
|
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 }}
|
style={{ scaleY }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
||||||
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
||||||
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
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,18 @@
|
|||||||
|
/**
|
||||||
|
* ثابتهای مشترک رابط کاربری — تنها منبع مقادیر پیشفرض (بدون متن پراکنده در کامپوننتها).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** عنوان پیشفرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
|
||||||
|
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
|
||||||
|
|
||||||
|
/** متن جایگزین پیشفرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمیدهد) */
|
||||||
|
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
|
||||||
|
|
||||||
|
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار میگیرد */
|
||||||
|
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
|
||||||
|
|
||||||
|
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
|
||||||
|
export const WORDS_PER_MINUTE = 200
|
||||||
|
|
||||||
|
/** برچسب زمان مطالعه در خروجی */
|
||||||
|
export const READING_TIME_LABEL = 'دقیقه مطالعه'
|
||||||
@@ -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;
|
units_en?: string;
|
||||||
client: string;
|
client: string;
|
||||||
client_en?: string;
|
client_en?: string;
|
||||||
|
/** مختصات جغرافیایی پروژه — عرض جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||||
|
lat?: number;
|
||||||
|
/** مختصات جغرافیایی پروژه — طول جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||||
|
lng?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
summary_en?: string;
|
summary_en?: string;
|
||||||
body: string[];
|
body: string[];
|
||||||
@@ -59,8 +63,27 @@ export type Article = {
|
|||||||
meta_description?: string;
|
meta_description?: string;
|
||||||
/** متن جایگزین تصویر شاخص (Alt) */
|
/** متن جایگزین تصویر شاخص (Alt) */
|
||||||
image_alt?: string;
|
image_alt?: string;
|
||||||
|
/** عنوان تصویر شاخص (Title) */
|
||||||
|
image_title?: string;
|
||||||
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده میشود */
|
||||||
author?: string;
|
author?: string;
|
||||||
/** هشتگها/برچسبهای مقاله */
|
/** هشتگها/برچسبهای مقاله */
|
||||||
tags?: string[];
|
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: 'پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران.',
|
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران.',
|
||||||
|
|
||||||
// کارت پروژه
|
// کارت پروژه
|
||||||
yearLabel: 'سال:',
|
|
||||||
sqm: 'متر مربع',
|
sqm: 'متر مربع',
|
||||||
|
|
||||||
// جزئیات پروژه
|
// جزئیات پروژه
|
||||||
projectNotFound: 'این پروژه پیدا نشد',
|
projectNotFound: 'این پروژه پیدا نشد',
|
||||||
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
||||||
@@ -106,6 +104,18 @@ export const fa = {
|
|||||||
nextTestimonial: 'نظر بعدی',
|
nextTestimonial: 'نظر بعدی',
|
||||||
selectTestimonialAria: 'انتخاب نظر',
|
selectTestimonialAria: 'انتخاب نظر',
|
||||||
testimonialByPrefix: 'نظر',
|
testimonialByPrefix: 'نظر',
|
||||||
|
|
||||||
|
// نقشه پروژهها (مپلیبر)
|
||||||
|
mapAria: 'نقشه موقعیت پروژهها',
|
||||||
|
mapLoading: 'در حال بارگذاری نقشه…',
|
||||||
|
mapErrorText: 'بارگذاری نقشه ناموفق بود — اتصال به سرور کاشیها را بررسی کنید.',
|
||||||
|
viewProject: 'مشاهده پروژه',
|
||||||
|
mapFullscreen: 'نمایش تمامصفحه',
|
||||||
|
mapExitFullscreen: 'خروج از تمامصفحه',
|
||||||
|
viewAddress: 'مشاهده آدرس پروژه',
|
||||||
|
mapNewBadge: 'جدید',
|
||||||
|
mapProjectDescription: 'پروژه اجراشده در اصفهان',
|
||||||
|
mapImagePlaceholder: 'تصویر پروژه',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Dict = typeof fa
|
export type Dict = typeof fa
|
||||||
@@ -151,7 +161,6 @@ export const en: Dict = {
|
|||||||
fallbackFaqTitle: 'Frequently Asked Questions',
|
fallbackFaqTitle: 'Frequently Asked Questions',
|
||||||
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
|
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
|
||||||
|
|
||||||
yearLabel: 'Year:',
|
|
||||||
sqm: 'm²',
|
sqm: 'm²',
|
||||||
|
|
||||||
projectNotFound: 'Project not found',
|
projectNotFound: 'Project not found',
|
||||||
@@ -206,6 +215,17 @@ export const en: Dict = {
|
|||||||
nextTestimonial: 'Next testimonial',
|
nextTestimonial: 'Next testimonial',
|
||||||
selectTestimonialAria: 'Select testimonial',
|
selectTestimonialAria: 'Select testimonial',
|
||||||
testimonialByPrefix: 'Testimonial by',
|
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 }
|
export const dict = { fa, en }
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ html[lang='en'] .article-body h3 {
|
|||||||
.article-body > :first-child {
|
.article-body > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
/* ---------- تایپوگرافی مقاله ----------
|
||||||
|
راستچین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
|
||||||
|
چپچین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
|
||||||
|
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین میشود. */
|
||||||
.article-body h1,
|
.article-body h1,
|
||||||
.article-body h2,
|
.article-body h2,
|
||||||
.article-body h3 {
|
.article-body h3 {
|
||||||
@@ -122,15 +126,64 @@ html[lang='en'] .article-body h3 {
|
|||||||
scroll-margin-top: 7rem;
|
scroll-margin-top: 7rem;
|
||||||
}
|
}
|
||||||
.article-body h1 {
|
.article-body h1 {
|
||||||
font-size: 18px; /* تیتر ۱ سایز ۱۸ */
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
.article-body h2 {
|
.article-body h2 {
|
||||||
font-size: 16px; /* تیتر ۲ سایز ۱۶ */
|
font-size: 16px;
|
||||||
padding-inline-start: 0.85rem;
|
padding-inline-start: 0.85rem;
|
||||||
border-inline-start: 3px solid #d5874c;
|
border-inline-start: 3px solid #d5874c;
|
||||||
}
|
}
|
||||||
.article-body h3 {
|
.article-body h3 {
|
||||||
font-size: 14px; /* تیتر ۳ سایز ۱۴ */
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
[data-dir='rtl'] .article-body {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body {
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
[data-dir='ltr'] .article-body h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ----------
|
||||||
|
متن لاتین همیشه ۲ پیکسل کوچکتر از متن فارسیِ همان بلوک است:
|
||||||
|
تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲.
|
||||||
|
calc(1em - 2px) نسبت به اندازه همان بلوک حساب میشود، پس قاعده در همه سطحها درست
|
||||||
|
میماند و خط پایه هم جابهجا نمیشود (line-height نسبی است).
|
||||||
|
در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچکتر است و واژههای فارسی
|
||||||
|
۲ پیکسل بزرگتر میشوند تا تفاضل همیشه ۲ پیکسل بماند. */
|
||||||
|
.article-body .latin,
|
||||||
|
.article-body .persian,
|
||||||
|
.article-body .english-text {
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body .latin,
|
||||||
|
.article-body .english-text {
|
||||||
|
font-size: calc(1em - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body .persian {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dir='ltr'] .article-body .latin,
|
||||||
|
[data-dir='ltr'] .article-body .english-text {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dir='ltr'] .article-body .persian {
|
||||||
|
font-size: calc(1em + 2px);
|
||||||
}
|
}
|
||||||
.article-body span[data-small="true"] {
|
.article-body span[data-small="true"] {
|
||||||
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
|
||||||
@@ -139,7 +192,7 @@ html[lang='en'] .article-body h3 {
|
|||||||
.english-text {
|
.english-text {
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
unicode-bidi: isolate;
|
unicode-bidi: isolate;
|
||||||
font-size: 12px;
|
/* اندازه از قواعد دوزبانه بالا میآید: ۲ پیکسل کوچکتر از متن فارسی همان بلوک */
|
||||||
}
|
}
|
||||||
.article-body ul,
|
.article-body ul,
|
||||||
.article-body ol {
|
.article-body ol {
|
||||||
@@ -183,6 +236,35 @@ html[lang='en'] .article-body h3 {
|
|||||||
border: 1px solid rgba(245, 243, 239, 0.1);
|
border: 1px solid rgba(245, 243, 239, 0.1);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
/* ---------- تصویر کپشندار: <figure><img><figcaption> ---------- */
|
||||||
|
.article-body figure {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.article-body figure img {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.article-body figcaption {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
color: rgba(245, 243, 239, 0.5);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* ویرایشگر: کپشن ورودی و دکمه حذف زیر تصویر */
|
||||||
|
[data-wysiwyg] .article-figure-node {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
[data-wysiwyg] .article-figure__caption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
|
||||||
|
outline: 1px dashed rgba(213, 135, 76, 0.8);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
.article-body strong {
|
.article-body strong {
|
||||||
color: #f5f3ef;
|
color: #f5f3ef;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -284,4 +366,78 @@ html[lang='en'] .article-body h3 {
|
|||||||
}
|
}
|
||||||
.aramesh-datepicker .rmdp-action-button {
|
.aramesh-datepicker .rmdp-action-button {
|
||||||
color: #d5874c;
|
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 React from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react';
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { ButtonLink } from '../components/Button';
|
import { PremiumButton } from '../components/PremiumButton';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ArticleMeta } from '../components/ArticleMeta';
|
import { ArticleMeta } from '../components/ArticleMeta';
|
||||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||||
@@ -13,17 +13,23 @@ import { useLang } from '../i18n/lang';
|
|||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||||
|
import { docToText } from '../utils/tiptapDoc';
|
||||||
|
import { detectDirection } from '../utils/direction';
|
||||||
|
import { wrapScriptRuns } from '../utils/bidiText';
|
||||||
import type { Article } from '../data/site';
|
import type { Article } from '../data/site';
|
||||||
|
|
||||||
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||||
function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
function renderBody(
|
||||||
|
article: Article,
|
||||||
|
dir: 'rtl' | 'ltr',
|
||||||
|
): { html: string | null; toc: TocItem[]; legacy: string[] } {
|
||||||
const body = article.body as unknown;
|
const body = article.body as unknown;
|
||||||
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
|
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
|
||||||
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
|
||||||
try {
|
try {
|
||||||
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
|
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
|
||||||
// تیترهای H2/H3 شناسه لنگر میگیرند تا فهرست مطالب به آنها بپرد
|
// تیترها شناسه لنگر میگیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچکتر» بستهبندی میشود
|
||||||
const { items, html: htmlWithIds } = extractToc(html);
|
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
|
||||||
return { html: htmlWithIds, toc: items, legacy: [] };
|
return { html: htmlWithIds, toc: items, legacy: [] };
|
||||||
} catch {
|
} catch {
|
||||||
return { html: null, toc: [], legacy: [] };
|
return { html: null, toc: [], legacy: [] };
|
||||||
@@ -68,23 +74,34 @@ export function ArticleDetail() {
|
|||||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
<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>
|
<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>
|
<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')}
|
{t('backToBlog')}
|
||||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
</PremiumButton>
|
||||||
</ButtonLink>
|
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { html, toc, legacy } = renderBody(article);
|
const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
|
||||||
|
const { html, toc, legacy } = renderBody(article, dir);
|
||||||
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
|
||||||
|
* از همین پیروی میکنند: فارسی راستبهچپ (فهرست سمت راست)، انگلیسی چپبهراست (فهرست سمت چپ).
|
||||||
|
*/
|
||||||
|
const hasToc = toc.length > 0;
|
||||||
|
// در چیدمان راستبهچپ ستون شماره ۱ سمت راست است؛ پس ستونها صریح شماره میخورند
|
||||||
|
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
|
||||||
|
const bodyColumns = 'lg:col-start-2';
|
||||||
|
const tocColumns = 'lg:col-start-1';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
<section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={article.image}
|
src={article.image}
|
||||||
alt={article.image_alt || article.title}
|
alt={article.image_alt || article.title}
|
||||||
|
title={article.image_title || undefined}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
||||||
@@ -98,11 +115,11 @@ export function ArticleDetail() {
|
|||||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||||
{t('home')}
|
{t('home')}
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||||
<Link to={href('/blog')} className="transition-colors hover:text-copper">
|
<Link to={href('/blog')} className="transition-colors hover:text-copper">
|
||||||
{t('blog')}
|
{t('blog')}
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
|
||||||
<span className="text-copper">{article.category}</span>
|
<span className="text-copper">{article.category}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
|
||||||
@@ -114,10 +131,14 @@ export function ArticleDetail() {
|
|||||||
|
|
||||||
<article className="bg-ink py-20 lg:py-28">
|
<article className="bg-ink py-20 lg:py-28">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14">
|
<div
|
||||||
<div className="min-w-0">
|
dir={dir}
|
||||||
|
data-dir={dir}
|
||||||
|
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
|
||||||
|
>
|
||||||
|
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80">
|
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
|
||||||
{article.excerpt}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -136,9 +157,12 @@ export function ArticleDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان میماند */}
|
{/* فهرست مطالب — در دسکتاپ کنار متن میچسبد و همراه مقاله پایین میآید */}
|
||||||
{toc.length > 0 && (
|
{hasToc && (
|
||||||
<aside className="order-first lg:order-none">
|
<aside
|
||||||
|
dir={dir}
|
||||||
|
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
|
||||||
|
>
|
||||||
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useLang } from '../i18n/lang'
|
|||||||
import { EditableItem } from '../admin/Editable'
|
import { EditableItem } from '../admin/Editable'
|
||||||
import { useAdmin } from '../admin/AdminContext'
|
import { useAdmin } from '../admin/AdminContext'
|
||||||
import { formatJalali } from '../utils/date'
|
import { formatJalali } from '../utils/date'
|
||||||
|
import { toPersianDigits } from '../utils/digits'
|
||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
function DraftBadge({ className = '' }: { className?: string }) {
|
function DraftBadge({ className = '' }: { className?: string }) {
|
||||||
@@ -98,7 +99,7 @@ export function Blog() {
|
|||||||
<span>{formatJalali(featured.date)}</span>
|
<span>{formatJalali(featured.date)}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<ClockIcon className="h-3.5 w-3.5" />
|
<ClockIcon className="h-3.5 w-3.5" />
|
||||||
{featured.read}
|
{toPersianDigits(featured.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2 text-copper">
|
<span className="flex items-center gap-2 text-copper">
|
||||||
|
|||||||
@@ -14,25 +14,8 @@ export function ContactPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHero pageKey="contact" />
|
<PageHero pageKey="contact" />
|
||||||
|
|
||||||
<section className="bg-ink py-24" aria-label={t('officesAria')}>
|
<section className="bg-ink py-2" 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>
|
</section>
|
||||||
|
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function Home() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Story />
|
|
||||||
<Stats />
|
<Stats />
|
||||||
|
<Story />
|
||||||
<Services />
|
<Services />
|
||||||
<BeforeAfter />
|
<BeforeAfter />
|
||||||
<FeaturedProjects />
|
<FeaturedProjects />
|
||||||
|
|||||||
@@ -1,74 +1,98 @@
|
|||||||
import React, { useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
|
import { ChevronLeftIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { ButtonLink } from '../components/Button'
|
import { PremiumButton } from '../components/PremiumButton'
|
||||||
import { ProjectCard } from '../components/ProjectCard'
|
import { ProjectCard } from '../components/ProjectCard'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Lightbox } from '../components/Lightbox'
|
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 { useSite, useStatusLabel } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
|
import { projectsData } from '../data/projectsData';
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const { projects, brand } = useSite()
|
const { projects, brand } = useSite()
|
||||||
const { t, href, lang } = useLang()
|
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)
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
|
||||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
if (!currentProject) {
|
||||||
useEffect(() => {
|
return <div className="text-center py-20 text-ivory">Project not found</div>
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = [
|
const facts = [
|
||||||
{ label: t('specLocation'), value: project.location },
|
{ label: t('specLocation'), value: displayLocation },
|
||||||
{ label: t('specYear'), value: project.year },
|
{ label: t('specYear'), value: year },
|
||||||
{ label: t('specArea'), value: project.area },
|
{ label: t('specArea'), value: displayArea },
|
||||||
{ label: t('specFloors'), value: project.floors },
|
{ label: t('specFloors'), value: floors },
|
||||||
{ label: t('specUnits'), value: project.units },
|
{ label: t('specUnits'), value: units },
|
||||||
{ label: t('specClient'), value: project.client },
|
{ label: t('specClient'), value: client },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}>
|
||||||
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
|
||||||
<div
|
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||||
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">
|
|
||||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||||
{t('home')}
|
{t('home')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -77,75 +101,80 @@ export function ProjectDetail() {
|
|||||||
{t('projects')}
|
{t('projects')}
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
<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>
|
</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="mb-8">
|
||||||
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
|
||||||
|
</div>
|
||||||
<Reveal className="order-1">
|
|
||||||
<span className="flex items-center gap-3 text-sm font-light text-copper">
|
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
||||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]">
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
|
|
||||||
|
|
||||||
{project.body.map((para) => (
|
{/* Main large image */}
|
||||||
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
|
<button
|
||||||
{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
|
|
||||||
type="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)}
|
onClick={() => setLightbox(0)}
|
||||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
|
||||||
>
|
>
|
||||||
<img
|
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
|
||||||
{project.gallery.slice(1).map((img, i) => (
|
{/* 2x2 Small images */}
|
||||||
<button
|
<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">
|
||||||
key={img}
|
{mapImages.slice(1, 5).map((img, i) => (
|
||||||
|
<button
|
||||||
|
key={img}
|
||||||
type="button"
|
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)}
|
onClick={() => setLightbox(i + 1)}
|
||||||
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
|
||||||
>
|
>
|
||||||
<img
|
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
||||||
src={img}
|
|
||||||
alt=""
|
|
||||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -154,17 +183,24 @@ export function ProjectDetail() {
|
|||||||
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
<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-6 h-px w-24 bg-copper/70" />
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{related.map((item, i) => (
|
{related.map((item, i) => {
|
||||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||||
<ProjectCard project={item} />
|
return (
|
||||||
</Reveal>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Lightbox
|
<Lightbox
|
||||||
images={project.gallery}
|
images={gallery}
|
||||||
index={lightbox}
|
index={lightbox}
|
||||||
onClose={() => setLightbox(null)}
|
onClose={() => setLightbox(null)}
|
||||||
onChange={setLightbox}
|
onChange={setLightbox}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { PageHero } from '../components/PageHero';
|
import { PageHero } from '../components/PageHero';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { ProjectCard } from '../components/ProjectCard';
|
import { ProjectCard } from '../components/ProjectCard';
|
||||||
|
import { PremiumButton } from '../components/PremiumButton';
|
||||||
import { Gallery } from '../components/home/Gallery';
|
import { Gallery } from '../components/home/Gallery';
|
||||||
import { ContactSection } from '../components/ContactSection';
|
import { ContactSection } from '../components/ContactSection';
|
||||||
import type { ProjectStatus } from '../data/site';
|
|
||||||
import { useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { useFieldSaver } from '../admin/AdminContext';
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
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() {
|
export function Projects() {
|
||||||
const { projects, status_labels } = useSite();
|
const { status_labels } = useSite();
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
|
||||||
@@ -27,27 +29,79 @@ export function Projects() {
|
|||||||
|
|
||||||
const doneLabel = useStatusLabel('done');
|
const doneLabel = useStatusLabel('done');
|
||||||
const progressLabel = useStatusLabel('progress');
|
const progressLabel = useStatusLabel('progress');
|
||||||
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
const FILTERS: { key: 'done' | 'progress'; label: string; }[] = [
|
||||||
{ key: 'done', label: doneLabel },
|
{ key: 'done', label: doneLabel },
|
||||||
{ key: 'progress', label: progressLabel }
|
{ key: 'progress', label: progressLabel }
|
||||||
];
|
];
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done');
|
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
||||||
const list = projects.filter((p) => p.status === status);
|
|
||||||
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero pageKey="projects" />
|
<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')}>
|
<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="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')}>
|
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||||
{FILTERS.map((filter, index) => {
|
{FILTERS.map((filter, index) => {
|
||||||
const isSelected = status === filter.key;
|
const isSelected = status === filter.key;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={filter.key}>
|
<React.Fragment key={filter.key}>
|
||||||
{/* خط جداکننده بین دکمهها */}
|
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
@@ -56,7 +110,7 @@ export function Projects() {
|
|||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isSelected}
|
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 ${
|
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
|
isSelected
|
||||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||||
@@ -76,15 +130,34 @@ export function Projects() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{list.map((project, i) => (
|
{displayedProjects.map((project, index) => {
|
||||||
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
const dummyImageName = `${(index % 6) + 1}.png`;
|
||||||
<EditableItem resource="projects" item={project} className="h-full">
|
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||||
<ProjectCard project={project} imageClass="h-80" />
|
|
||||||
</EditableItem>
|
return (
|
||||||
</Reveal>
|
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
||||||
))}
|
<ProjectCard
|
||||||
<AddItemButton resource="projects" />
|
project={project}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
detailHref={`/projects/${project.slug}`}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -92,4 +165,4 @@ export function Projects() {
|
|||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* دوزبانهسازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچکتر» از متن
|
||||||
|
* فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در
|
||||||
|
* مقاله انگلیسی برعکس، واژههای فارسی ۲ پیکسل بزرگتر میشوند.
|
||||||
|
*
|
||||||
|
* اجرای غیرغالب متن با یک span جهتدار جدا میشود تا نه جهت متن بههم بریزد
|
||||||
|
* (unicode-bidi: isolate) و نه خط پایه جابهجا شود؛ اندازه در CSS با
|
||||||
|
* calc(1em - 2px) نسبت به همان بلوک حساب میشود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** اجرای متن لاتین — حروف/ارقام لاتین بههمراه فاصله و نشانههای داخلی */
|
||||||
|
const LATIN_PATTERN =
|
||||||
|
"[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*"
|
||||||
|
|
||||||
|
/** اجرای متن فارسی/عربی — با نیمفاصله و ارقام فارسی */
|
||||||
|
const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*'
|
||||||
|
|
||||||
|
/** کلاس span متن لاتین و متن فارسی — همینها در CSS اندازه ۲ پیکسل تفاضلی میگیرند */
|
||||||
|
export const LATIN_CLASS = 'latin'
|
||||||
|
export const RTL_CLASS = 'persian'
|
||||||
|
|
||||||
|
/** عناصری که متنشان دستنخورده میماند (کد، مارکهای قدیمی و اجرای بستهبندیشده) */
|
||||||
|
const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian'
|
||||||
|
|
||||||
|
/** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */
|
||||||
|
export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] {
|
||||||
|
const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g')
|
||||||
|
const runs: { from: number; to: number }[] = []
|
||||||
|
|
||||||
|
for (const match of text.matchAll(scan)) {
|
||||||
|
const from = match.index ?? 0
|
||||||
|
runs.push({ from, to: from + match[0].length })
|
||||||
|
}
|
||||||
|
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بستهبندی اجرای غیرغالب همه گرههای متنی داخل یک ریشه HTML.
|
||||||
|
* در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا میشود.
|
||||||
|
*/
|
||||||
|
export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void {
|
||||||
|
const document = root.ownerDocument
|
||||||
|
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
|
||||||
|
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
|
||||||
|
|
||||||
|
const texts: Text[] = []
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
|
const text = node as Text
|
||||||
|
if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue
|
||||||
|
if (!text.data.trim()) continue
|
||||||
|
if (findScriptRuns(text.data, dir).length) texts.push(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const text of texts) {
|
||||||
|
const value = text.data
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
for (const run of findScriptRuns(value, dir)) {
|
||||||
|
if (run.from > cursor) fragment.append(value.slice(cursor, run.from))
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = className
|
||||||
|
span.setAttribute('dir', runDir)
|
||||||
|
span.textContent = value.slice(run.from, run.to)
|
||||||
|
fragment.append(span)
|
||||||
|
cursor = run.to
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < value.length) fragment.append(value.slice(cursor))
|
||||||
|
text.replaceWith(fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||||
|
|
||||||
|
/** تبدیل ارقام لاتین به ارقام فارسی — برای نمایش یکدست فارسی */
|
||||||
|
export function toPersianDigits(value: string | number): string {
|
||||||
|
return String(value).replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)])
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* تشخیص جهت غالب متن — پایه چیدمان مقاله و فهرست مطالب.
|
||||||
|
* سایت فارسی است؛ اما مقالهای که متنش لاتین است باید چپبهراست خوانده شود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** خطهای راستبهچپ: عبری، عربی و فارسی */
|
||||||
|
const RTL_CHARS = /[\u0590-\u05ff\u0600-\u06ff\u0700-\u074f\ufb50-\ufdff\ufe70-\ufeff]/
|
||||||
|
/** ارقام فارسی/عربی — در شمارش جهت بیطرفاند */
|
||||||
|
const RTL_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/
|
||||||
|
/** حروف لاتین (با علامتها) */
|
||||||
|
const LTR_CHARS = /[A-Za-z\u00c0-\u024f]/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جهت غالب نمونههای متن: فارسی/عربی → rtl و لاتین → ltr.
|
||||||
|
* در حالت مساوی (مثلاً متن کوتاه یا فقط رقم) پیشفرض سایت، راستبهچپ، برمیگردد.
|
||||||
|
*/
|
||||||
|
export function detectDirection(...samples: (string | null | undefined)[]): 'rtl' | 'ltr' {
|
||||||
|
let rtl = 0
|
||||||
|
let ltr = 0
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
for (const char of sample ?? '') {
|
||||||
|
if (RTL_DIGITS.test(char)) continue
|
||||||
|
if (RTL_CHARS.test(char)) rtl += 1
|
||||||
|
else if (LTR_CHARS.test(char)) ltr += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtl >= ltr ? 'rtl' : 'ltr'
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** پروتکلها و الگوهای مجاز برای نشانی پیوند — ویرایشگر و مودال لینک هر دو از اینها استفاده میکنند */
|
||||||
|
const ALLOWED_URI = /^(https?:\/\/|\/|#|mailto:|tel:)/i
|
||||||
|
const BARE_DOMAIN = /^[\w-]+(\.[\w-]+)+([/?#].*)?$/i
|
||||||
|
/** نشانی کامل بدون فاصله — برای تشخیص متن چسباندهشده یا تایپشده */
|
||||||
|
const URL_LIKE = /^(https?:\/\/\S+|www\.\S+|[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?)$/i
|
||||||
|
|
||||||
|
/** آیا متن یک نشانی کامل است؟ (برای بازکردن خودکار مودال لینک) */
|
||||||
|
export function isUrlLike(value: string): boolean {
|
||||||
|
return URL_LIKE.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیا نشانی برای درج در لینک مجاز است؟ */
|
||||||
|
export function isAllowedHref(uri: string): boolean {
|
||||||
|
return ALLOWED_URI.test(uri.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نرمالسازی نشانی ورودی کاربر:
|
||||||
|
* «example.com» → «https://example.com» و ورودی خالی/نامعتبر → null
|
||||||
|
*/
|
||||||
|
export function normalizeHref(raw: string): string | null {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (!value) return null
|
||||||
|
if (isAllowedHref(value)) return value
|
||||||
|
if (BARE_DOMAIN.test(value)) return `https://${value}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkOptions = {
|
||||||
|
/** باز شدن در تب جدید */
|
||||||
|
newTab: boolean
|
||||||
|
/** عدم انتقال اعتبار سئو (nofollow) */
|
||||||
|
nofollow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ساخت مقدار rel بر اساس گزینههای انتخابی کاربر */
|
||||||
|
export function buildLinkRel({ newTab, nofollow }: LinkOptions): string | null {
|
||||||
|
const tokens: string[] = []
|
||||||
|
if (newTab) tokens.push('noopener', 'noreferrer')
|
||||||
|
if (nofollow) tokens.push('nofollow')
|
||||||
|
return tokens.length ? tokens.join(' ') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** خواندن گزینه nofollow از مقدار rel لینک موجود */
|
||||||
|
export function parseLinkRel(rel?: string | null): LinkOptions {
|
||||||
|
const tokens = (rel ?? '').split(/\s+/).filter(Boolean)
|
||||||
|
return { newTab: tokens.includes('noopener') || tokens.includes('noreferrer'), nofollow: tokens.includes('nofollow') }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { READING_TIME_LABEL, WORDS_PER_MINUTE } from '../data/constants'
|
||||||
|
import { toPersianDigits } from './digits'
|
||||||
|
import { docToText } from './tiptapDoc'
|
||||||
|
|
||||||
|
export { WORDS_PER_MINUTE, READING_TIME_LABEL }
|
||||||
|
|
||||||
|
/** شمارش واژههای متن سند (بدون شمارش نویسههای قالببندی) */
|
||||||
|
export function countWords(body: unknown): number {
|
||||||
|
return docToText(body)
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** زمان مطالعه بر حسب دقیقه — سقف واژهها تقسیم بر سرعت مطالعه، حداقل یک دقیقه */
|
||||||
|
export function readingTimeMinutes(body: unknown): number {
|
||||||
|
return Math.max(1, Math.ceil(countWords(body) / WORDS_PER_MINUTE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** زمان مطالعه آماده نمایش با ارقام فارسی — مثل «۵ دقیقه مطالعه» */
|
||||||
|
export function formatReadingTime(body: unknown): string {
|
||||||
|
return `${toPersianDigits(readingTimeMinutes(body))} ${READING_TIME_LABEL}`
|
||||||
|
}
|
||||||
@@ -1,31 +1,27 @@
|
|||||||
import slugify from 'slugify'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن.
|
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
|
||||||
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی میکند تا لنگرهای
|
* حروف و اعداد فارسی/عربی و لاتین در شناسه میمانند (در URL بهصورت percent-encoded
|
||||||
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراکگذاری بمانند.
|
* نوشته میشوند و در همه مرورگرها کار میکنند)؛ فقط نشانهها و فاصلهها به خط تیره
|
||||||
|
* تبدیل میشوند. نیمفاصله و نویسههای نامرئی جهت هم حذف میشوند تا تیترهای تکراری
|
||||||
|
* شناسه یکتا بگیرند.
|
||||||
*/
|
*/
|
||||||
export function slugifyFa(input: string): string {
|
export function slugifyFa(input: string): string {
|
||||||
const normalized = (input ?? '')
|
return (input ?? '')
|
||||||
.toString()
|
.toString()
|
||||||
.normalize('NFC')
|
.normalize('NFC')
|
||||||
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → فاصله
|
// نیمفاصله، اتصالدهنده و نویسههای نامرئی جهت → حذف
|
||||||
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ')
|
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||||
// یکسانسازی حروف عربی با معادل فارسی
|
// یکسانسازی حروف عربی با معادل فارسی
|
||||||
.replace(/[يى]/g, 'ی')
|
.replace(/[يى]/g, 'ی')
|
||||||
.replace(/ك/g, 'ک')
|
.replace(/ك/g, 'ک')
|
||||||
.replace(/[ۀة]/g, 'ه')
|
.replace(/[ۀة]/g, 'ه')
|
||||||
|
.toLowerCase()
|
||||||
|
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
|
||||||
|
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||||
.trim()
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
const slug = slugify(normalized, {
|
.replace(/-{2,}/g, '-')
|
||||||
lower: true,
|
.replace(/^-+|-+$/g, '')
|
||||||
trim: true,
|
|
||||||
replacement: '-',
|
|
||||||
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانهها حذف شوند
|
|
||||||
remove: /[^\w\s-]/g,
|
|
||||||
})
|
|
||||||
|
|
||||||
return slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی میگیرند (مثل about، about-2) */
|
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی میگیرند (مثل about، about-2) */
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* کمکتابعهای مشترک سند TipTap — استخراج متن و ساختار از JSON مقاله.
|
||||||
|
* هم چکلیست سئو و هم محاسبه زمان مطالعه از همینجا استفاده میکنند (DRY).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ArticleBodyDoc } from '../data/site'
|
||||||
|
|
||||||
|
type DocNode = {
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
attrs?: Record<string, unknown>
|
||||||
|
marks?: { type?: string; attrs?: Record<string, unknown> }[]
|
||||||
|
content?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node: unknown, visit: (node: DocNode) => void): void {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
const current = node as DocNode
|
||||||
|
visit(current)
|
||||||
|
for (const child of current.content ?? []) walk(child, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن یک سند TipTap (یا آرایه پاراگرافهای قدیمی) */
|
||||||
|
export function docToText(body: unknown): string {
|
||||||
|
if (Array.isArray(body)) return body.join(' ')
|
||||||
|
const parts: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type === 'text' && node.text) parts.push(node.text)
|
||||||
|
})
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
||||||
|
export function docHeadings(body: unknown, level = 2): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const headings: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type !== 'heading' || node.attrs?.level !== level) return
|
||||||
|
const texts: string[] = []
|
||||||
|
walk(node, (inner) => {
|
||||||
|
if (inner.type === 'text' && inner.text) texts.push(inner.text)
|
||||||
|
})
|
||||||
|
headings.push(texts.join(''))
|
||||||
|
})
|
||||||
|
return headings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تصاویر سند با متن جایگزین */
|
||||||
|
export function docImages(body: unknown): { src: string; alt: string }[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const images: { src: string; alt: string }[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
if (node.type !== 'image') return
|
||||||
|
images.push({ src: String(node.attrs?.src ?? ''), alt: String(node.attrs?.alt ?? '') })
|
||||||
|
})
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نشانی همه پیوندهای سند */
|
||||||
|
export function docLinks(body: unknown): string[] {
|
||||||
|
if (Array.isArray(body)) return []
|
||||||
|
const links: string[] = []
|
||||||
|
walk(body, (node) => {
|
||||||
|
for (const mark of node.marks ?? []) {
|
||||||
|
if (mark.type === 'link' && mark.attrs?.href) links.push(String(mark.attrs.href))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
/** متن نخستین پاراگراف — برای بررسی کلمه کلیدی در ابتدای متن */
|
||||||
|
export function firstParagraph(body: unknown): string {
|
||||||
|
if (Array.isArray(body)) return body[0] ?? ''
|
||||||
|
const content = (body as { content?: { type?: string }[] } | null)?.content
|
||||||
|
const paragraph = content?.find((child) => child?.type === 'paragraph')
|
||||||
|
return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as ArticleBodyDoc
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تبدیل بدنه ذخیرهشده مقاله به سند TipTap برای بارگذاری در ویرایشگر.
|
||||||
|
* ردیفهای قدیمی متن را بهصورت آرایه پاراگراف نگه داشتهاند و ممکن است رشته JSON هم
|
||||||
|
* برگردد؛ هر دو شکل به سند تبدیل میشوند تا هنگام ویرایش، متن قبلی از دست نرود.
|
||||||
|
*/
|
||||||
|
export function toArticleBodyDoc(body: unknown): ArticleBodyDoc {
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
const paragraphs = body
|
||||||
|
.map((paragraph) => String(paragraph ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] }))
|
||||||
|
return paragraphs.length ? { type: 'doc', content: paragraphs } : { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try {
|
||||||
|
return toArticleBodyDoc(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
return { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body && typeof body === 'object') return body as ArticleBodyDoc
|
||||||
|
return { ...EMPTY_DOC }
|
||||||
|
}
|
||||||
@@ -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,11 +4,19 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// وبورکر مپلیبر از node_modules ساخته و بهعنوان دارایی خروجی منتشر میشود؛
|
||||||
|
// در src/map/mapWorker.ts با setWorkerUrl به آن اشاره میکنیم (نسخهی
|
||||||
|
// پیشفرض مپلیبر کنار باندل است و در خروجی ویت وجود ندارد).
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
// در حالت توسعه، /api و /media به Traefik پروکسی میشوند (پورت ۵۱۷۳ روی هاست)
|
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||||
|
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// در حالت توسعه، درخواستهای API و Media به Traefik (پورت ۵۱۷۳) فرستاده میشوند
|
||||||
'/api': 'http://localhost:5173',
|
'/api': 'http://localhost:5173',
|
||||||
'/media': 'http://localhost:5173',
|
'/media': 'http://localhost:5173',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1009 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 781 KiB |