Author SHA1 Message Date
fatemeh sane 853ad67db5 feat(ui): update UI v1 2026-09-25 22:49:30 +03:30
MMD 805f4594d4 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/pages/ArticleDetail.tsx
2026-09-17 12:31:04 +03:30
fatemeh sane 7a0defc3d4 fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography 2026-09-17 10:10:50 +03:30
MMD fe6957bf83 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
2026-09-15 14:41:46 +03:30
fatemeh sane 049291c109 feat(admin): enhance editor UI (links, image captions) & fix categories
Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
2026-09-15 11:33:04 +03:30
MMD 53673d0f9b Merge branch 'bilingual-en'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
#	frontend/src/pages/ArticleDetail.tsx
2026-09-13 18:45:06 +03:30
MMD 9d55f521a0 Merge remote-tracking branch 'origin/dashboard/blog-changes' 2026-09-13 18:29:51 +03:30
MMD 35c03ef35e اصلاح عنوان تب صفحات جزئیات — brand به‌جای brand.name 2026-09-13 18:29:34 +03:30
fatemeh sane bb6c586d99 Update blog editor features 2026-09-13 10:39:25 +03:30
fatemeh sane 20c95e73b3 Merge branch 'site-v2' of https://git.bennu-api-bahrestan.ir/Behrad/aramesh-backend 2026-09-12 19:19:45 +03:30
111 changed files with 13263 additions and 896 deletions
File diff suppressed because it is too large Load Diff
+42 -4
View File
@@ -1,11 +1,42 @@
# Aramesh Construction Group — Frontend
A React + Vite + Tailwind CSS application. 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
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering.
## 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.
**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
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
* **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.
* **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 & 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.
* **Custom Tiptap WYSIWYG Editor:**
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
* **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:**
* 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.
## Development Setup
@@ -36,3 +67,10 @@ npm run build
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
```
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، می‌توانید از این کامیت یک‌خطی استفاده کنید:
```bash
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
+401 -5
View File
@@ -17,9 +17,16 @@
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"maplibre-gl": "^6.10.0",
"pmtiles": "^4.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
"slugify": "^1.6.9",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
@@ -933,7 +940,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
@@ -943,7 +949,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
@@ -953,8 +958,7 @@
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
@@ -1040,6 +1044,92 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.4",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1972,6 +2062,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2020,6 +2116,15 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
@@ -2440,6 +2545,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2763,6 +2877,22 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.407",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
@@ -3137,6 +3267,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3290,6 +3426,12 @@
"node": ">=6.9.0"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -3617,6 +3759,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -3630,6 +3778,12 @@
"node": ">=6"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3733,6 +3887,45 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/maplibre-gl": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
"@maplibre/mlt": "^1.3.0",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"bidi-js": "^1.1.0",
"earcut": "^3.2.3",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3770,6 +3963,15 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
@@ -3791,6 +3993,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -3860,7 +4068,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4017,6 +4224,18 @@
"node": ">=8"
}
},
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4056,6 +4275,15 @@
"node": ">= 6"
}
},
"node_modules/pmtiles": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
"license": "BSD-3-Clause",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -4228,6 +4456,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4238,6 +4472,17 @@
"node": ">= 0.8.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
@@ -4377,6 +4622,12 @@
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4408,6 +4659,12 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4420,6 +4677,12 @@
"node": ">=0.10.0"
}
},
"node_modules/react-date-object": {
"version": "2.1.9",
"resolved": "https://registry.npmmirror.com/react-date-object/-/react-date-object-2.1.9.tgz",
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT"
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -4433,12 +4696,61 @@
"react": "^18.3.1"
}
},
"node_modules/react-element-popper": {
"version": "2.1.7",
"resolved": "https://registry.npmmirror.com/react-element-popper/-/react-element-popper-2.1.7.tgz",
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
"license": "MIT",
"peerDependencies": {
"react": ">=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": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-multi-date-picker": {
"version": "4.5.2",
"resolved": "https://registry.npmmirror.com/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
"license": "MIT",
"dependencies": {
"react-date-object": "^2.1.8",
"react-element-popper": "^2.1.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4481,6 +4793,43 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-select": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4504,6 +4853,15 @@
"node": ">=8.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -4534,6 +4892,15 @@
"node": ">=4"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -4693,6 +5060,15 @@
"node": ">=8"
}
},
"node_modules/slugify": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
"integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -4918,6 +5294,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5055,6 +5437,20 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+8
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "npx vite",
"build": "npx vite build",
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"preview": "npx vite preview"
},
@@ -19,9 +20,16 @@
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"maplibre-gl": "^6.10.0",
"pmtiles": "^4.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
"slugify": "^1.6.9",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
+836
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

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

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+584
View File
@@ -0,0 +1,584 @@
#!/usr/bin/env node
/**
* به‌روزرسانی یک‌بارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
*
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» می‌شود — هم روی دیسک
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
* موفقیت پشتیبان، پالت تازه نوشته و آپلود می‌شود.
*
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
* می‌شوند، نه JSON خام؛ پس تنها راه درست به‌روزرسانی، PUT روی همان API است.
*
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیش‌فرض هیچ تغییری نمی‌دهد:
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
* node --experimental-strip-types scripts/update-map-palette.ts --palette
* node --experimental-strip-types scripts/update-map-palette.ts --restore
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
*
* در همهٔ حالت‌ها (جز restore) ترتیب امن رعایت می‌شود: خواندن و اعتبارسنجی →
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
*
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده می‌شوند (MINIO_ROOT_USER /
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نام‌ها.
*/
import { createHash, createHmac } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
/* ------------------------------------------------------------------ */
/* پالت تازه */
/* ------------------------------------------------------------------ */
/** نگاشت دسته‌ها به رنگ‌های تازهٔ برند — تنها منبع حقیقت این اسکریپت */
const PALETTE = {
background: '#181512',
urban: '#27221c',
highway: '#6a5544',
street: '#4a3a2d',
alley: '#352c25',
water: '#172c32',
parks: '#20312b',
nature: '#314238',
mainText: '#ead9c6',
subText: '#a99584',
borders: '#3e362e',
} as const
type Category = keyof typeof PALETTE
/* ------------------------------------------------------------------ */
/* قواعد دسته‌بندی لایه‌ها */
/* ------------------------------------------------------------------ */
type Layer = {
id: string
type: string
'source-layer'?: string
paint?: Record<string, unknown>
layout?: Record<string, unknown>
}
type StyleDocument = {
version?: number
layers?: Layer[]
sources?: Record<string, unknown>
/** الگوی نشانی گلیف‌ها — باید به پوشهٔ v2.0 باکت اشاره کند */
glyphs?: string
}
/** پوشهٔ فونت‌های آفلاین در باکت MinIO */
const GLYPHS_FOLDER = 'v2.0'
/** الگوی استاندارد گلیف — جانگهدارها دست‌نخورده می‌مانند */
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
/**
* اصلاح مسیر گلیف‌ها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
* «v2.0/{fontstack}/{range}.pbf» تبدیل می‌شود تا با ساختار پوشه‌های باکت
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمی‌کند.
*/
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
const current = (style.glyphs ?? '').trim()
const marker = current.indexOf('{fontstack}')
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
if (next === current) return null
style.glyphs = next
return { from: current || '(خالی)', to: next }
}
/** آیا مقدار text-field به یک فیلد «نام» اشاره می‌کند؟ (name، name_en، name:latin و…) */
const NAME_REFERENCE = /name(:[a-z-]+)?/i
/**
* فونت‌های محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
* تنها پوشه‌های Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
* فقط لاتین‌اند.
*/
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
/**
* بازگردانی متن برچسب‌ها به قالب دوزبانهٔ اصلی، به‌تفکیک لایه.
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راه‌ها)
*/
const TEXT_FIELD_RESTORE: Record<string, string> = {
water_name: '{name:latin}\n{name:nonlatin}',
place_other: '{name:latin}\n{name:nonlatin}',
place_suburb: '{name:latin}\n{name:nonlatin}',
place_village: '{name:latin}\n{name:nonlatin}',
place_town: '{name:latin}\n{name:nonlatin}',
place_city: '{name:latin}\n{name:nonlatin}',
place_city_large: '{name:latin}\n{name:nonlatin}',
place_state: '{name:latin}\n{name:nonlatin}',
highway_name_other: '{name:latin} {name:nonlatin}',
place_country_other: '{name:latin}',
place_country_minor: '{name:latin}',
place_country_major: '{name:latin}',
}
/** قالب پیش‌فرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
function referencesName(value: unknown): boolean {
if (typeof value === 'string') return NAME_REFERENCE.test(value)
if (Array.isArray(value)) return value.some(referencesName)
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
return false
}
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
function persianFontFor(stack: string[]): string {
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
if (local) return local
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
}
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
/**
* آماده‌سازی برچسب‌ها برای فارسی:
* ۱) متن به قالب دوزبانهٔ اصلی برمی‌گردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
* ۲) پشتهٔ فونت بازچینی می‌شود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
* چون مپ‌لیبر برای هر پشته فقط یک فایل گلیف می‌گیرد، همان فونت اول باید هر دو خط
* را پوشش دهد و Noto Sans لاتین را هم دارد.
*/
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
const changed: LabelChange[] = []
const skipped: string[] = []
for (const layer of style.layers ?? []) {
const layout = layer.layout
if (layer.type !== 'symbol' || !layout) continue
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
if (!stack.length) continue
const persianFont = persianFontFor(stack)
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
if (!('text-field' in layout)) {
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح می‌شود
layout['text-font'] = restacked
changed.push({ id: layer.id, font: fontChange })
continue
}
if (!referencesName(layout['text-field'])) {
layout['text-font'] = restacked
skipped.push(layer.id)
continue
}
const from = JSON.stringify(layout['text-field']).slice(0, 48)
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
layout['text-field'] = to
layout['text-font'] = restacked
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
}
return { changed, skipped }
}
/**
* بررسی فونت‌ها: هر فونتی که در text-font لایه‌های برچسب استفاده شده باید در
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
*/
async function verifyFonts(style: StyleDocument): Promise<void> {
const fonts = new Set<string>()
for (const layer of style.layers ?? []) {
if (layer.type !== 'symbol') continue
const stack = layer.layout?.['text-font']
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
}
if (!fonts.size) {
console.log('• فونتی در لایه‌های برچسب پیدا نشد')
return
}
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
const missing: string[] = []
for (const font of [...fonts].sort()) {
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
const ok = (await fetch(url, { method: 'HEAD' })).ok
if (!ok) missing.push(font)
console.log(` ${ok ? '✓' : '✗'} ${font}`)
}
console.log(
missing.length
? `✗ فونت‌های ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
: `✓ همهٔ فونت‌ها در ${GLYPHS_FOLDER} باکت موجودند`,
)
}
/* ------------------------------------------------------------------ */
/* بررسی پوشش فونت‌های محلی (کدام فونت فارسی را رندر می‌کند؟) */
/* ------------------------------------------------------------------ */
/** بازه‌های کلیدی گلیف: لاتین پایه، عربی/فارسی، فرم‌های نمایشی عربی */
const COVERAGE_RANGES = [
{ label: 'لاتین', range: '0-255' },
{ label: 'فارسی', range: '1536-1791' },
{ label: 'فرم فارسی', range: '65024-65279' },
] as const
/** فهرست پوشه‌های فونت داخل v2.0 باکت */
async function listFontFolders(): Promise<string[]> {
const response = await fetch(
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
)
const xml = await response.text()
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
return [...xml.matchAll(pattern)].map((match) => match[1])
}
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
async function rangeCoverage(font: string, range: string): Promise<string> {
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return 'نیست'
const bytes = Number(response.headers.get('content-length') ?? 0)
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
}
/** گزارش پوشش خط‌ها برای همهٔ فونت‌های باکت — ورودی تصمیم انتخاب فونت برچسب‌ها */
async function auditFonts(): Promise<void> {
const fonts = await listFontFolders()
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
for (const font of fonts) {
const cells: string[] = []
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
}
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر می‌کند.')
}
/**
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا می‌شود تا مطمئن شویم سند
* سالم است و اسکیمای سبک (نسخه، لایه‌ها، منابع) نشکسته است.
*/
function validateStyle(style: StyleDocument, label: string): void {
const problems: string[] = []
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
else {
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
}
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
problems.push('sources باید یک شیء باشد')
}
console.log(
problems.length
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
)
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
}
type Rule = {
category: Category
/** با این الگو روی شناسهٔ لایه تطبیق داده می‌شود */
test: RegExp
/** کدام ویژگی‌های paint رنگ‌آمیزی شوند */
properties: string[]
}
/**
* جدول قواعد — به ترتیب بررسی می‌شود و نخستین تطبیق برنده است.
* رنگ‌های داخل عبارت‌ها (interpolate/match) هم بازنویسی می‌شوند تا پالت
* یکدست بماند.
*/
const RULES: Rule[] = [
{ category: 'background', test: /^background$/, properties: ['background-color'] },
// آب: خود لایه‌های آب و راه‌های آبی (برچسب‌ها قاعدهٔ جدا دارند)
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
// پارک‌ها پیش از طبیعت بررسی می‌شوند تا چمن/پارک زیر «طبیعت» نرود
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
// طبیعت: پوشش زمینِ جنگلی/علف‌زارِ بیرون از شهر
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
// کاربری شهری و ساختمان‌ها
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
// برچسب‌های اصلی: شهر/شهرک و نام بزرگراه‌ها
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
// بقیهٔ برچسب‌ها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
]
/** ویژگی‌های فرعی که همیشه با رنگ پس‌زمینه هماهنگ می‌شوند تا خوانایی بماند */
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
/** ویژگی‌های آیکون برچسب‌ها که با متن هم‌رنگ می‌شوند */
const ICON_PROPERTIES = ['icon-color']
const COLOR_PATTERN = /^(#|rgb|hsl)/i
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
/* ------------------------------------------------------------------ */
/* بازنویسی رنگ‌ها (توابع خالص) */
/* ------------------------------------------------------------------ */
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارت‌ها — را با رنگ تازه عوض می‌کند */
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
if (isColor(value)) {
stats.seen.add(value.trim())
stats.replaced += 1
return color
}
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {}
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
return result
}
return value
}
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
/** یک لایه را بر پایهٔ قواعد رنگ‌آمیزی می‌کند و گزارش تغییرها را برمی‌گرداند */
function recolorLayer(layer: Layer): LayerReport {
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
const stats = { replaced: 0, seen: new Set<string>() }
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
if (rule) {
for (const property of rule.properties) {
if (!(property in layer.paint)) continue
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
}
}
// لایه‌های برچسب: هاله و آیکون‌ها با پالت هماهنگ می‌شوند
if (layer.type === 'symbol') {
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
? PALETTE[rule.category]
: PALETTE.subText
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
}
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
if (!(property in layer.paint)) continue
const target = property === 'icon-color' ? textColor : PALETTE.background
layer.paint[property] = paintColor(layer.paint[property], target, stats)
}
}
// لبهٔ ساختمان‌ها با رنگ مرزها هم‌خانواده می‌شود
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
}
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
}
/** کل سند سبک را بازنویسی می‌کند (بدون دست‌زدن به ساختار و ویژگی‌های غیررنگی) */
function recolorStyle(style: StyleDocument): LayerReport[] {
const layers = style.layers ?? []
return layers.map(recolorLayer)
}
/** گزارش فشردهٔ تغییرهای پالت — به‌تفکیک دسته و لایه‌های دست‌نخورده */
function reportPalette(reports: LayerReport[]): void {
const byCategory = new Map<string, number>()
for (const report of reports) {
if (!report.replaced) continue
const category = report.category ?? 'بدون‌دسته'
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
}
console.log('\nگزارش تغییرها:')
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
}
const untouched = reports.filter((report) => !report.replaced)
if (untouched.length) {
console.log(`\nلایه‌های بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
}
}
/* ------------------------------------------------------------------ */
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
/* ------------------------------------------------------------------ */
type Credentials = { user: string; secret: string }
async function readCredentials(): Promise<Credentials> {
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
const fromFile: Record<string, string> = {}
try {
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
if (match) fromFile[match[1]] = match[2].trim()
}
} catch {
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین می‌شوند */
}
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
return { user, secret }
}
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام می‌دهند */
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
const url = new URL(objectPath, endpoint)
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)
const payloadHash = sha256(body)
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
const authorization =
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: authorization,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
'Content-Type': 'application/json',
},
body,
})
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
}
/* ------------------------------------------------------------------ */
/* اجرا */
/* ------------------------------------------------------------------ */
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
const STYLE_PATH = '/maps/style.json'
const BACKUP_PATH = '/maps/style.backup.json'
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
/** بازگردانی: پشتیبان دقیقاً به‌جای style.json نوشته می‌شود */
async function restore(): Promise<void> {
const backup = await readFile(LOCAL_BACKUP, 'utf8')
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
console.log(
restored === backup
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
: '✗ محتوای بازگردانده‌شده با پشتیبان یکسان نیست — دستی بررسی کنید',
)
}
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
async function main(): Promise<void> {
const dryRun = process.argv.includes('--dry-run')
const action = ACTIONS.find((flag) => process.argv.includes(flag))
// پیش‌فرض امن: بدون پرچم هیچ نوشتنی انجام نمی‌شود
if (!action) {
console.log(
'کاری انجام نشد — یکی از پرچم‌ها را بدهید:\n' +
' --audit-fonts گزارش پوشش خط‌های هر فونت در v2.0 (فقط خواندن)\n' +
' --fix-labels برچسب‌های دوزبانه + پشتهٔ فونت محلی\n' +
' --fix-glyphs اصلاح مسیر گلیف‌ها به v2.0\n' +
' --palette اعمال پالت تازه\n' +
' --restore بازگردانی از پشتیبان\n' +
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
)
return
}
if (action === '--audit-fonts') {
await auditFonts()
return
}
if (action === '--restore') {
await restore()
return
}
// ۱) خواندن سبک فعلی
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
const original = await response.text()
const style = JSON.parse(original) as StyleDocument
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
validateStyle(style, 'نسخهٔ فعلی')
// ۲) پشتیبان‌گیری، پیش از هر تغییر
await mkdir(OUTPUT_DIR, { recursive: true })
await writeFile(LOCAL_BACKUP, original, 'utf8')
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
if (!dryRun) {
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
}
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
if (action === '--fix-labels') {
const { changed, skipped } = fixLabels(style)
console.log(`\nلایه‌های برچسب اصلاح‌شده (${changed.length}):`)
for (const layer of changed) {
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
}
if (skipped.length) {
console.log(`\nلایه‌های بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
}
await verifyFonts(style)
} else if (action === '--fix-glyphs') {
const change = fixGlyphsPath(style)
console.log(
change ? `\nمسیر گلیف‌ها: ${change.from} → ${change.to}` : '\nمسیر گلیف‌ها از قبل به v2.0 اشاره می‌کند',
)
await verifyFonts(style)
} else {
reportPalette(recolorStyle(style))
}
validateStyle(style, 'نسخهٔ تازه')
// ۴) نوشتن و آپلود نسخهٔ تازه
const updated = JSON.stringify(style, null, 2)
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
if (dryRun) {
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
return
}
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
}
main().catch((error: unknown) => {
console.error('\nخطا:', error instanceof Error ? error.message : error)
process.exitCode = 1
})
+3 -1
View File
@@ -115,8 +115,10 @@ function AppShell() {
const { data, error, reload } = useSiteData();
const { t, dir, lang } = useLang();
// overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky
// (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود.
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 />
{error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
+13 -1
View File
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
import Swal from 'sweetalert2'
import type { SiteData } from '../api'
import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient'
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
import { ENTITIES, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error'
@@ -129,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
return () => document.removeEventListener('click', onClick, true)
}, [isEditMode])
// توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه
// دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند
useEffect(() => {
const syncAuth = () => setIsAuthenticated(!!getToken())
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.addEventListener('storage', syncAuth)
return () => {
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.removeEventListener('storage', syncAuth)
}
}, [])
// در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود
useEffect(() => {
if (saveStatus !== 'saving') return
+227 -56
View File
@@ -1,4 +1,5 @@
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 { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2'
@@ -8,28 +9,49 @@ import { adminApi } from './adminClient'
import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField'
import type { ArticleBodyDoc } from '../data/site'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import type { CategoryOption } from '../components/admin/LuxeSelect'
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'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
type Draft = {
id: number
slug: string
/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
/**
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند:
* عنوان (مبنای Alt پویا)، دسته‌بندی و متادیتای تصویر شاخص.
* `category` در فرم یک شیء تک‌مقداری {value,label} یا null است
* که هنگام ذخیره به رشته (ستون category در بک‌اند) نگاشت می‌شود.
*/
type ArticleMetaForm = {
title: string
title_en: string
date: string
date_en: string
category: string
category_en: string
image: string
read: string
read_en: string
excerpt: string
excerpt_en: string
body: ArticleBodyDoc
body_en: ArticleBodyDoc
status: 'draft' | 'published'
seo_keyphrase: 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[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
return []
}
const STATUS_TEXT: Record<string, string> = {
@@ -57,11 +79,16 @@ export function ArticleWritePage() {
slug: `article-${Date.now()}`,
title: '',
title_en: '',
date: new Date().toLocaleDateString('fa-IR'),
date: new Date().toISOString().split('T')[0],
date_en: '',
category: 'یادداشت',
category: ARTICLE_CATEGORIES[0],
category_en: '',
tags: [],
image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
read_en: '',
excerpt: '',
@@ -71,7 +98,31 @@ export function ArticleWritePage() {
status: 'draft',
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)
/**
* شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
*/
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft)
draftRef.current = draft
@@ -105,13 +156,24 @@ export function ArticleWritePage() {
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({
...(article as unknown as Draft),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
// بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
body: toArticleBodyDoc(article.body),
body_en:
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
? (rawEnBody as ArticleBodyDoc)
: EMPTY_DOC,
})
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
}, [article?.id])
@@ -207,9 +269,15 @@ export function ArticleWritePage() {
queueSave({ [name]: e.target.value }),
})
const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
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 (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
{/* نوار بالا */}
@@ -302,49 +370,107 @@ export function ArticleWritePage() {
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p>
{/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جای‌نگهدار می‌ماند */}
<input
type="text"
{...textField(suf('title'))}
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"
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره می‌شود */}
<Controller
name="title"
control={control}
render={({ field }) => (
<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-8">
<ImageField
label="تصویر شاخص مقاله"
value={draft.image}
alt={imageAltField.value}
altFallback={imageAltFallback}
title={imageTitleField.value}
onSave={async (url) => {
queueSave({ image: url })
return true
}}
onSaveAlt={async (alt) => {
imageAltField.onChange(alt)
queueSave({ image_alt: alt })
return true
}}
onSaveTitle={async (imageTitle) => {
imageTitleField.onChange(imageTitle)
queueSave({ image_title: imageTitle })
return true
}}
/>
</div>
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<div className="mt-8">
<ArticleEditor
contentKey={loadedContentKey}
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => queueSave({ [suf('body')]: json })}
onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json)
if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
queueSave({ body: json, read: `${minutes} دقیقه` })
}
}}
/>
</div>
{/* توضیحات متای سئو */}
<section className="mt-8 border border-ivory/15 bg-char p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
<span
className={`text-[11px] font-light ${
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
}`}
>
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
</span>
</div>
<p className="mt-1 text-xs font-light text-ivory/50">
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده می‌شود (۱۵۰ تا ۱۶۰ نویسه).
</p>
<textarea
id="meta-description"
rows={3}
value={draft.meta_description ?? ''}
onChange={(e) => queueSave({ meta_description: e.target.value })}
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</section>
{/* چکیده و متادیتا */}
<SeoChecklist
input={{
title: draft.title,
slug: draft.slug,
excerpt: draft.excerpt,
excerpt: draft.meta_description || draft.excerpt,
body: draft.body,
keyphrase: draft.seo_keyphrase,
}}
/>
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2>
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دسته‌بندی و هشتگ‌ها</h2>
<div className="mt-5 grid gap-5">
<div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
@@ -356,38 +482,83 @@ export function ArticleWritePage() {
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...textField(suf('excerpt'))}
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : undefined}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...textField(suf('excerpt'))}
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارت‌های وبلاگ…'}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
<input
id="article-author"
type="text"
{...textField('author')}
placeholder="نام و نام خانوادگی نویسنده"
className="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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-5 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div>
<label className="text-xs font-light text-ivory/50">دسته</label>
<input
type="text"
{...textField(suf('category'))}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<div className="mt-2">
{/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
<Controller
name="category"
control={control}
render={({ field }) => (
<CategorySelect
value={field.value}
options={ARTICLE_CATEGORIES}
onChange={(option) => {
field.onChange(option)
queueSave({ category: categoryValue(option) })
}}
/>
)}
/>
</div>
</div>
<div>
<label className="text-xs font-light text-ivory/50">تاریخ</label>
<input
type="text"
{...textField(suf('date'))}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label>
<div className="mt-2">
<LuxeTagsSelect
value={normalizeTags(draft.tags)}
onChange={(tags) => queueSave({ tags })}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div>
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
<div className="mt-2">
<JalaliDatePicker
id="article-date"
value={draft.date ?? ''}
onChange={(isoDate) => queueSave({ date: isoDate })}
/>
</div>
</div>
<div>
<label className="text-xs font-light text-ivory/50">زمان مطالعه</label>
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input
type="text"
{...textField(suf('read'))}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
disabled={editLang !== 'en'}
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
className={`mt-2 w-full text-sm font-light ${
editLang === 'en'
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
}`}
/>
</div>
<div>
+151 -22
View File
@@ -6,8 +6,11 @@ import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient'
import { editableFields } from './entities'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = {
@@ -117,22 +120,46 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6">
{fields.map((field) => (
<FieldControl
key={field.name}
field={field}
value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
onSave={(value) => saveField(field.name, value)}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
))}
{fields.map((field) => {
const isImage = field.type === 'image'
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
const altKey = isImage
? field.name === 'src' && 'alt' in item
? '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 (
<FieldControl
key={field.name}
field={field}
value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
altValue={altKey ? String(item[altKey] ?? '') : undefined}
altFallback={imageAltFallback}
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
onSave={(value) => saveField(field.name, value)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
onSaveImageTitle={
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
)
})}
</div>
</div>
@@ -157,19 +184,36 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
function FieldControl({
field,
value,
altValue,
altFallback,
placeholder,
imageTitleValue,
onSave,
onSaveAlt,
onSaveImageTitle,
registerFlush,
}: {
field: FieldConfig
value: unknown
/** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string
altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown
/** مقدار Title تصویر — فقط برای فیلدهای image */
imageTitleValue?: string
onSave: (value: unknown) => Promise<boolean>
onSaveAlt?: (alt: string) => Promise<boolean>
onSaveImageTitle?: (title: string) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [text, setText] = useState<string>(String(value ?? ''))
// همگام‌سازی مقدار محلی با داده‌ای که از بیرون می‌آید (بارگذاری/به‌روزرسانی خوش‌بینانه)
useEffect(() => {
setText(String(value ?? ''))
}, [value])
const commitText = async () => {
const next = field.type === 'number' ? Number(text) || 0 : text
if (String(value ?? '') === String(next)) return
@@ -177,7 +221,61 @@ function FieldControl({
}
if (field.type === 'image') {
return <ImageField label={field.label} value={String(value ?? '')} onSave={(url) => onSave(url)} />
return (
<ImageField
label={field.label}
value={String(value ?? '')}
alt={altValue}
altFallback={altFallback}
title={imageTitleValue}
onSave={(url) => onSave(url)}
onSaveAlt={onSaveAlt}
onSaveTitle={onSaveImageTitle}
/>
)
}
if (field.type === 'select') {
// مقدار نمایشی از وضعیت محلی می‌آید تا افزودن/حذف گزینه فوراً دیده شود
const selectProps = {
value: text,
options: field.options ?? [],
onChange: (next: string) => {
setText(next)
void onSave(next)
},
}
return (
<div>
<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>}
<div className="mt-2">
{field.creatable ? (
<LuxeCreatableSelect {...selectProps} />
) : (
<LuxeSelect {...selectProps} />
)}
</div>
</div>
)
}
if (field.type === 'date') {
return (
<div>
<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>}
<div className="mt-2">
<JalaliDatePicker
value={String(value ?? '')}
onChange={(isoDate) => {
setText(isoDate)
void onSave(isoDate)
}}
/>
</div>
</div>
)
}
if (field.type === 'wysiwyg') {
@@ -193,7 +291,11 @@ function FieldControl({
}
if (field.type === 'list') {
return <ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
return field.ui === 'tags' ? (
<TagsField field={field} value={value} onSave={onSave} />
) : (
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
)
}
if (field.type === 'textarea') {
@@ -230,6 +332,34 @@ function FieldControl({
)
}
/** فیلد هشتگ‌ها — react-select قابل ساخت (Creatable) با ذخیره فوری */
function TagsField({
field,
value,
onSave,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
}) {
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
const update = (next: string[]) => {
setTags(next)
void onSave(next)
}
return (
<div>
<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>}
<div className="mt-2">
<LuxeTagsSelect value={tags} onChange={update} />
</div>
</div>
)
}
/** ویرایش آرایه خطی (پاراگراف‌ها، گالری، نکات) — هر تغییر ذخیره می‌شود */
function ListField({
field,
@@ -309,10 +439,9 @@ function WysiwygField({
registerFlush?: (fn: () => void) => void
hint?: string
}) {
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: { type: 'doc', content: [{ type: 'paragraph' }] },
const [doc, setDoc] = useState<Record<string, unknown>>(
// بدنه ذخیره‌شده (سند جدید، رشته JSON یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
() => toArticleBodyDoc(value),
)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null)
+4
View File
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token'
/** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string | null): void => {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
}
export class HttpError extends Error {
+53 -18
View File
@@ -1,40 +1,60 @@
import type { SiteData } from '../api'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
export type FieldConfig = {
name: string
label: string
type: FieldType
hint?: string
/** فقط برای type === 'select' — گزینه‌های ثابت react-select */
options?: string[]
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
creatable?: boolean
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
altFallbackSuffix?: string
}
export type EntityConfig = {
/** مسیر منبع در پنل مدیریت، مثل services یا why-us */
resource: string
/** کلید همان منبع در پاسخ GET /api/site */
dataKey: string
title: string
/** مجموعه یا رکورد یگانه */
kind: 'collection' | 'singleton'
/** فیلدی که عنوان آیتم را نشان می‌دهد */
titleKey: string
fields: FieldConfig[]
/** مقادیر پیش‌فرض برای «افزودن» */
defaults: Record<string, unknown>
}
const F = (name: string, label: string, type: FieldType = 'text', hint?: string, shared = false): FieldConfig => ({
const F = (
name: string,
label: string,
type: FieldType = 'text',
hint?: string,
sharedOrExtra?: boolean | Partial<FieldConfig>,
): FieldConfig => ({
name,
label,
type,
hint,
shared,
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
? sharedOrExtra
: { shared: !!sharedOrExtra }),
})
/** پیکربندی همه منابع قابل ویرایش — هم مجموعه‌ها و هم رکوردهای یگانه */
/** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */
export const ARTICLE_CATEGORIES = [
'معماری و ساخت',
'سرمایه‌گذاری ملکی',
'طراحی داخلی',
'اخبار و اطلاعیه‌ها',
'یادداشت مدیریت',
]
export const ENTITIES: Record<string, EntityConfig> = {
services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
@@ -60,14 +80,32 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [
F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده', 'textarea'),
F('image', 'تصویر شاخص', 'image', undefined, true),
F('category', 'دسته'),
F('date', 'تاریخ'),
F('read', 'زمان مطالعه'),
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true),
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
F('author', 'نویسنده', 'text', undefined, true),
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
F('read', 'زمان مطالعه (دقیقه)'),
F('slug', 'نشانی (slug)', 'text', undefined, true),
],
defaults: { title: 'مقاله جدید', slug: `article-${Date.now()}`, date: '', category: '', image: '', read: '', excerpt: '', body: { type: 'doc', content: [{ type: 'paragraph' }] } },
defaults: {
title: 'مقاله جدید',
slug: `article-${Date.now()}`,
date: new Date().toISOString().split('T')[0],
category: ARTICLE_CATEGORIES[0],
tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
excerpt: '',
seo_keyphrase: '',
body: { type: 'doc', content: [{ type: 'paragraph' }] }
},
},
testimonials: {
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
@@ -135,7 +173,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
defaults: { label: 'آیتم جدید', href: '/' },
},
seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key',
@@ -146,8 +183,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
],
defaults: { page_key: '', title: '', description: '' },
},
// ---------- رکوردهای یگانه ----------
brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name',
+16 -4
View File
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site'
import { API_URL } from './config'
import { getToken } from './admin/adminClient'
import { getToken, setToken } from './admin/adminClient'
import { localizeSite } from './i18n/localize'
import { useLang } from './i18n/lang'
@@ -230,10 +230,16 @@ interface SiteContextValue {
const SiteContext = createContext<SiteContextValue>({
data: null,
error: false,
// پیش‌فرض‌های بی‌اثر — مصرف‌کننده بیرون از Provider هم بدون خطا رندر می‌شود
// eslint-disable-next-line @typescript-eslint/no-empty-function
reload: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
updateSite: () => {},
})
/** سقف انتظار داده سایت — سرور بی‌پاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بی‌پایان */
const SITE_TIMEOUT_MS = 15_000
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null)
const [error, setError] = useState(false)
@@ -241,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const load = useCallback(() => {
let cancelled = false
setError(false)
fetch(`${API_URL}/api/site`)
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
fetch(`${API_URL}/api/site`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData>
@@ -255,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (res.ok) {
if (res.status === 401) {
// توکن منقضی/نامعتبر — پاک می‌شود تا بارگذاری‌های بعدی درخواست بی‌فایده نفرستند
setToken(null)
} else if (res.ok) {
const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts]
}
} catch {
/* بدون پیش‌نویس‌ها ادامه بده */
/* بدون پیش‌نویس‌ها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
}
}
if (!cancelled) {
@@ -275,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
})
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [])
+12 -10
View File
@@ -2,7 +2,9 @@ import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { formatJalali } from '../utils/date'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang()
@@ -24,28 +26,28 @@ export function ArticleCard({ article }: { article: Article }) {
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
<div className="flex flex-1 flex-col p-8">
<h3 className="mb-4 md:mb-6 text-xl font-bold leading-relaxed text-ivory transition-colors duration-300 group-hover:text-copper">
{article.title}
</h3>
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
<p className="mb-6 md:mb-10 text-[15px] font-light leading-loose text-ivory/60 line-clamp-3">
{article.excerpt}
</p>
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
<span className="flex items-center gap-3">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-6 md:pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-2">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{article.read}
{toPersianDigits(article.read)}
</span>
</span>
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:hover:[&>svg]:-translate-x-1 ltr:hover:[&>svg]:translate-x-1">
{t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
</span>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
type ArticleMetaProps = {
article: Article
className?: string
}
/**
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده می‌شود:
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
*/
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
const rawDate = (article.date ?? '').trim()
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
const initial = author.charAt(0)
// تاریخ ذخیره‌شده (میلادی) به شمسی تبدیل می‌شود؛ در نبود تاریخ، امروز با ساعت نمایش داده می‌شود
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
const dateLabel = formatJalali(rawDate, todayLabel)
return (
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
{/* آواتار و نام نویسنده */}
<span className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
{initial}
</span>
<span className="flex flex-col">
<span className="text-sm font-bold text-ivory">{author}</span>
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
</span>
</span>
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
{/* تاریخ جلالی */}
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{dateLabel}
</span>
{/* زمان مطالعه — با ارقام فارسی */}
{article.read && (
<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" />
{toPersianDigits(article.read)}
</span>
)}
</div>
)
}
-110
View File
@@ -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>
)
}
+8 -14
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from './Reveal'
import { Button } from './Button'
import { PremiumButton } from './PremiumButton'
import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext'
@@ -194,17 +194,15 @@ export function ContactSection() {
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
</p>
<Button
type="button"
variant="outline"
className="mt-8 font-light"
<PremiumButton
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '' })
}}
className="mt-8 font-light"
>
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</Button>
</PremiumButton>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
@@ -253,21 +251,17 @@ export function ContactSection() {
</div>
<div className="md:col-span-2 flex justify-start">
<Button
<PremiumButton
type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending}
>
{sending ? (
content.sending_label
) : (
<>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
)}
</Button>
</PremiumButton>
</div>
</form>
)}
+67 -5
View File
@@ -1,7 +1,18 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
/**
* تنظیمات ثابت هر زبان.
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
*/
const LANGS = {
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
en: { label: 'English', sliderTranslate: 'translate-x-full' },
} as const
type LangKey = keyof typeof LANGS
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) {
const { lang, t } = useLang()
@@ -22,10 +33,61 @@ export function LangToggle({ className = '' }: { className?: string }) {
onClick={switchLang}
aria-label={t('switchLangAria')}
title={t('switchLangAria')}
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
dir="ltr"
className={[
// ── Capsule shell ──────────────────────────────────────────────
'relative flex items-center',
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
'bg-[#1a1715]',
// deep inset shadow → recessed track feel
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
// interaction
'cursor-pointer select-none',
'active:scale-[0.975] transition-transform duration-150 ease-lux',
className,
].join(' ')}
>
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
<span>{t('switchLang')}</span>
{/* ── Inner Track ───────────────────────────────────────────────
این دیو داخلی باعث می‌شود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
*/}
<div className="relative w-full h-full flex items-center">
{/* ── Slider pill ─────────────────────────────────────────────── */}
<span
aria-hidden="true"
className={[
'absolute top-0 left-0', // چسبیده به لبه‌های Track داخلی
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
'bg-[#27221c] border border-[#d5874c]',
// subtle raised shadow — tactile without being flashy
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
'transition-transform duration-[380ms] ease-lux',
LANGS[lang as LangKey].sliderTranslate,
].join(' ')}
/>
{/* ── Labels ──────────────────────────────────────────────────── */}
<span className="relative z-10 flex w-full h-full">
{(Object.keys(LANGS) as LangKey[]).map((key) => {
const isActive = lang === key
return (
<span
key={key}
className={[
// each label occupies exactly half the capsule
'w-1/2 h-full flex justify-center items-center',
'text-[11.5px] font-semibold tracking-wide leading-none',
'transition-colors duration-[380ms] ease-lux',
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
].join(' ')}
>
{LANGS[key].label}
</span>
)
})}
</span>
</div>
</button>
)
}
}
+38 -26
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
type Props = {
images: string[]
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') prev()
if (e.key === 'ArrowLeft') next()
if (e.key === 'ArrowRight') next()
if (e.key === 'ArrowLeft') prev()
}
window.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
>
{/* Solid Brand Background */}
<div
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
className="absolute inset-0 bg-[#27221c]"
onClick={onClose}
aria-hidden="true"
/>
{/* Close Button at Top Right */}
<button
type="button"
onClick={onClose}
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
>
<XIcon className="h-5 w-5" />
<MdClose size={24} />
</button>
<motion.img
key={index}
src={images[index as number]}
alt=""
className="relative max-h-[80vh] w-auto max-w-full object-contain"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
{/* Main Content Area */}
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
{/* Prev Button */}
<button
type="button"
onClick={prev}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
>
<ChevronRightIcon className="h-5 w-5" />
<MdChevronRight size={28} />
</button>
<span className="text-sm font-light text-ivory/70" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
{/* Image */}
<motion.img
key={index}
src={images[index as number]}
alt=""
className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
{/* Next Button */}
<button
type="button"
onClick={next}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
>
<ChevronLeftIcon className="h-5 w-5" />
<MdChevronLeft size={28} />
</button>
</div>
<div className="absolute bottom-6 z-10 flex items-center justify-center">
<span className="text-sm font-light text-copper" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
</div>
</motion.div>
)}
+62
View File
@@ -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>
)
}
+61 -32
View File
@@ -1,60 +1,89 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon } from 'lucide-react'
import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang'
import { PremiumButton } from './PremiumButton'
import type { StaticProject } from '../data/projectsData'
import type { ProjectCardItem } from '../data/site'
export function ProjectCard({ project }: any) {
type ProjectCardProps = {
project: StaticProject | ProjectCardItem;
imageUrl?: string;
detailHref?: string;
}
export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps) {
const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status)
const { t, href, lang } = useLang()
const { t, lang } = useLang()
// Type guards for backward compatibility
const isStatic = 'name' in project;
// Resolve localized texts
const title = isStatic
? (project.name[lang as 'en' | 'fa'] || project.name.en)
: (project as ProjectCardItem).title;
const locRaw = isStatic ? project.location : (project as ProjectCardItem).location;
const location = typeof locRaw === 'string' ? locRaw : (locRaw?.[lang as 'en' | 'fa'] || locRaw?.en);
const areaRaw = isStatic ? project.area : (project as ProjectCardItem).area;
const area = typeof areaRaw === 'string' ? areaRaw : (areaRaw?.[lang as 'en' | 'fa'] || areaRaw?.en);
const finalImageUrl = imageUrl || (project as ProjectCardItem).image;
const finalDetailHref = detailHref || (project as ProjectCardItem).detailHref || '#';
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return (
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */}
<div className="relative aspect-[4/3] w-full overflow-hidden">
<img
src={project.image}
alt={project.title}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
<Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
{/* Fixed height image wrapper */}
<div className="relative w-full overflow-hidden">
{finalImageUrl && (
<img
src={finalImageUrl}
alt={title}
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
/>
)}
{/* نشانگر وضعیت کپسولی شیشه‌ای */}
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
<div
className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
>
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText}
</div>
{/* گوی دکمه شیشه‌ای شناور برای ورود به پروژه */}
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<div className="flex flex-1 flex-col p-7 pb-4">
<div className="text-center">
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
{project.title}
{title}
</h3>
<p className="mt-4 text-sm font-light text-ivory/60">
{project.location}
</p>
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
</div>
<div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
<span>{t('yearLabel')} {project.year}</span>
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */}
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
</div>
{area && (
<>
<div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-end text-sm font-light text-ivory/60">
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */}
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
</div>
</>
)}
</div>
</Link>
{/* پایهٔ کارت: یک دکمه واحد */}
<div className="flex flex-col gap-2 px-7 pb-7">
<PremiumButton to={finalDetailHref} className="w-full">
{t('viewProject')}
</PremiumButton>
</div>
</article>
)
}
}
@@ -0,0 +1,19 @@
import { MdApartment } from 'react-icons/md'
import { useLang } from '../i18n/lang'
/**
* جای‌نگهدار تصویر پروژه — همان زبان بصری کارت پاپ‌آپ، برای هر پروژه‌ای که
* تصویری در داده ندارد (مثل ردیف‌های شیت). تک‌منبع این ظاهر همین‌جاست.
*/
export function ProjectImagePlaceholder({ className = '' }: { className?: string }) {
const { t } = useLang()
return (
<div
className={`flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-char to-ink text-ivory/25 ${className}`}
>
<MdApartment className="h-8 w-8" aria-hidden="true" />
<span className="text-[10px] font-light">{t('mapImagePlaceholder')}</span>
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react'
import type { MouseEvent } from 'react'
import { ListIcon } from 'lucide-react'
import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 }
/**
* متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
*/
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 }
const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>()
const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = headingText(element).replace(/\s+/g, ' ').trim()
if (!text) return
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
element.setAttribute('id', id)
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
})
// تبدیل اختیاری (بسته‌بندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا می‌شود
transform?.(doc.body)
return { items, html: doc.body.innerHTML }
}
type TableOfContentsProps = {
items: TocItem[]
title?: string
className?: string
}
/**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
useEffect(() => {
setActiveId(items[0]?.id ?? '')
}, [items])
useEffect(() => {
if (!items.length || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
const first = visible[0]?.target.id
if (first) setActiveId(first)
},
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
)
items.forEach((item) => {
const el = document.getElementById(item.id)
if (el) observer.observe(el)
})
return () => observer.disconnect()
}, [items])
if (items.length === 0) return null
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
event.preventDefault()
const el = document.getElementById(id)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.history.replaceState(null, '', `#${id}`)
}
return (
<nav
aria-label="فهرست مطالب"
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
>
<p className="flex items-center gap-2 text-sm font-bold text-copper">
<ListIcon className="h-4 w-4" aria-hidden="true" />
{title}
</p>
<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-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
{items.map((item) => {
const isActive = item.id === activeId
return (
<li key={item.id}>
<button
type="button"
onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined}
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'
: 'text-ivory/60 hover:text-copper'
}`}
>
{item.text}
</button>
</li>
)
})}
</ul>
</nav>
)
}
+296 -131
View File
@@ -1,47 +1,107 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core'
import {
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ItalicIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
ItalicIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
Loader2Icon,
PilcrowIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
UnderlineIcon,
Undo2Icon,
Loader2Icon,
} from 'lucide-react'
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
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 LegacySmallSizeMark = Mark.create({
name: 'smallSize',
parseHTML() {
return [{ tag: 'span[data-small]' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
},
})
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [2, 3] },
heading: { levels: [1, 2, 3] },
}),
Image.configure({ allowBase64: false }),
ArticleImage.configure({ allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
isAllowedUri: (uri: string) => isAllowedHref(uri),
}),
LegacySmallSizeMark,
EnglishText,
BidiTypography,
]
type ArticleEditorProps = {
doc: Record<string, unknown>
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({
onClick,
active = false,
@@ -58,6 +118,7 @@ function ToolButton({
type="button"
title={label}
aria-label={label}
aria-pressed={active}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
@@ -74,13 +135,15 @@ function ToolButton({
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
/**
* ویرایشگر WYSIWYG مقاله (TipTap) — راست‌به‌چپ، با آپلود مستقیم تصویر به MinIO
* (انتخاب فایل، چسباندن یا کشیدن تصویر داخل متن).
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور،
* کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
*/
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [direction, setDirection] = useState<TextDirection>('rtl')
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
const editor = useEditor({
extensions: ARTICLE_EXTENSIONS,
@@ -89,16 +152,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
editorProps: {
attributes: {
dir: 'rtl',
lang: 'fa',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true',
},
handlePaste: (_event_ignored, event) => {
handlePaste: (_view, event, _slice) => {
const file = event.clipboardData?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
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
},
handleDrop: (_view, event, _slice, moved) => {
@@ -111,157 +188,245 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
}
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()),
})
// جهت متن فقط روی ناحیه ویرایش اعمال می‌شود؛ نوار ابزار همیشه راست‌چین می‌ماند
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) {
if (!editor) return
setUploading(true)
await runSave(async () => {
const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run()
// نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor
.chain()
.focus()
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
.run()
})
setUploading(false)
}
const setLink = () => {
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
const openLinkModalForSelection = () => {
if (!editor) return
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
const attrs = editor.getAttributes('link') as {
href?: string | null
target?: string | null
rel?: string | null
}
void Swal.fire({
title: 'نشانی لینک',
input: 'text',
inputValue: 'https://',
background: '#1e1a16',
color: '#F5F3EF',
showCancelButton: true,
confirmButtonText: 'ثبت',
cancelButtonText: 'انصراف',
buttonsStyling: 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',
const editing = Boolean(attrs.href)
setLinkModal({
open: true,
editing,
mode: 'selection',
range: null,
initial: {
href: attrs.href ?? '',
newTab: editing ? attrs.target === '_blank' : true,
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
},
}).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
return (
<div className="border border-ivory/15 bg-char">
<div className="flex flex-wrap items-center gap-0.5 border-b border-ivory/10 px-2 py-1.5">
<ToolButton
label="بولد"
active={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
<>
<div className="border border-ivory/15 bg-char" data-dir={direction}>
{/* نوار ابزار: همیشه راست‌چین و در بالای ویرایشگر ثابت می‌ماند */}
<div
dir="rtl"
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"
>
<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>
<ToolButton
label="پاراگراف"
active={editor.isActive('paragraph')}
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
>
<PilcrowIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۱"
active={editor.isActive('heading', { level: 1 })}
onClick={() => applyBlockFormat(editor.view, 1)}
>
<Heading1Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۲"
active={editor.isActive('heading', { level: 2 })}
onClick={() => applyBlockFormat(editor.view, 2)}
>
<Heading2Icon 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 />
<ToolButton
label="تیتر میانی"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر کوچک"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3Icon className="h-4 w-4" />
</ToolButton>
{/* جهت متن — فقط محتوای ویرایشگر را عوض می‌کند */}
<ToolButton
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 />
<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>
<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 />
<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>
<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 />
<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>
<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) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
/>
</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) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
/>
</div>
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && (
<LinkModal
initial={linkModal.initial}
editing={linkModal.editing}
onApply={applyLink}
onRemove={removeLink}
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
/>
)}
</>
)
}
@@ -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)
}
@@ -0,0 +1,26 @@
import { Mark, mergeAttributes } from '@tiptap/core'
/** نام مارک متن انگلیسی — ویرایشگر و رندر استاتیک مقاله از همین نام استفاده می‌کنند */
export const ENGLISH_TEXT_MARK = 'englishText'
/**
* مارک «متن انگلیسی» — متن انتخاب‌شده را داخل
* <span dir="ltr" class="english-text"> می‌پیچد تا ۱۲ پیکسل و چپ‌به‌راست
* نمایش داده شود، بدون اینکه جهت متن فارسی اطرافش به‌هم بخورد.
*/
export const EnglishText = Mark.create({
name: ENGLISH_TEXT_MARK,
inclusive: false,
parseHTML() {
return [{ tag: 'span.english-text' }]
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(HTMLAttributes, { dir: 'ltr', class: 'english-text' }),
0,
]
},
})
+129 -29
View File
@@ -1,23 +1,71 @@
import { useRef, useState } from 'react'
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { useId, useRef, useState } from 'react'
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
import { useSmartTextField } from './useSmartTextField'
type ImageFieldProps = {
label: string
value: string
onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) */
alt?: string
onSaveAlt?: (alt: string) => Promise<boolean>
/** عنوان تصویر (Title) */
title?: string
onSaveTitle?: (title: string) => Promise<boolean>
/**
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
* هرگز از URL یا نام فایل ساخته نمی‌شود.
*/
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 — در کشوهای ویرایش استفاده می‌شود.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود.
* انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود.
* پیش‌فرض خودکار با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمی‌گردد.
*/
export function ImageField({ label, value, onSave }: ImageFieldProps) {
export function ImageField({
label,
value,
onSave,
alt,
onSaveAlt,
title,
onSaveTitle,
altFallback = '',
titleFallback = '',
}: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null)
const titleId = useId()
const altId = useId()
const [uploading, setUploading] = useState(false)
const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt
const titleField = useSmartTextField({
value: title ?? '',
fallback: titleFallback,
hardFallback: DEFAULT_IMAGE_TITLE,
onCommit: onSaveTitle,
})
const altField = useSmartTextField({
value: alt ?? '',
fallback: altFallback,
hardFallback: label,
onCommit: onSaveAlt,
})
if (!isAuthenticated || !isEditMode) return null
const pick = async (file: File | undefined) => {
@@ -28,6 +76,9 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
const { url } = await adminApi.upload(file)
const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد')
// پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit()
if (showAlt) await altField.commit()
})
} finally {
setUploading(false)
@@ -37,43 +88,92 @@ export function ImageField({ label, value, onSave }: ImageFieldProps) {
return (
<div>
<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 ? (
<img
src={value}
alt={label}
className="h-16 w-24 border border-ivory/15 object-cover"
alt={altField.display || label}
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 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 className="mt-3">
<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 ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button>
</div>
{(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>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
void pick(e.target.files?.[0])
e.target.value = ''
onChange={(event) => {
void pick(event.target.files?.[0])
event.target.value = ''
}}
/>
</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,52 @@
import DatePicker from 'react-multi-date-picker'
import type { DateObject } from 'react-multi-date-picker'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
import { toISODate, toJalaliDateObject } from '../../utils/date'
type JalaliDatePickerProps = {
/** مقدار ذخیره‌شده — ISO میلادی (YYYY-MM-DD) */
value: string
/** تغییر مقدار — همیشه ISO میلادی برمی‌گرداند تا ذخیره‌سازی یکدست بماند */
onChange: (isoDate: string) => void
id?: string
placeholder?: string
disabled?: boolean
className?: string
}
/**
* انتخابگر تاریخ شمسی (react-multi-date-picker) با تقویم و locale فارسی.
* نمایش جلالی است اما مقدار ذخیره‌شده ISO میلادی می‌ماند.
*/
export function JalaliDatePicker({
value,
onChange,
id,
placeholder = 'انتخاب تاریخ',
disabled = false,
className = '',
}: JalaliDatePickerProps) {
return (
<DatePicker
id={id}
value={toJalaliDateObject(value)}
onChange={(selected) => {
const picked = Array.isArray(selected) ? selected[0] : selected
if (!picked) return
onChange(toISODate(picked as DateObject))
}}
calendar={persian}
locale={persian_fa}
format="YYYY/MM/DD"
calendarPosition="bottom-right"
className={`aramesh-datepicker ${className}`}
containerClassName="w-full"
inputClass="w-full cursor-pointer 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 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={placeholder}
disabled={disabled}
mobileLabels={{ OK: 'تأیید', CANCEL: 'انصراف' }}
portal
/>
)
}
+215
View File
@@ -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=&quot;_blank&quot;</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=&quot;{relPreview}&quot;
</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>
)
}
@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react'
import Select from 'react-select'
import CreatableSelect from 'react-select/creatable'
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
import { XIcon } from 'lucide-react'
export type LuxeOption = { label: string; value: string }
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
LuxeOption,
IsMulti,
GroupBase<LuxeOption>
>
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
`!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 {
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
valueContainer: () => '!gap-1 !px-3 !py-0.5',
placeholder: () => '!text-[13px] !text-ivory/35',
singleValue: () => '!text-[13px] !text-ivory',
input: () => '!text-[13px] !text-ivory',
indicatorsContainer: () => '!text-ivory/40',
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
indicatorSeparator: () => '!bg-ivory/15',
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
menuList: () => '!p-1',
noOptionsMessage: () => '!text-xs !text-ivory/45',
loadingMessage: () => '!text-xs !text-ivory/45',
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
multiValueLabel: () => '!text-xs !text-copper',
...extras,
}
}
/** دکمه ضربدر حذف برچسب انتخاب‌شده — همیشه واضح و قابل کلیک */
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 =>
typeof document === 'undefined' ? undefined : document.body
const uniqueOptions = (values: string[]): LuxeOption[] =>
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
type LuxeSelectProps = {
value: string
options: string[]
onChange: (value: string) => void
placeholder?: string
isClearable?: boolean
isDisabled?: boolean
inputId?: string
}
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
export function LuxeSelect({
value,
options,
onChange,
placeholder = 'انتخاب کنید…',
isClearable = true,
isDisabled = false,
inputId,
}: LuxeSelectProps) {
const selectOptions = useMemo(
() => uniqueOptions([...options, ...(value ? [value] : [])]),
[options, value],
)
return (
<Select<LuxeOption, false>
inputId={inputId}
classNamePrefix="luxe"
options={selectOptions}
value={selectOptions.find((option) => option.value === value) ?? null}
onChange={(option) => onChange(option?.value ?? '')}
placeholder={placeholder}
isClearable={isClearable}
isDisabled={isDisabled}
noOptionsMessage={() => 'موردی یافت نشد'}
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"
menuPortalTarget={portalTarget()}
/>
)
}
type LuxeTagsSelectProps = {
value: string[]
onChange: (value: string[]) => void
placeholder?: string
}/** هشتگ‌ها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
export function LuxeTagsSelect({
value,
onChange,
placeholder = 'هشتگ بنویسید و Enter بزنید…',
}: LuxeTagsSelectProps) {
const options = useMemo(() => uniqueOptions(value), [value])
/** پاک‌سازی و حذف تکراری‌ها هنگام افزودن یا حذف برچسب */
const applyTags = (next: readonly LuxeOption[]) => {
const tags = next.map((option) => option.value.trim()).filter(Boolean)
onChange([...new Set(tags)])
}
return (
<CreatableSelect<LuxeOption, true>
classNamePrefix="luxe"
isMulti
options={options}
value={options}
onChange={applyTags}
placeholder={placeholder}
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
noOptionsMessage={() => 'هشتگی یافت نشد'}
closeMenuOnSelect={false}
components={{ MultiValueRemove: TagRemoveButton }}
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
+3 -74
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
import type { ArticleBodyDoc } from '../../data/site'
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
export type SeoInput = {
title: string
@@ -13,78 +14,6 @@ export type SeoInput = {
type Status = 'good' | 'ok' | 'bad'
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[] {
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', 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 در متن نیست.' })
else
checks.push(
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
// تیتر 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: 'متن تیتربندی شده است.' })
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 }
}
+4 -5
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button';
import { PremiumButton } from '../PremiumButton';
import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang';
@@ -53,10 +53,9 @@ export function Articles() {
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
<PremiumButton to={href(heading.cta_href)} className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</PremiumButton>
</div>
</Reveal>
)}
+9 -7
View File
@@ -18,20 +18,22 @@ export function Faq({ hideHeading = false }: FaqProps) {
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
{/* Header — centered, full width */}
{!hideHeading && (
<div className="lg:col-span-4">
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
<SectionHeading
title={t('fallbackFaqTitle')}
lead={t('fallbackFaqLead')}
headingKey="faq"
align="center"
/>
</div>
)}
<div className={hideHeading ? "" : "lg:col-span-8"}>
{/* Accordion list — constrained width, direction-aware */}
<div className="w-full max-w-3xl mx-auto">
<ul>
{faqs.map((faq, i) => {
const isOpen = open === i
@@ -43,9 +45,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
@@ -63,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
</motion.div>
)}
</AnimatePresence>
@@ -1,22 +1,23 @@
import React, { useState } from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site'
import { projectsData } from '../../data/projectsData'
export function FeaturedProjects() {
const { projects, status_labels } = useSite()
const { status_labels } = useSite()
const { t, href } = useLang()
const heading = useSectionHeading('featured_projects')
const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key)
if (!label) return
@@ -30,8 +31,12 @@ export function FeaturedProjects() {
{ key: 'done', label: useStatusLabel('done') },
{ key: 'progress', label: useStatusLabel('progress') },
]
const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3)
// Filter and slice static project data
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
const BASE_IMG_URL = "/seed-media/2026/09/";
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
@@ -44,7 +49,6 @@ export function FeaturedProjects() {
const isSelected = status === filter.key
return (
<React.Fragment key={filter.key}>
{/* خط جداکننده بین دکمه‌ها */}
{index > 0 && (
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
)}
@@ -82,19 +86,26 @@ export function FeaturedProjects() {
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="grid gap-6 lg:grid-cols-3"
>
{list.map((project, i) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} />
</EditableItem>
</motion.div>
))}
{list.map((project, i) => {
const dummyImageName = `${(i % 6) + 1}.png`;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<ProjectCard
project={project}
imageUrl={imageUrl}
detailHref={`/projects/${project.slug}`}
/>
</motion.div>
)
})}
</motion.div>
</AnimatePresence>
</div>
@@ -103,11 +114,7 @@ export function FeaturedProjects() {
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink
to={href(heading.cta_href)}
variant="outline"
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
>
<PremiumButton to={href(heading.cta_href)}>
<InlineEdit
value={heading.cta_label}
onSave={(v) =>
@@ -117,10 +124,7 @@ export function FeaturedProjects() {
})
}
/>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
</span>
</ButtonLink>
</PremiumButton>
</div>
</Reveal>
)}
+57 -35
View File
@@ -64,7 +64,7 @@ export function Gallery() {
<AnimatePresence>
{active !== null && gallery[active] && (
<motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -74,49 +74,71 @@ export function Gallery() {
aria-label={t('viewImage')}
onClick={close}
>
{/* Close Button */}
<button
type="button"
onClick={close}
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('close')}
>
<XIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(-1)
}}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
aria-label={t('prevImage')}
>
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(1)
}}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
aria-label={t('nextImage')}
>
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<motion.img
key={active}
src={gallery[active].src}
alt=""
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
{/* Flex Container for Prev, Image, Next */}
<div
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
onClick={(e) => e.stopPropagation()}
/>
>
<button
type="button"
onClick={() => step(-1)}
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('prevImage')}
>
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<motion.img
key={active}
src={gallery[active].src}
alt={gallery[active].alt}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
/>
<button
type="button"
onClick={() => step(1)}
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('nextImage')}
>
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button>
</div>
{/* Modern Dot Pagination */}
<div
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
onClick={(e) => e.stopPropagation()}
>
{gallery.map((_, idx) => {
const isActive = idx === active;
return (
<button
key={idx}
type="button"
onClick={() => setActive(idx)}
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
}`}
/>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
+10 -35
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { ChevronDownIcon } from 'lucide-react'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
@@ -84,45 +84,20 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4"
>
{/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<ButtonLink
to={href(hero.cta_primary_href)}
variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
>
<span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
</span>
</ButtonLink>
{/* دکمه اصلی */}
<PremiumButton to={href(hero.cta_primary_href)}>
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</PremiumButton>
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<ButtonLink
to={href(hero.cta_secondary_href)}
variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
>
{/* دکمه ثانویه */}
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</ButtonLink>
</PremiumButton>
</motion.div>
</div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */}
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
>
<p className="text-3xl font-bold text-copper font-mono">
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
</p>
</motion.div>
</motion.div>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
+8 -9
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react'
import { CheckIcon, SendIcon } from 'lucide-react'
import { CheckIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from '../Reveal'
import { Button } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
@@ -98,7 +98,7 @@ export function Newsletter() {
}
return (
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
{/* هاله نور پس‌زمینه برای عمق بصری */}
@@ -142,19 +142,18 @@ export function Newsletter() {
)}
</div>
<Button
<button
type="submit"
variant="dark"
disabled={sending}
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
<span>
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
{sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)}
</span>
</Button>
</button>
</form>
</Reveal>
</div>
+54 -17
View File
@@ -1,31 +1,68 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion';
import { useSite } from '../../api';
import { useLang, formatNumber } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext';
import { AddItemButton, EditableItem } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
function Counter({ value }: {value: number;}) {
const { lang } = useLang();
function useCounter(targetNumber: number, duration: number = 2000) {
const [count, setCount] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!inView) return;
let frame = 0;
const total = 60;
const id = window.setInterval(() => {
frame += 1;
const progress = 1 - Math.pow(1 - frame / total, 3);
setDisplay(Math.round(value * progress));
if (frame >= total) window.clearInterval(id);
}, 20);
return () => window.clearInterval(id);
}, [inView, value]);
const element = ref.current;
if (!element) return;
return <span ref={ref}>{formatNumber(display, lang)}</span>;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
let startTime: number | null = null;
const step = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = timestamp - startTime;
// ease-out cubic
const easeOut = progress >= duration ? 1 : 1 - Math.pow(1 - progress / duration, 3);
setCount(Math.floor(easeOut * targetNumber));
if (progress < duration) {
window.requestAnimationFrame(step);
} else {
setCount(targetNumber);
}
};
window.requestAnimationFrame(step);
}
},
{ threshold: 0.1 }
);
observer.observe(element);
return () => observer.disconnect();
}, [targetNumber, duration]);
return { count, ref };
}
function Counter({ value }: { value: string | number }) {
const { lang } = useLang();
const strValue = String(value);
// Match the first sequence of digits
const numMatch = strValue.match(/(\d+)/);
const targetNumber = numMatch ? parseInt(numMatch[0], 10) : 0;
const { count, ref } = useCounter(targetNumber, 1500);
// If there's a number, replace it with the animated (and localized) count, otherwise render string as is.
const formattedDisplay = formatNumber(count, lang);
const renderValue = numMatch ? strValue.replace(numMatch[0], formattedDisplay) : strValue;
return <span ref={ref}>{renderValue}</span>;
}
export function Stats() {
+4 -5
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
@@ -89,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && (
<Reveal delay={0.22}>
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</PremiumButton>
</Reveal>
)}
+5 -4
View File
@@ -9,8 +9,8 @@ export function Team() {
const { team } = useSite()
const { t } = useLang()
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title" >
<div className="mx-auto max-w-site px-6 lg:px-10 ">
<SectionHeading
title={t('fallbackTeamTitle')}
lead={t('fallbackTeamLead')}
@@ -22,7 +22,8 @@ export function Team() {
{team.map((member, i) => (
<Reveal key={member.id} delay={0.06 * i} className="h-full">
<EditableItem resource="team" item={member} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
{/* اضافه شدن rounded-2xl برای گرد شدن گوشه‌ها */}
<article className="group flex h-full flex-col items-center rounded-2xl border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
{member.name.trim().charAt(0)}
</div>
@@ -40,4 +41,4 @@ export function Team() {
</div>
</section>
)
}
}
+5 -3
View File
@@ -16,6 +16,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
return (
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
{/* دایره‌ها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
<span
aria-hidden="true"
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
@@ -35,7 +36,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
>
<EditableItem resource="timeline" item={item}>
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
{/* لایه نوری ظریف که در هاور روشن می‌شود */}
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
@@ -72,13 +72,15 @@ export function Timeline() {
</div>
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
{/* خط پس‌زمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
<span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
/>
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
<motion.span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
style={{ scaleY }}
/>
+5
View File
@@ -1,3 +1,8 @@
// آدرس بک‌اند — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ (/api از طریق Traefik).
// فقط اگر بک‌اند روی مبدأ دیگری سرو می‌شود VITE_API_URL را تنظیم کنید.
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
// آدرس پایه کاشی‌های نقشه (MinIO: style.json، iran.pmtiles، فونت‌ها و اسپرایت).
// در صورت تغییر مبدا سرو کاشی‌ها VITE_MAP_URL را تنظیم کنید.
export const MAP_BASE_URL: string =
(import.meta.env.VITE_MAP_URL as string | undefined) ?? 'http://localhost:19000/maps'
+18
View File
@@ -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 = 'دقیقه مطالعه'
+247
View File
@@ -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,
}
}
+74
View File
@@ -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' },
];
+31
View File
@@ -32,6 +32,10 @@ export type Project = {
units_en?: string;
client: string;
client_en?: string;
/** مختصات جغرافیایی پروژه — عرض جغرافیایی؛ ستون‌های آینده بک‌اند، اختیاری */
lat?: number;
/** مختصات جغرافیایی پروژه — طول جغرافیایی؛ ستون‌های آینده بک‌اند، اختیاری */
lng?: number;
summary: string;
summary_en?: string;
body: string[];
@@ -55,4 +59,31 @@ export type Article = {
status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
seo_keyphrase: string;
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
meta_description?: string;
/** متن جایگزین تصویر شاخص (Alt) */
image_alt?: string;
/** عنوان تصویر شاخص (Title) */
image_title?: string;
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */
author?: string;
/** هشتگ‌ها/برچسب‌های مقاله */
tags?: string[];
};
/**
* شکل پروژه در کارت‌های فهرست — از پروژهٔ بک‌اند یا ردیف شیت پر می‌شود؛
* فیلدهای اختیاری فقط وقتی رندر می‌شوند که داده‌شان موجود باشد.
*/
export type ProjectCardItem = {
id: number | string;
title: string;
status: ProjectStatus;
/** مسیر صفحهٔ جزئیات پروژه */
detailHref: string;
/** نشانی بیرونی پروژه (نشان) — در نبودش دکمهٔ آن رندر نمی‌شود */
addressUrl?: string;
location?: string;
image?: string;
area?: string;
};
+23 -3
View File
@@ -48,9 +48,7 @@ export const fa = {
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران.',
// کارت پروژه
yearLabel: 'سال:',
sqm: 'متر مربع',
// جزئیات پروژه
projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
@@ -106,6 +104,18 @@ export const fa = {
nextTestimonial: 'نظر بعدی',
selectTestimonialAria: 'انتخاب نظر',
testimonialByPrefix: 'نظر',
// نقشه پروژه‌ها (مپ‌لیبر)
mapAria: 'نقشه موقعیت پروژه‌ها',
mapLoading: 'در حال بارگذاری نقشه…',
mapErrorText: 'بارگذاری نقشه ناموفق بود — اتصال به سرور کاشی‌ها را بررسی کنید.',
viewProject: 'مشاهده پروژه',
mapFullscreen: 'نمایش تمام‌صفحه',
mapExitFullscreen: 'خروج از تمام‌صفحه',
viewAddress: 'مشاهده آدرس پروژه',
mapNewBadge: 'جدید',
mapProjectDescription: 'پروژه اجراشده در اصفهان',
mapImagePlaceholder: 'تصویر پروژه',
}
export type Dict = typeof fa
@@ -151,7 +161,6 @@ export const en: Dict = {
fallbackFaqTitle: 'Frequently Asked Questions',
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
yearLabel: 'Year:',
sqm: 'm²',
projectNotFound: 'Project not found',
@@ -206,6 +215,17 @@ export const en: Dict = {
nextTestimonial: 'Next testimonial',
selectTestimonialAria: 'Select testimonial',
testimonialByPrefix: 'Testimonial by',
mapAria: 'Projects location map',
mapLoading: 'Loading map…',
mapErrorText: 'The map failed to load — check the tile server connection.',
viewProject: 'View project',
mapFullscreen: 'Fullscreen',
mapExitFullscreen: 'Exit fullscreen',
viewAddress: 'View project location',
mapNewBadge: 'New',
mapProjectDescription: 'Project delivered in Isfahan',
mapImagePlaceholder: 'Project image',
}
export const dict = { fa, en }
+249 -3
View File
@@ -101,6 +101,7 @@ html[lang='en'] .article-body h3 {
.article-body {
line-height: 2.4;
word-wrap: break-word;
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */
}
.article-body p {
margin-top: 1.75rem;
@@ -109,6 +110,11 @@ html[lang='en'] .article-body h3 {
.article-body > :first-child {
margin-top: 0;
}
/* ---------- تایپوگرافی مقاله ----------
راست‌چین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
چپ‌چین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین می‌شود. */
.article-body h1,
.article-body h2,
.article-body h3 {
color: #f5f3ef;
@@ -116,15 +122,77 @@ html[lang='en'] .article-body h3 {
margin-top: 3rem;
margin-bottom: 0.25rem;
line-height: 1.9;
/* فاصله از هدر چسبان هنگام پرش لنگرهای فهرست مطالب */
scroll-margin-top: 7rem;
}
.article-body h1 {
font-size: 18px;
}
.article-body h2 {
font-size: 1.6rem;
font-size: 16px;
padding-inline-start: 0.85rem;
border-inline-start: 3px solid #d5874c;
}
.article-body h3 {
font-size: 1.25rem;
color: #e8c9a8;
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"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
}
/* مارک «متن انگلیسی» ویرایشگر — چپ‌به‌راست و ۱۲ پیکسل بدون به‌هم‌زدن جهت متن فارسی */
.english-text {
direction: ltr;
unicode-bidi: isolate;
/* اندازه از قواعد دوزبانه بالا می‌آید: ۲ پیکسل کوچک‌تر از متن فارسی همان بلوک */
}
.article-body ul,
.article-body ol {
@@ -168,6 +236,35 @@ html[lang='en'] .article-body h3 {
border: 1px solid rgba(245, 243, 239, 0.1);
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 {
color: #f5f3ef;
font-weight: 700;
@@ -195,3 +292,152 @@ html[lang='en'] .article-body h3 {
background: transparent;
padding: 0;
}
/* ---------- تقویم شمسی (react-multi-date-picker) — تم تیره با لهجه مسی ---------- */
.aramesh-datepicker.rmdp-wrapper {
width: max-content;
border: 1px solid rgba(245, 243, 239, 0.12);
border-radius: 0;
background-color: #1e1a16;
color: #f5f3ef;
box-shadow: 0 24px 50px -18px rgba(0, 0, 0, 0.75);
}
.aramesh-datepicker .rmdp-calendar {
padding: 8px;
}
.aramesh-datepicker .rmdp-header-values {
color: #d5874c;
font-size: 13px;
font-weight: 700;
}
.aramesh-datepicker .rmdp-arrow {
border-color: #d5874c;
}
.aramesh-datepicker .rmdp-arrow-container {
box-shadow: none;
}
.aramesh-datepicker .rmdp-arrow-container:hover {
background-color: rgba(213, 135, 76, 0.2);
box-shadow: none;
}
.aramesh-datepicker .rmdp-week-day {
color: rgba(245, 243, 239, 0.45);
font-size: 11px;
font-weight: 400;
}
.aramesh-datepicker .rmdp-day {
color: rgba(245, 243, 239, 0.85);
}
.aramesh-datepicker .rmdp-day span {
border-radius: 0;
}
.aramesh-datepicker .rmdp-day:hover:not(.rmdp-disabled):not(.rmdp-selected) span {
background-color: rgba(213, 135, 76, 0.16);
color: #d5874c;
}
.aramesh-datepicker .rmdp-day.rmdp-selected span:not(.highlight) {
background-color: #d5874c;
color: #1e1a16;
font-weight: 700;
box-shadow: none;
}
.aramesh-datepicker .rmdp-day.rmdp-today span {
background-color: rgba(213, 135, 76, 0.25);
color: #f5f3ef;
}
.aramesh-datepicker .rmdp-day.rmdp-deactive {
color: rgba(245, 243, 239, 0.22);
}
.aramesh-datepicker .rmdp-day.rmdp-disabled {
color: rgba(245, 243, 239, 0.12);
}
.aramesh-datepicker .rmdp-month-picker,
.aramesh-datepicker .rmdp-year-picker {
background-color: #1e1a16;
border-radius: 0;
}
.aramesh-datepicker .rmdp-panel {
background-color: #1e1a16;
}
.aramesh-datepicker .rmdp-panel-body li {
background-color: rgba(213, 135, 76, 0.15);
color: #f5f3ef;
box-shadow: none;
}
.aramesh-datepicker .rmdp-action-button {
color: #d5874c;
}
/* ---------- نقشه مپ‌لیبر — کنترل‌ها و پاپ‌آپ هم‌رنگ تم تیره ---------- */
.lux-map-popup .maplibregl-popup-content {
background: transparent;
padding: 0;
border-radius: 1rem;
box-shadow: none;
}
/* دم پاپ‌آپ — هم‌رنگ کارت شیشه‌ای */
.lux-map-popup .maplibregl-popup-tip {
border-top-color: #1e1a16;
}
.maplibregl-ctrl-group {
overflow: hidden;
border: 1px solid rgba(245, 243, 239, 0.14);
border-radius: 0.75rem !important;
background: rgba(30, 26, 22, 0.85) !important;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45) !important;
backdrop-filter: blur(10px);
}
.maplibregl-ctrl-group button {
width: 34px;
height: 34px;
background-color: transparent !important;
}
.maplibregl-ctrl-group button:hover {
background-color: rgba(213, 135, 76, 0.16) !important;
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid rgba(245, 243, 239, 0.12) !important;
}
/* آیکون‌های پیش‌فرض کنترل‌ها تیره‌اند — با اینورتر روشن می‌شوند */
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
filter: invert(0.92);
}
.maplibregl-ctrl-attrib.maplibregl-compact {
border-radius: 0.75rem;
background: rgba(30, 26, 22, 0.85);
color: rgba(245, 243, 239, 0.55);
font-size: 10px;
}
.maplibregl-ctrl-attrib a {
color: rgba(213, 135, 76, 0.9);
}
.maplibregl-ctrl-attrib-button {
filter: invert(0.92);
}
.maplibregl-ctrl-scale {
border-color: rgba(245, 243, 239, 0.35);
border-radius: 2px;
background: rgba(30, 26, 22, 0.7);
color: rgba(245, 243, 239, 0.6);
font-size: 10px;
}
/* ---------- ورود نرم کارت پاپ‌آپ و حالت تمام‌صفحه ---------- */
@keyframes luxPopupIn {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes luxFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
Binary file not shown.
+34
View File
@@ -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>
)
}
+133
View File
@@ -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>
)
}
+81
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+66
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+35
View File
@@ -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
}
+15
View File
@@ -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)
+141
View File
@@ -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)
}
+41
View File
@@ -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 }
+2
View File
@@ -0,0 +1,2 @@
/** وضعیت چرخه‌ی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
export type MapStatus = 'loading' | 'ready' | 'error'
+192
View File
@@ -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 }
}
+156
View File
@@ -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 }
}
+85 -47
View File
@@ -1,33 +1,41 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon, ClockIcon } from 'lucide-react';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button';
import { PremiumButton } from '../components/PremiumButton';
import { ArticleCard } from '../components/ArticleCard';
import { ArticleMeta } from '../components/ArticleMeta';
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo';
import { useEffect } from 'react';
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';
/** بدنه مقاله را به HTML تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(article: Article): { html: string | null; legacy: string[] } {
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(
article: Article,
dir: 'rtl' | 'ltr',
): { html: string | null; toc: TocItem[]; legacy: string[] } {
const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, legacy: body };
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try {
return {
html: renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }),
legacy: [],
};
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
// تیترها شناسه لنگر می‌گیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچک‌تر» بسته‌بندی می‌شود
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
return { html: htmlWithIds, toc: items, legacy: [] };
} catch {
return { html: null, legacy: [] };
return { html: null, toc: [], legacy: [] };
}
}
return { html: null, legacy: [] };
return { html: null, toc: [], legacy: [] };
}
export function ArticleDetail() {
@@ -41,7 +49,8 @@ export function ArticleDetail() {
if (!article) return;
applySeo({
title: `${article.title} | ${brand.name}`,
description: article.excerpt,
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description: article.meta_description || article.excerpt,
path: href(`/blog/${article.slug}`),
image: article.image,
type: 'article',
@@ -50,10 +59,10 @@ export function ArticleDetail() {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.excerpt,
description: article.meta_description || article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: lang,
author: { '@type': 'Organization', name: brand },
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
},
@@ -65,21 +74,36 @@ export function ArticleDetail() {
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
<ButtonLink to={href('/blog')} variant="copper" className="mt-8">
<PremiumButton to={href('/blog')} className="mt-8">
{t('backToBlog')}
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</PremiumButton>
</section>);
}
const { html, 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);
/**
* جهت غالب متن مقاله — بدنه (با 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 (
<>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img src={article.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img
src={article.image}
alt={article.image_alt || article.title}
title={article.image_title || undefined}
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"
@@ -91,44 +115,58 @@ export function ArticleDetail() {
<Link to={href('/')} className="transition-colors hover:text-copper">
{t('home')}
</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">
{t('blog')}
</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>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
<div className="mt-6 flex items-center gap-5 text-xs font-light text-ivory/60">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
</span>
</div>
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
<ArticleMeta article={article} className="mt-7" />
</div>
</section>
<article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-3xl px-6">
<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">
{article.excerpt}
</p>
</Reveal>
{html ? (
<Reveal delay={0.05}>
<div
className="article-body mt-4 text-base font-light text-ivory/70"
dangerouslySetInnerHTML={{ __html: html }} />
</Reveal>
) : (
legacy.map((paragraph, i) =>
<Reveal key={i} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
<div className="mx-auto max-w-site px-6 lg:px-10">
<div
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>
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
{article.excerpt}
</p>
</Reveal>
)
)}
{html ? (
<Reveal delay={0.05}>
<div
className="article-body mt-4 text-base font-light text-ivory/70"
dangerouslySetInnerHTML={{ __html: html }} />
</Reveal>
) : (
legacy.map((paragraph, i) =>
<Reveal key={i} delay={0.03 * i}>
<p className="mt-8 text-base font-light leading-10 text-ivory/70">{paragraph}</p>
</Reveal>
)
)}
</div>
{/* فهرست مطالب — در دسکتاپ کنار متن می‌چسبد و همراه مقاله پایین می‌آید */}
{hasToc && (
<aside
dir={dir}
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
>
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
</aside>
)}
</div>
</div>
</article>
+4 -2
View File
@@ -9,6 +9,8 @@ import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) {
@@ -94,10 +96,10 @@ export function Blog() {
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{featured.date}</span>
<span>{formatJalali(featured.date)}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{featured.read}
{toPersianDigits(featured.read)}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
+2 -19
View File
@@ -14,25 +14,8 @@ export function ContactPage() {
<>
<PageHero pageKey="contact" />
<section className="bg-ink py-24" aria-label={t('officesAria')}>
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full">
<EditableItem resource="offices" item={office} className="h-full">
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
<div className="mt-auto pt-10">
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
{office.hours}
</p>
</div>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="offices" />
</div>
<section className="bg-ink py-2" aria-label={t('officesAria')}>
</section>
<ContactSection />
+1 -1
View File
@@ -19,8 +19,8 @@ export function Home() {
return (
<>
<Hero />
<Story />
<Stats />
<Story />
<Services />
<BeforeAfter />
<FeaturedProjects />
+145 -109
View File
@@ -1,74 +1,98 @@
import React, { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
import { ChevronLeftIcon } from 'lucide-react'
import { Reveal } from '../components/Reveal'
import { ButtonLink } from '../components/Button'
import { PremiumButton } from '../components/PremiumButton'
import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox'
import { useEffect } from 'react';
import { LazyProjectsMapSingle } from '../map/LazyMaps';
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo';
import { projectsData } from '../data/projectsData';
export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite()
const { t, href, lang } = useLang()
const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '')
const currentProject = projectsData.find((p) => p.slug === slug)
const [lightbox, setLightbox] = useState<number | null>(null)
// عنوان تب، توضیح متا و OG از خود پروژه
useEffect(() => {
if (!project) return;
applySeo({
title: `${project.title} | ${brand.name}`,
description: project.summary,
path: href(`/projects/${project.slug}`),
image: project.image,
lang,
});
}, [project?.id, lang, href]);
if (!project) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">
{t('projectNotFoundText')}
</p>
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
{t('backToProjects')}
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</section>
)
if (!currentProject) {
return <div className="text-center py-20 text-ivory">Project not found</div>
}
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
const backendProject = projects.find((p) => p.slug === slug)
// Dynamic bilingual text from projectsData
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
const displayLocation = currentProject.location[lang as 'en' | 'fa'] || currentProject.location.en;
const displayArea = currentProject.area[lang as 'en' | 'fa'] || currentProject.area.en;
const displayStatus = currentProject.status;
const statusLabel = useStatusLabel(displayStatus)
// Fallbacks for layout structure fields missing in projectsData
const summary = backendProject?.summary || (lang === 'en' ? 'A modern and luxurious architectural project built with high-quality materials and smart design.' : 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ.');
const body = backendProject?.body || (lang === 'en'
? ['Located in the heart of the city, this project offers unparalleled access to major urban hubs.', 'It features state-of-the-art facilities, green spaces, and a sustainable approach to modern living.']
: ['لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است.', 'چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.']);
const gallery = backendProject?.gallery?.length ? backendProject.gallery : [
'/seed-media/2026/09/1.png',
'/seed-media/2026/09/2.png',
'/seed-media/2026/09/3.png',
'/seed-media/2026/09/4.png'
];
const image = backendProject?.image || '/seed-media/2026/09/1.png';
const year = backendProject?.year || (lang === 'en' ? '2024' : '1402');
const floors = backendProject?.floors || '5';
const units = backendProject?.units || '20';
const client = backendProject?.client || (lang === 'en' ? 'Aramesh Group' : 'گروه آرامش');
// تصاویر پاپ‌آپ نقشه
const mapImages = useMemo(
() => [image, ...gallery].filter(Boolean),
[image, gallery]
)
// مختصات همین پروژه
const mapLocation = useMemo(
() => findMapProjectByName(displayTitle) ?? createPlaceholderProject(displayTitle, displayLocation),
[displayTitle, displayLocation]
)
// SEO
useEffect(() => {
applySeo({
title: `${displayTitle} | ${brand.name}`,
description: summary,
path: href(`/projects/${currentProject.slug}`),
image: image,
lang,
});
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]);
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3)
const facts = [
{ label: t('specLocation'), value: project.location },
{ label: t('specYear'), value: project.year },
{ label: t('specArea'), value: project.area },
{ label: t('specFloors'), value: project.floors },
{ label: t('specUnits'), value: project.units },
{ label: t('specClient'), value: project.client },
{ label: t('specLocation'), value: displayLocation },
{ label: t('specYear'), value: year },
{ label: t('specArea'), value: displayArea },
{ label: t('specFloors'), value: floors },
{ label: t('specUnits'), value: units },
{ label: t('specClient'), value: client },
]
return (
<>
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper">
{t('home')}
</Link>
@@ -77,75 +101,80 @@ export function ProjectDetail() {
{t('projects')}
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{project.title}</span>
<span className="text-copper">{displayTitle}</span>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
</div>
</section>
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
<Reveal className="order-1">
<span className="flex items-center gap-3 text-sm font-light text-copper">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
<div className="mb-8">
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
</div>
{/* Gallery Desktop: Grid. Mobile: Grid */}
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]">
{project.body.map((para) => (
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
<div className="mt-10">
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
{t('similarProjectCta')}
</ButtonLink>
</div>
</Reveal>
<Reveal delay={0.1} className="order-2">
<button
{/* Main large image */}
<button
type="button"
className="col-span-2 lg:col-span-1 h-[250px] md:h-[400px] lg:h-full relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block"
onClick={() => setLightbox(0)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={project.gallery[0] || project.image}
alt=""
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
{project.gallery.slice(1).map((img, i) => (
<button
key={img}
{/* 2x2 Small images */}
<div className="col-span-2 lg:col-span-1 grid grid-cols-2 gap-2 md:gap-3 lg:grid-rows-2 lg:h-full">
{mapImages.slice(1, 5).map((img, i) => (
<button
key={img}
type="button"
className={`relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block h-[120px] md:h-[200px] lg:h-full ${i >= 2 ? 'hidden md:block' : ''}`}
onClick={() => setLightbox(i + 1)}
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={img}
alt=""
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
</button>
))}
</div>
</Reveal>
</div>
{/* Smart Content Layout */}
<div className="max-w-4xl mx-auto mt-16 lg:mt-24 flex flex-col gap-16 lg:gap-20">
{/* Description */}
<Reveal>
<span className="flex items-center gap-3 text-sm font-light text-copper mb-6">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
<p className="mt-8 text-xl font-medium leading-9 text-ivory/90">{summary}</p>
{body.map((para) => (
<p key={para} className="mt-6 text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
</Reveal>
{/* Specifications Table - Perfectly center-aligned */}
<Reveal>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-px border border-ivory/10 bg-ivory/10 rounded-xl overflow-hidden">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 flex flex-col items-center justify-center text-center">
<dt className="text-xs font-light text-ivory/50 mb-2">{f.label}</dt>
<dd className="text-base font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
</Reveal>
{/* Map (بدون Reveal) */}
{mapLocation && (
<div className="h-80 md:h-[400px] rounded-xl overflow-hidden border border-white/10 shadow-2xl">
<LazyProjectsMapSingle location={mapLocation} images={mapImages} className="h-full w-full" />
</div>
)}
</div>
</div>
</section>
@@ -154,17 +183,24 @@ export function ProjectDetail() {
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => (
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ProjectCard project={item} />
</Reveal>
))}
{related.map((item, i) => {
const dummyImageName = `${(i % 6) + 1}.png`;
return (
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
<ProjectCard
project={item}
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
detailHref={href(`/projects/${item.slug}`)}
/>
</Reveal>
);
})}
</div>
</div>
</section>
<Lightbox
images={project.gallery}
images={gallery}
index={lightbox}
onClose={() => setLightbox(null)}
onChange={setLightbox}
+93 -20
View File
@@ -1,18 +1,20 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { PageHero } from '../components/PageHero';
import { Reveal } from '../components/Reveal';
import { ProjectCard } from '../components/ProjectCard';
import { PremiumButton } from '../components/PremiumButton';
import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection';
import type { ProjectStatus } from '../data/site';
import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit';
import { AddItemButton, EditableItem } from '../admin/Editable';
import { projectsData } from '../data/projectsData';
import { LazyProjectsMapAll } from '../map/LazyMaps';
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
export function Projects() {
const { projects, status_labels } = useSite();
const { status_labels } = useSite();
const { t } = useLang();
const save = useFieldSaver();
@@ -27,27 +29,79 @@ export function Projects() {
const doneLabel = useStatusLabel('done');
const progressLabel = useStatusLabel('progress');
const FILTERS: {key: ProjectStatus;label: string;}[] = [
const FILTERS: { key: 'done' | 'progress'; label: string; }[] = [
{ key: 'done', label: doneLabel },
{ key: 'progress', label: progressLabel }
];
const [status, setStatus] = useState<ProjectStatus>('done');
const list = projects.filter((p) => p.status === status);
const [status, setStatus] = useState<'done' | 'progress'>('done');
// Filtering and Pagination State
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const itemsPerPage = 12;
// Filter projects by status
const filteredProjects = useMemo(() => {
return projectsData.filter((p) => p.status === status);
}, [status]);
// Currently displayed projects
const displayedProjects = useMemo(() => {
return filteredProjects.slice(0, page * itemsPerPage);
}, [filteredProjects, page]);
// Handle Tab Switch
const handleTabSwitch = (newStatus: 'done' | 'progress') => {
setStatus(newStatus);
setPage(1); // Reset pagination on tab switch
};
// Handle Load More
const handleLoadMore = () => {
setIsLoading(true);
// Brief loading state
setTimeout(() => {
setPage((prev) => prev + 1);
setIsLoading(false);
}, 400);
};
const BASE_IMG_URL = "/seed-media/2026/09/";
// Resolve a project href from a MapProject entry by matching against projectsData slugs
const resolveProjectHref = (mapProject: MapProject): string | undefined => {
const match = projectsData.find(
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name
);
return match ? `/projects/${match.slug}` : undefined;
};
return (
<>
<PageHero pageKey="projects" />
{/* نقشهٔ محدود‌شده — همهٔ پروژه‌ها با سنجاق مسی */}
<section className="bg-char py-10" aria-label={t('mapAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="w-full max-w-full overflow-hidden rounded-2xl border border-white/10">
<LazyProjectsMapAll
projects={MAP_PROJECTS}
resolveProjectHref={resolveProjectHref}
className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full"
/>
</div>
</div>
</section>
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (وسط‌چین و بدون کادر) */}
{/* دکمه‌های فیلتر */}
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => {
const isSelected = status === filter.key;
return (
<React.Fragment key={filter.key}>
{/* خط جداکننده بین دکمه‌ها */}
{index > 0 && (
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
)}
@@ -56,7 +110,7 @@ export function Projects() {
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => setStatus(filter.key)}
onClick={() => handleTabSwitch(filter.key)}
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
isSelected
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
@@ -76,15 +130,34 @@ export function Projects() {
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{list.map((project, i) => (
<Reveal key={project.id} delay={0.05 * i} className="h-full">
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} imageClass="h-80" />
</EditableItem>
</Reveal>
))}
<AddItemButton resource="projects" />
{displayedProjects.map((project, index) => {
const dummyImageName = `${(index % 6) + 1}.png`;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return (
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
<ProjectCard
project={project}
imageUrl={imageUrl}
detailHref={`/projects/${project.slug}`}
/>
</Reveal>
);
})}
</div>
{/* Load More Button */}
{displayedProjects.length < filteredProjects.length && (
<div className="mt-16 flex justify-center">
<PremiumButton
onClick={handleLoadMore}
disabled={isLoading}
className="px-10 py-4 w-48"
>
{isLoading ? 'در حال بارگذاری...' : 'مشاهده بیشتر'}
</PremiumButton>
</div>
)}
</div>
</section>
@@ -92,4 +165,4 @@ export function Projects() {
<ContactSection />
</>
);
}
}
+74
View File
@@ -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)
}
}
+69
View File
@@ -0,0 +1,69 @@
import DateObject from 'react-date-object'
import gregorian from 'react-date-object/calendars/gregorian'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
/** قالب رسمی نمایش تاریخ جلالی — تقویم شمسی و اعداد فارسی */
export const JALALI_DATE_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
calendar: 'persian',
})
/** ساعت به وقت جلالی (فارسی) */
export const JALALI_TIME_FORMATTER = new Intl.DateTimeFormat('fa-IR', {
hour: '2-digit',
minute: '2-digit',
})
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T\s].*)?$/
/**
* نمایش هر مقدار تاریخ به شکل جلالی (مثل «۲۲ شهریور ۱۴۰۵»).
* رشته‌هایی که تاریخ نیستند (مثل تاریخ شمسی دست‌نویس) دست‌نخورده برمی‌گردند.
*/
export function formatJalali(value?: string | number | Date | null, fallback = ''): string {
if (value === null || value === undefined || value === '') return fallback
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(value)
}
if (typeof value === 'number') {
const fromNumber = new Date(value)
return Number.isNaN(fromNumber.getTime()) ? fallback : JALALI_DATE_FORMATTER.format(fromNumber)
}
const raw = String(value).trim()
if (!raw) return fallback
if (ISO_DATE.test(raw)) {
const parsed = new Date(raw)
if (!Number.isNaN(parsed.getTime())) return JALALI_DATE_FORMATTER.format(parsed)
}
return raw
}
/** رشته ISO میلادی ذخیره‌شده (YYYY-MM-DD) → DateObject شمسی برای انتخابگر تاریخ */
export function toJalaliDateObject(value?: string | null): DateObject | null {
const raw = (value ?? '').trim()
if (!raw) return null
if (ISO_DATE.test(raw)) {
const parsed = new DateObject({ date: raw })
if (parsed.isValid) return parsed.convert(persian, persian_fa)
}
// تاریخ‌های شمسی دست‌نویس مثل ۱۴۰۳/۰۵/۱۲
const manual = new DateObject({ date: raw, calendar: persian, locale: persian_fa })
return manual.isValid ? manual : null
}
/** انتخاب انتخابگر تاریخ → رشته ISO میلادی برای ذخیره در دیتابیس */
export function toISODate(value?: DateObject | Date | null): string {
if (!value) return ''
const asObject = value instanceof Date ? new DateObject({ date: value }) : value
const gregorianDate = asObject.convert(gregorian)
const pad = (n: number) => String(n).padStart(2, '0')
return `${gregorianDate.year}-${pad(gregorianDate.month.number)}-${pad(gregorianDate.day)}`
}
+6
View File
@@ -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)])
}
+30
View File
@@ -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'
}
+48
View File
@@ -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') }
}
+22
View File
@@ -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}`
}
+33
View File
@@ -0,0 +1,33 @@
/**
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
* حروف و اعداد فارسی/عربی و لاتین در شناسه می‌مانند (در URL به‌صورت percent-encoded
* نوشته می‌شوند و در همه مرورگرها کار می‌کنند)؛ فقط نشانه‌ها و فاصله‌ها به خط تیره
* تبدیل می‌شوند. نیم‌فاصله و نویسه‌های نامرئی جهت هم حذف می‌شوند تا تیترهای تکراری
* شناسه یکتا بگیرند.
*/
export function slugifyFa(input: string): string {
return (input ?? '')
.toString()
.normalize('NFC')
// نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → حذف
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
// یکسان‌سازی حروف عربی با معادل فارسی
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه')
.toLowerCase()
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim()
.replace(/\s+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
}
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */
export function uniqueSlug(base: string, used: Map<string, number>): string {
const key = base || 'section'
const count = used.get(key) ?? 0
used.set(key, count + 1)
return count === 0 ? key : `${key}-${count + 1}`
}
+103
View File
@@ -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 }
}
+45
View File
@@ -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'));
+1
View File
@@ -5,6 +5,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
/* Bundler mode */
"moduleResolution": "bundler",
+10 -2
View File
@@ -4,11 +4,19 @@ import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// وب‌ورکر مپ‌لیبر از node_modules ساخته و به‌عنوان دارایی خروجی منتشر می‌شود؛
// در src/map/mapWorker.ts با setWorkerUrl به آن اشاره می‌کنیم (نسخه‌ی
// پیش‌فرض مپ‌لیبر کنار باندل است و در خروجی ویت وجود ندارد).
worker: {
format: 'es',
},
server: {
// در حالت توسعه، /api و /media به Traefik پروکسی می‌شوند (پورت ۵۱۷۳ روی هاست)
port: 5174, // سرور توسعه فرانت‌اند روی این پورت اجرا می‌شود تا تداخل نکند
strictPort: true, // جلوگیری از تغییر خودکار پورت
proxy: {
// در حالت توسعه، درخواست‌های API و Media به Traefik (پورت ۵۱۷۳) فرستاده می‌شوند
'/api': 'http://localhost:5173',
'/media': 'http://localhost:5173',
},
},
})
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1009 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Some files were not shown because too many files have changed in this diff Show More