Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575e4868be | ||
|
|
846682ac4a | ||
|
|
f7105a62cc | ||
|
|
0628344dcf | ||
|
|
d25db9367c | ||
|
|
1d29e50852 | ||
|
|
853ad67db5 |
@@ -55,3 +55,17 @@ OFFSITE_RCLONE_REMOTE=
|
|||||||
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
|
||||||
# DOMAIN=aramesh.example
|
# DOMAIN=aramesh.example
|
||||||
# ACME_EMAIL=admin@aramesh.example
|
# ACME_EMAIL=admin@aramesh.example
|
||||||
|
# دامنه دوم (در صورت وجود) — مسیرها و گواهی TLS برای هر دو صادر میشود
|
||||||
|
# DOMAIN_ALIAS=aramesh.example.ir
|
||||||
|
|
||||||
|
# --- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ---
|
||||||
|
# «on» یعنی سایت آنلاین است اما پشت گیت ورود و بدون ایندکس؛ «off» یعنی انتشار عمومی.
|
||||||
|
# این مقادیر را دستی ویرایش نکنید — اسکریپت preview-mode.sh هماهنگشان میکند:
|
||||||
|
# bash scripts/preview-mode.sh on|off|status → bash scripts/stack.sh deploy-prod
|
||||||
|
# PREVIEW_MODE=on
|
||||||
|
# هش htpasswd کاربر مشترک پیشنمایش — اولین «on» خودش میسازد
|
||||||
|
# PREVIEW_AUTH_USERS=preview:$apr1$...
|
||||||
|
# فهرست میانافزارهای فعال؛ «on» پرش میکند و «off» خالیش میکند
|
||||||
|
# PREVIEW_MIDDLEWARES=preview-auth@docker,preview-noindex@docker
|
||||||
|
# در انتشار، دامنه دوم را به اصلی ریدایرکت میکند تا محتوای تکراری ساخته نشود
|
||||||
|
# ALIAS_REDIRECT_MIDDLEWARES=to-primary@docker
|
||||||
|
|||||||
@@ -103,12 +103,47 @@ bash scripts/stack.sh deploy-prod
|
|||||||
`ssh -L 9001:127.0.0.1:9001 server`
|
`ssh -L 9001:127.0.0.1:9001 server`
|
||||||
- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
|
- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
|
||||||
|
|
||||||
|
### پیشنمایش خصوصی (کار روی سایت، قبل از انتشار عمومی)
|
||||||
|
|
||||||
|
سایت روی اینترنت بالا میآید و تیم با همان پنل مدیریت محتوا را ویرایش میکند
|
||||||
|
(ویرایش درجای ایمیل/تلفن/متنها و نوشتن مقاله)، اما **نه عموم به آن دسترسی دارند و
|
||||||
|
نه گوگل چیزی ایندکس میکند**. دو لایه محافظت:
|
||||||
|
|
||||||
|
1. **گیت ورود Traefik (Basic Auth)** — همه مسیرها (سایت، API، تصاویر و چت) پشت یک
|
||||||
|
نام کاربری/رمز مشترکاند؛ بدون آن فقط ۴۰۱ برمیگردد
|
||||||
|
2. **noindex** — هدر `X-Robots-Tag` روی همه پاسخها + `robots.txt` مسدودکننده +
|
||||||
|
نقشه سایت خالی؛ حتی اگر گیت برداشته شود، گوگل چیزی ذخیره نمیکند
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/preview-mode.sh on # فعالسازی (بار اول نام کاربری/رمز میسازد و چاپ میکند)
|
||||||
|
bash scripts/stack.sh deploy-prod # اعمال
|
||||||
|
bash scripts/preview-mode.sh status # وضعیت فعلی
|
||||||
|
```
|
||||||
|
|
||||||
|
- دامنه اصلی (`DOMAIN`) و دامنه دوم (`DOMAIN_ALIAS`) هر دو سرو میشوند و هر دو گیت دارند
|
||||||
|
- برای دادن دسترسی به کارفرما، همان نام کاربری/رمز مشترک را بدهید
|
||||||
|
- رمز مشترک در `.env` (`PREVIEW_AUTH_USERS`) میماند — فقط یکبار چاپ میشود
|
||||||
|
|
||||||
|
### انتشار عمومی (برداشتن پیشنمایش)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/preview-mode.sh off # حذف گیت ورود + اجازه ایندکس
|
||||||
|
bash scripts/stack.sh deploy-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
بعد از این مرحله:
|
||||||
|
|
||||||
|
1. دامنه دوم بهصورت ۳۰۱ دائمی به دامنه اصلی ریدایرکت میشود (جلوگیری از محتوای تکراری)
|
||||||
|
2. در Search Console دامنه را تأیید و `https://DOMAIN/sitemap.xml` را ثبت کنید
|
||||||
|
3. اگر پیشتر چیزی ایندکس شده بود، حذفش را از همان کنسول درخواست کنید
|
||||||
|
|
||||||
### چکلیست رفتن به تولید
|
### چکلیست رفتن به تولید
|
||||||
|
|
||||||
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
|
1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
|
||||||
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
|
||||||
3. `DOMAIN`، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
3. `DOMAIN`، `DOMAIN_ALIAS` (دامنه دوم)، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
|
||||||
4. اولین انتشار را روی یک زیردامنه آزمایشی انجام دهید (نقش staging) — `deploy-prod`
|
4. `bash scripts/preview-mode.sh on` — اولین انتشار بهصورت پیشنمایش خصوصی میرود؛
|
||||||
|
محتوا را روی سایت آنلاین نهایی کنید و بعد `preview-mode.sh off` + deploy مجدد
|
||||||
5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
|
5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
|
||||||
6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
|
6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
|
||||||
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
|
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import ipaddress
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -294,9 +300,41 @@ def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, in
|
|||||||
return data, content_type, orig_w, orig_h
|
return data, content_type, orig_w, orig_h
|
||||||
|
|
||||||
|
|
||||||
|
def _readable_key(name: str, ext: str) -> str:
|
||||||
|
"""کلید ذخیرهسازی خوانا از نام دلخواه/نام فایل — «۱۴۰۳/elahieh-tower-a1b2c3.jpg».
|
||||||
|
|
||||||
|
نامگذاریها ایمن میشوند: نویسههای ناامن و جداکننده مسیر حذف، فاصلهها خط تیره،
|
||||||
|
طول محدود؛ خالی بودن نام → شناسه تصادفی مثل قبل. پسوند ۶ نویسهای تضمین یکتایی است.
|
||||||
|
"""
|
||||||
|
slug = re.sub(r"[^\w-ۿ\- ]+", "", name, flags=re.UNICODE).strip()
|
||||||
|
slug = re.sub(r"[\s_]+", "-", slug).strip("-")[:80]
|
||||||
|
unique = uuid.uuid4().hex[:6]
|
||||||
|
base = f"{slug}-{unique}" if slug else uuid.uuid4().hex
|
||||||
|
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def _store_image(data: bytes, content_type: str, name: str) -> str:
|
||||||
|
"""ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمیگردد."""
|
||||||
|
ext = ALLOWED_IMAGE_TYPES[content_type]
|
||||||
|
key = _readable_key(name, ext)
|
||||||
|
s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name="us-east-1",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
||||||
|
return f"{settings.media_public_base}/{key}"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_media(
|
async def upload_media(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
name: str = Form(""),
|
||||||
_admin: models.AdminUser = Depends(get_current_admin),
|
_admin: models.AdminUser = Depends(get_current_admin),
|
||||||
):
|
):
|
||||||
content_type = (file.content_type or "").lower()
|
content_type = (file.content_type or "").lower()
|
||||||
@@ -325,24 +363,90 @@ async def upload_media(
|
|||||||
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
|
||||||
pass
|
pass
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
|
||||||
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
|
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
|
||||||
|
url = _store_image(data, content_type, display_name)
|
||||||
s3 = boto3.client(
|
|
||||||
"s3",
|
|
||||||
endpoint_url=settings.s3_endpoint,
|
|
||||||
aws_access_key_id=settings.s3_access_key,
|
|
||||||
aws_secret_access_key=settings.s3_secret_key,
|
|
||||||
region_name="us-east-1",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"url": f"{settings.media_public_base}/{key}",
|
"url": url,
|
||||||
"key": key,
|
"key": url.removeprefix(f"{settings.media_public_base}/"),
|
||||||
|
"size": len(data),
|
||||||
|
"content_type": content_type,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- درونریزی تصویر از نشانی خارجی (چسباندن از Outline و مانند آن) ----------
|
||||||
|
|
||||||
|
|
||||||
|
class ImportImageIn(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_public_http(url: str) -> None:
|
||||||
|
"""فقط http/https به مقاصد عمومی — جلوگیری از SSRF به شبکه داخلی."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||||
|
raise HTTPException(status_code=400, detail="نشانی تصویر معتبر نیست")
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(parsed.hostname, None)
|
||||||
|
except socket.gaierror:
|
||||||
|
raise HTTPException(status_code=400, detail="میزبان تصویر پیدا نشد")
|
||||||
|
for info in infos:
|
||||||
|
ip = ipaddress.ip_address(info[4][0])
|
||||||
|
if not ip.is_global:
|
||||||
|
raise HTTPException(status_code=400, detail="نشانی تصویر به میزبان داخلی اشاره میکند")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-image")
|
||||||
|
async def import_image(
|
||||||
|
payload: ImportImageIn,
|
||||||
|
_admin: models.AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""دانلود تصویر از نشانی خارجی، بهینهسازی و ذخیره در Object Storage خودِ سایت —
|
||||||
|
تصاویر چسباندهشده از Outline/مستندات خارجی به میزبانی سایت منتقل میشوند."""
|
||||||
|
_assert_public_http(payload.url)
|
||||||
|
|
||||||
|
max_bytes = settings.media_max_upload_mb * 1024 * 1024
|
||||||
|
request = urllib.request.Request(
|
||||||
|
payload.url, headers={"User-Agent": "ArameshSite/1.0 (+image import)"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||||||
|
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail="فرمت تصویر پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
|
||||||
|
)
|
||||||
|
data = response.read(max_bytes + 1)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
|
||||||
|
if len(data) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="تصویر خالی است")
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
|
||||||
|
)
|
||||||
|
|
||||||
|
width = height = None
|
||||||
|
if content_type in REENCODE_TYPES:
|
||||||
|
try:
|
||||||
|
data, content_type, width, height = _optimize_image(data, content_type)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
|
||||||
|
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||||
|
media_url = _store_image(data, content_type, url_name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": media_url,
|
||||||
|
"key": media_url.removeprefix(f"{settings.media_public_base}/"),
|
||||||
"size": len(data),
|
"size": len(data),
|
||||||
"content_type": content_type,
|
"content_type": content_type,
|
||||||
"width": width,
|
"width": width,
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ class Settings(BaseSettings):
|
|||||||
site_url: str = ""
|
site_url: str = ""
|
||||||
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
# مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
|
||||||
frontend_dist: str = "../frontend/dist"
|
frontend_dist: str = "../frontend/dist"
|
||||||
|
# پیشنمایش خصوصی: سایت روی اینترنت است اما نه عمومی و نه ایندکسشدنی.
|
||||||
|
# گیت اصلی ورود در Traefik است (Basic Auth)؛ این پرچم لایه دوم است:
|
||||||
|
# هدر noindex روی همه پاسخها + robots.txt مسدودکننده.
|
||||||
|
preview_mode: bool = False
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ app.add_middleware(
|
|||||||
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||||
|
|
||||||
|
|
||||||
|
# پیشنمایش خصوصی — لایه دوم جلوگیری از ایندکس (لایه اول، گیت ورود Traefik است).
|
||||||
|
# روی همه پاسخها هدر noindex مینشیند تا حتی در صورت حذف گیت، گوگل چیزی ذخیره نکند.
|
||||||
|
# همیشه ثبت میشود و پرچم در زمان هر درخواست خوانده میشود (خاموشکردن انتشار = فقط تغییر .env).
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _preview_noindex(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if settings.preview_mode:
|
||||||
|
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||||
|
return response
|
||||||
|
|
||||||
app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
app.include_router(public.router, prefix="/api", tags=["عمومی"])
|
||||||
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
|
||||||
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
|
||||||
|
|||||||
@@ -145,6 +145,17 @@ def run_ddl_migrations() -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
|
||||||
)
|
)
|
||||||
|
# متادیتای تصویر شاخص مقاله + پرسشوپاسخهای پایان مقاله (سئو)
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_title VARCHAR(256) NOT NULL DEFAULT ''")
|
||||||
|
)
|
||||||
|
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs JSON"))
|
||||||
|
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS faqs_en JSON"))
|
||||||
|
# سطرهای موجود ستون تازه را NULL میگیرند — آرایه خالی یعنی «بدون پرسشوپاسخ»
|
||||||
|
conn.execute(text("UPDATE articles SET faqs = '[]'::json WHERE faqs IS NULL"))
|
||||||
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
# ستونهای انگلیسی — رشتهها با پیشفرض خالی، JSON بدون پیشفرض (NULL = بدون ترجمه)
|
||||||
for table, column, col_type in EN_COLUMNS:
|
for table, column, col_type in EN_COLUMNS:
|
||||||
if col_type == "JSON":
|
if col_type == "JSON":
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
@@ -95,12 +96,18 @@ class Article(Base):
|
|||||||
category: Mapped[str] = mapped_column(String(64), default="")
|
category: Mapped[str] = mapped_column(String(64), default="")
|
||||||
category_en: Mapped[str] = mapped_column(String(64), default="")
|
category_en: Mapped[str] = mapped_column(String(64), default="")
|
||||||
image: Mapped[str] = mapped_column(Text, default="")
|
image: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
# متن جایگزین و عنوان تصویر شاخص — سئو و دسترسپذیری
|
||||||
|
image_alt: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
image_title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
read: Mapped[str] = mapped_column(String(32), default="")
|
read: Mapped[str] = mapped_column(String(32), default="")
|
||||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||||
|
# پرسش و پاسخهای پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
|
||||||
|
faqs: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
|
||||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||||
|
|||||||
@@ -77,12 +77,17 @@ class ArticleOut(ORMModel):
|
|||||||
category: str
|
category: str
|
||||||
category_en: str = ""
|
category_en: str = ""
|
||||||
image: str
|
image: str
|
||||||
|
image_alt: str = ""
|
||||||
|
image_title: str = ""
|
||||||
read: str
|
read: str
|
||||||
read_en: str = ""
|
read_en: str = ""
|
||||||
excerpt: str
|
excerpt: str
|
||||||
excerpt_en: str = ""
|
excerpt_en: str = ""
|
||||||
body: dict
|
body: dict
|
||||||
body_en: Optional[dict] = None
|
body_en: Optional[dict] = None
|
||||||
|
# پرسشوپاسخهای پایان مقاله — [{question, answer}]؛ سطرهای قدیمی ممکن است NULL باشند
|
||||||
|
faqs: Optional[list[dict]] = None
|
||||||
|
faqs_en: Optional[list[dict]] = None
|
||||||
status: str
|
status: str
|
||||||
seo_keyphrase: str
|
seo_keyphrase: str
|
||||||
|
|
||||||
@@ -484,12 +489,16 @@ class ArticleIn(BaseModel):
|
|||||||
category: str = ""
|
category: str = ""
|
||||||
category_en: str = ""
|
category_en: str = ""
|
||||||
image: str = ""
|
image: str = ""
|
||||||
|
image_alt: str = ""
|
||||||
|
image_title: str = ""
|
||||||
read: str = ""
|
read: str = ""
|
||||||
read_en: str = ""
|
read_en: str = ""
|
||||||
excerpt: str = ""
|
excerpt: str = ""
|
||||||
excerpt_en: str = ""
|
excerpt_en: str = ""
|
||||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||||
|
faqs: list[dict] = []
|
||||||
|
faqs_en: list[dict] = []
|
||||||
status: Literal["draft", "published"] = "draft"
|
status: Literal["draft", "published"] = "draft"
|
||||||
seo_keyphrase: str = Field(default="", max_length=128)
|
seo_keyphrase: str = Field(default="", max_length=128)
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
@@ -786,12 +795,16 @@ class ArticlePatch(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
category_en: Optional[str] = None
|
category_en: Optional[str] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
|
image_alt: Optional[str] = None
|
||||||
|
image_title: Optional[str] = None
|
||||||
read: Optional[str] = None
|
read: Optional[str] = None
|
||||||
read_en: Optional[str] = None
|
read_en: Optional[str] = None
|
||||||
excerpt: Optional[str] = None
|
excerpt: Optional[str] = None
|
||||||
excerpt_en: Optional[str] = None
|
excerpt_en: Optional[str] = None
|
||||||
body: Optional[dict] = None
|
body: Optional[dict] = None
|
||||||
body_en: Optional[dict] = None
|
body_en: Optional[dict] = None
|
||||||
|
faqs: Optional[list[dict]] = None
|
||||||
|
faqs_en: Optional[list[dict]] = None
|
||||||
status: Optional[Literal["draft", "published"]] = None
|
status: Optional[Literal["draft", "published"]] = None
|
||||||
seo_keyphrase: Optional[str] = None
|
seo_keyphrase: Optional[str] = None
|
||||||
_validate_body = field_validator("body")(validate_doc_body)
|
_validate_body = field_validator("body")(validate_doc_body)
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
@router.get("/robots.txt", response_class=Response)
|
@router.get("/robots.txt", response_class=Response)
|
||||||
def robots(db: Session = Depends(get_db)):
|
def robots(db: Session = Depends(get_db)):
|
||||||
|
# پیشنمایش خصوصی — هیچ خزندهای نباید چیزی ذخیره کند
|
||||||
|
if settings.preview_mode:
|
||||||
|
return Response(
|
||||||
|
content="User-agent: *\nDisallow: /\n",
|
||||||
|
media_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
||||||
if settings.site_url:
|
if settings.site_url:
|
||||||
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
|
||||||
@@ -206,6 +212,13 @@ def _sitemap_entry(loc: str, fa: str, en: str) -> str:
|
|||||||
|
|
||||||
@router.get("/sitemap.xml", response_class=Response)
|
@router.get("/sitemap.xml", response_class=Response)
|
||||||
def sitemap(db: Session = Depends(get_db)):
|
def sitemap(db: Session = Depends(get_db)):
|
||||||
|
# پیشنمایش خصوصی — نقشه سایت خالی تا هیچ نشانیای اعلام نشود
|
||||||
|
if settings.preview_mode:
|
||||||
|
xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
|
||||||
|
)
|
||||||
|
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||||
fa_urls = list(STATIC_PATHS)
|
fa_urls = list(STATIC_PATHS)
|
||||||
fa_urls += [
|
fa_urls += [
|
||||||
f"/blog/{a.slug}"
|
f"/blog/{a.slug}"
|
||||||
@@ -262,7 +275,7 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
brand = _brand_name(db)
|
brand = _brand_name(db)
|
||||||
title, excerpt = _article_pair(article, "fa")
|
title, excerpt = _article_pair(article, "fa")
|
||||||
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
|
||||||
jsonld = {
|
jsonld: object = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": title,
|
"headline": title,
|
||||||
@@ -273,6 +286,10 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
"publisher": {"@type": "Organization", "name": brand},
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||||
}
|
}
|
||||||
|
# پرسشوپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایهای از دو اسکیما)
|
||||||
|
faq_schema = _faq_jsonld(article, "fa")
|
||||||
|
if faq_schema:
|
||||||
|
jsonld = [jsonld, faq_schema]
|
||||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
@@ -325,12 +342,35 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
# ---------- صفحات انگلیسی (/en/...) ----------
|
# ---------- صفحات انگلیسی (/en/...) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
|
||||||
|
"""اسکیمای FAQPage برای نتایج غنی گوگل — از پرسشوپاسخهای پایان مقاله.
|
||||||
|
نسخه انگلیسی فقط وقتی ترجمه ثبت شده باشد جایگزین میشود."""
|
||||||
|
faqs = article.faqs if isinstance(article.faqs, list) else []
|
||||||
|
if lang == "en":
|
||||||
|
faqs_en = article.faqs_en if isinstance(article.faqs_en, list) else None
|
||||||
|
if faqs_en:
|
||||||
|
faqs = faqs_en
|
||||||
|
entities = [
|
||||||
|
{"@type": "Question", "name": (f.get("question") or "").strip(),
|
||||||
|
"acceptedAnswer": {"@type": "Answer", "text": (f.get("answer") or "").strip()}}
|
||||||
|
for f in faqs
|
||||||
|
if isinstance(f, dict) and (f.get("question") or "").strip()
|
||||||
|
]
|
||||||
|
if not entities:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": entities,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
||||||
brand = _brand_name(db, "en")
|
brand = _brand_name(db, "en")
|
||||||
title, excerpt = _article_pair(article, "en")
|
title, excerpt = _article_pair(article, "en")
|
||||||
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
body = article.body_en if isinstance(article.body_en, dict) else article.body
|
||||||
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
|
||||||
jsonld = {
|
jsonld: object = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
"headline": title,
|
"headline": title,
|
||||||
@@ -341,6 +381,9 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
|||||||
"publisher": {"@type": "Organization", "name": brand},
|
"publisher": {"@type": "Organization", "name": brand},
|
||||||
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
|
||||||
}
|
}
|
||||||
|
faq_schema = _faq_jsonld(article, "en")
|
||||||
|
if faq_schema:
|
||||||
|
jsonld = [jsonld, faq_schema]
|
||||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
|
|
||||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||||
|
|
||||||
@@ -361,3 +362,218 @@ def test_article_body_url_sanitization(auth_client):
|
|||||||
body = res.json()["body"]
|
body = res.json()["body"]
|
||||||
marks = body["content"][0]["content"][0].get("marks", [])
|
marks = body["content"][0]["content"][0].get("marks", [])
|
||||||
assert all("href" not in m.get("attrs", {}) for m in marks)
|
assert all("href" not in m.get("attrs", {}) for m in marks)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- نامگذاری تصاویر، درونریزی و پرسشوپاسخ مقاله ----------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeS3:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.objects = {}
|
||||||
|
|
||||||
|
def put_object(self, **kwargs):
|
||||||
|
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_storage(monkeypatch):
|
||||||
|
"""MinIO در تست در دسترس نیست — کلاینت S3 و DNS عمومی جعل میشوند."""
|
||||||
|
fake = _FakeS3()
|
||||||
|
monkeypatch.setattr("app.api.admin.boto3.client", lambda *a, **k: fake)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.admin.socket.getaddrinfo",
|
||||||
|
lambda host, port, *a, **k: [(socket.AF_INET, None, None, "", ("93.184.216.34", 0))],
|
||||||
|
)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes() -> bytes:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (8, 8), (200, 120, 60)).save(buf, "PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_with_readable_name(auth_client, monkeypatch):
|
||||||
|
_mock_storage(monkeypatch)
|
||||||
|
res = auth_client.post(
|
||||||
|
"/api/admin/upload",
|
||||||
|
files={"file": ("raw name.png", _png_bytes(), "image/png")},
|
||||||
|
data={"name": "برج الهیه tower!"},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
key = res.json()["key"]
|
||||||
|
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر
|
||||||
|
assert "برج-الهیه-tower" in key
|
||||||
|
assert key.endswith(".png")
|
||||||
|
assert "!" not in key
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||||
|
_mock_storage(monkeypatch)
|
||||||
|
res = auth_client.post(
|
||||||
|
"/api/admin/upload",
|
||||||
|
files={"file": ("Elahieh Tower.jpg", _png_bytes(), "image/jpeg")},
|
||||||
|
data={"name": ""},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "Elahieh-Tower" in res.json()["key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_image_rejects_internal_host(auth_client):
|
||||||
|
# SSRF — میزبان حلقهمحلی/خصوصی رد میشود پیش از هر درخواست شبکه
|
||||||
|
res = auth_client.post("/api/admin/import-image", json={"url": "http://127.0.0.1:9000/x.png"})
|
||||||
|
assert res.status_code == 400
|
||||||
|
res = auth_client.post("/api/admin/import-image", json={"url": "http://192.168.1.5/x.png"})
|
||||||
|
assert res.status_code == 400
|
||||||
|
res = auth_client.post("/api/admin/import-image", json={"url": "file:///etc/passwd"})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_image_rejects_bad_content_type(auth_client, monkeypatch):
|
||||||
|
_mock_storage(monkeypatch)
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self):
|
||||||
|
self.headers = {"Content-Type": "text/html"}
|
||||||
|
|
||||||
|
def read(self, n):
|
||||||
|
return b"<html></html>"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout):
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", fake_urlopen)
|
||||||
|
res = auth_client.post("/api/admin/import-image", json={"url": "https://example.com/page"})
|
||||||
|
assert res.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_image_happy_path(auth_client, monkeypatch):
|
||||||
|
_mock_storage(monkeypatch)
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self):
|
||||||
|
self.headers = {"Content-Type": "image/png"}
|
||||||
|
|
||||||
|
def read(self, n):
|
||||||
|
return _png_bytes()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", lambda request, timeout: FakeResponse())
|
||||||
|
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
assert res.json()["url"].startswith("/media/")
|
||||||
|
assert "Elahieh-pic" in res.json()["key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_article_persists_image_meta_and_faqs(auth_client, client):
|
||||||
|
created = auth_client.post(
|
||||||
|
"/api/admin/articles",
|
||||||
|
json={"slug": "meta-article", "title": "متادیتا", "body": DOC},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
article_id = created.json()["id"]
|
||||||
|
|
||||||
|
faqs = [{"question": "هزینه چقدر است؟", "answer": "بستگی به پروژه دارد."}]
|
||||||
|
res = auth_client.patch(
|
||||||
|
f"/api/admin/articles/{article_id}",
|
||||||
|
json={
|
||||||
|
"image_alt": "نمای برج الهیه",
|
||||||
|
"image_title": "برج آرامش",
|
||||||
|
"faqs": faqs,
|
||||||
|
"faqs_en": [{"question": "What is the cost?", "answer": "Depends on the project."}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
assert res.json()["image_alt"] == "نمای برج الهیه"
|
||||||
|
assert res.json()["image_title"] == "برج آرامش"
|
||||||
|
assert res.json()["faqs"] == faqs
|
||||||
|
|
||||||
|
# انتشار و سپس خروجی عمومی — همین مقادیر برمیگردد
|
||||||
|
assert auth_client.patch(f"/api/admin/articles/{article_id}", json={"status": "published"}).status_code == 200
|
||||||
|
public = client.get("/api/articles/meta-article").json()
|
||||||
|
assert public["image_alt"] == "نمای برج الهیه"
|
||||||
|
assert public["faqs"] == faqs
|
||||||
|
|
||||||
|
assert auth_client.delete(f"/api/admin/articles/{article_id}").status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_blog_shell_includes_faqpage_jsonld(auth_client, client):
|
||||||
|
created = auth_client.post(
|
||||||
|
"/api/admin/articles",
|
||||||
|
json={
|
||||||
|
"slug": "faq-article",
|
||||||
|
"title": "مقاله پرسشوپاسخ",
|
||||||
|
"body": DOC,
|
||||||
|
"status": "published",
|
||||||
|
"faqs": [{"question": "زمان ساخت؟", "answer": "۳۰ ماه."}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
|
||||||
|
res = client.get("/blog/faq-article")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "FAQPage" in res.text
|
||||||
|
assert "زمان ساخت؟" in res.text
|
||||||
|
|
||||||
|
# نسخه انگلیسی بدون ترجمه → همان فارسی؛ با ترجمه → انگلیسی
|
||||||
|
en = client.get("/en/blog/faq-article").text
|
||||||
|
assert "FAQPage" in en
|
||||||
|
article_id = created.json()["id"]
|
||||||
|
auth_client.patch(
|
||||||
|
f"/api/admin/articles/{article_id}",
|
||||||
|
json={"faqs_en": [{"question": "How long?", "answer": "Thirty months."}]},
|
||||||
|
)
|
||||||
|
en = client.get("/en/blog/faq-article").text
|
||||||
|
assert "How long?" in en
|
||||||
|
assert "زمان ساخت؟" not in en
|
||||||
|
|
||||||
|
auth_client.delete(f"/api/admin/articles/{article_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_blog_shell_without_faqs_has_no_faqpage(client):
|
||||||
|
res = client.get("/blog/published-post")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "FAQPage" not in res.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- پیشنمایش خصوصی (برای انتشار عمومی خاموش میشود) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_mode_blocks_robots_and_sitemap(client, monkeypatch):
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "preview_mode", True)
|
||||||
|
try:
|
||||||
|
robots = client.get("/robots.txt")
|
||||||
|
assert robots.status_code == 200
|
||||||
|
assert "Disallow: /" in robots.text
|
||||||
|
assert "Sitemap:" not in robots.text
|
||||||
|
|
||||||
|
sitemap = client.get("/sitemap.xml")
|
||||||
|
assert sitemap.status_code == 200
|
||||||
|
assert "published-post" not in sitemap.text
|
||||||
|
|
||||||
|
# لایه دوم: هدر noindex روی همه پاسخها
|
||||||
|
assert "noindex" in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||||
|
finally:
|
||||||
|
monkeypatch.setattr(settings, "preview_mode", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_mode_allows_indexing(client):
|
||||||
|
robots = client.get("/robots.txt").text
|
||||||
|
assert "Disallow: /admin" in robots
|
||||||
|
assert "Sitemap:" in robots
|
||||||
|
assert "noindex" not in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
#
|
#
|
||||||
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
|
||||||
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
|
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
|
||||||
|
#
|
||||||
|
# ---- پیشنمایش خصوصی و انتشار (scripts/preview-mode.sh) ----
|
||||||
|
# در حالت پیشنمایش (PREVIEW_MODE=on) کل سایت پشت Basic Auth است و noindex میگیرد:
|
||||||
|
# تیم با یک نام کاربری/رمز مشترک وارد میشود، محتوا را ویرایش میکند و هیچکس دیگری —
|
||||||
|
# از جمله گوگل — به سایت دسترسی ندارد. برای انتشار عمومی:
|
||||||
|
# bash scripts/preview-mode.sh off → deploy مجدد → ثبت sitemap در Search Console.
|
||||||
|
#
|
||||||
|
# DOMAIN دامنه اصلی است و DOMAIN_ALIAS دامنه دوم (هر دو مسیر و گواهی میگیرند).
|
||||||
|
# در زمان انتشار، برای جلوگیری از محتوای تکراری، دامنه دوم را به اصلی ریدایرکت کنید:
|
||||||
|
# ALIAS_REDIRECT=on (در .env) — ریدایرکت ۳۰۱ دائمی به دامنه اصلی.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
@@ -43,7 +53,7 @@ services:
|
|||||||
- traefik.http.routers.media.entrypoints=websecure
|
- traefik.http.routers.media.entrypoints=websecure
|
||||||
- traefik.http.routers.media.tls.certresolver=le
|
- traefik.http.routers.media.tls.certresolver=le
|
||||||
- traefik.http.routers.media.priority=100
|
- traefik.http.routers.media.priority=100
|
||||||
- traefik.http.routers.media.middlewares=media-rewrite,sec-headers
|
- traefik.http.routers.media.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}media-rewrite,sec-headers
|
||||||
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
|
||||||
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
|
||||||
- traefik.http.services.media.loadbalancer.server.port=9000
|
- traefik.http.services.media.loadbalancer.server.port=9000
|
||||||
@@ -53,16 +63,18 @@ services:
|
|||||||
# سایت هممبدأ است — CORS لازم نیست
|
# سایت هممبدأ است — CORS لازم نیست
|
||||||
CORS_ORIGINS: '[]'
|
CORS_ORIGINS: '[]'
|
||||||
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
|
||||||
|
# پیشنمایش خصوصی — هدر noindex + robots مسدودکننده (لایه دوم؛ لایه اول گیت Traefik)
|
||||||
|
PREVIEW_MODE: ${PREVIEW_MODE:-off}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||||
- traefik.http.routers.api.entrypoints=websecure
|
- traefik.http.routers.api.entrypoints=websecure
|
||||||
- traefik.http.routers.api.tls.certresolver=le
|
- traefik.http.routers.api.tls.certresolver=le
|
||||||
- traefik.http.routers.api.middlewares=api-flood,sec-headers
|
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
|
||||||
- traefik.http.routers.seo.entrypoints=websecure
|
- traefik.http.routers.seo.entrypoints=websecure
|
||||||
- traefik.http.routers.seo.tls.certresolver=le
|
- traefik.http.routers.seo.tls.certresolver=le
|
||||||
- traefik.http.routers.seo.middlewares=sec-headers
|
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
labels:
|
labels:
|
||||||
@@ -70,12 +82,28 @@ services:
|
|||||||
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
|
||||||
- traefik.http.routers.web.entrypoints=websecure
|
- traefik.http.routers.web.entrypoints=websecure
|
||||||
- traefik.http.routers.web.tls.certresolver=le
|
- traefik.http.routers.web.tls.certresolver=le
|
||||||
- traefik.http.routers.web.middlewares=sec-headers
|
- traefik.http.routers.web.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||||
# تعریف یکبارهٔ میانافزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده میشود
|
# تعریف یکبارهٔ میانافزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده میشود
|
||||||
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
|
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
|
||||||
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
|
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
|
||||||
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
|
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
|
||||||
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
|
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
|
||||||
|
# ---- گیت پیشنمایش خصوصی ----
|
||||||
|
# ورود با نام کاربری/رمز مشترک (htpasswd) — تا وقتی PREVIEW_MIDDLEWARES تنظیم است فعال میشود
|
||||||
|
- traefik.http.middlewares.preview-auth.basicauth.users=${PREVIEW_AUTH_USERS:-disabled:nope}
|
||||||
|
- traefik.http.middlewares.preview-auth.basicauth.realm=Aramesh private preview
|
||||||
|
# لایه دوم جلوگیری از ایندکس — حتی اگر گیت حذف شد، گوگل چیزی ذخیره نکند
|
||||||
|
- traefik.http.middlewares.preview-noindex.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow, noarchive
|
||||||
|
# ---- ریدایرکت دامنه دوم به اصلی (زمان انتشار: ALIAS_REDIRECT=on) ----
|
||||||
|
- traefik.http.middlewares.to-primary.redirectregex.regex=^https?://[^/]+(.*)
|
||||||
|
- traefik.http.middlewares.to-primary.redirectregex.replacement=https://${DOMAIN}$$1
|
||||||
|
- traefik.http.middlewares.to-primary.redirectregex.permanent=true
|
||||||
|
- traefik.http.routers.alias-redirect.rule=Host(`${DOMAIN_ALIAS:-invalid.invalid}`)
|
||||||
|
- traefik.http.routers.alias-redirect.entrypoints=websecure
|
||||||
|
- traefik.http.routers.alias-redirect.tls.certresolver=le
|
||||||
|
- traefik.http.routers.alias-redirect.priority=1000
|
||||||
|
- traefik.http.routers.alias-redirect.middlewares=${ALIAS_REDIRECT_MIDDLEWARES:-sec-headers}
|
||||||
|
- traefik.http.routers.alias-redirect.service=web
|
||||||
|
|
||||||
chatbot:
|
chatbot:
|
||||||
labels:
|
labels:
|
||||||
@@ -84,7 +112,7 @@ services:
|
|||||||
- traefik.http.routers.chat.entrypoints=websecure
|
- traefik.http.routers.chat.entrypoints=websecure
|
||||||
- traefik.http.routers.chat.tls.certresolver=le
|
- traefik.http.routers.chat.tls.certresolver=le
|
||||||
- traefik.http.routers.chat.priority=100
|
- traefik.http.routers.chat.priority=100
|
||||||
- traefik.http.routers.chat.middlewares=api-flood,sec-headers
|
- traefik.http.routers.chat.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||||
|
|
||||||
umami:
|
umami:
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ The **Pinar** font is installed and served locally. The application no longer re
|
|||||||
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
|
**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
|
## 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**:
|
* **Tiptap Editor Fixes**:
|
||||||
* Resolved the initialization bug where the editor body remained empty during edit mode.
|
* 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.
|
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
|
||||||
@@ -60,3 +66,11 @@ npm run build
|
|||||||
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
|
||||||
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
|
||||||
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، میتوانید از این کامیت یکخطی استفاده کنید:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,12 @@
|
|||||||
"@tiptap/static-renderer": "^3.31.3",
|
"@tiptap/static-renderer": "^3.31.3",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
|
"maplibre-gl": "^6.10.0",
|
||||||
|
"pmtiles": "^4.5.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.88.0",
|
"react-hook-form": "^7.88.0",
|
||||||
|
"react-icons": "^5.7.0",
|
||||||
"react-multi-date-picker": "^4.5.2",
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
@@ -1041,6 +1044,92 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/point-geometry": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/vector-tile": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "26.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||||
|
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||||
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/mlt": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
@@ -1973,6 +2062,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -2450,6 +2545,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -2783,6 +2887,12 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.407",
|
"version": "1.5.407",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||||
@@ -3157,6 +3267,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -3310,6 +3426,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "7.2.3",
|
"version": "7.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||||
@@ -3637,6 +3759,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -3650,6 +3778,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -3753,6 +3887,39 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/maplibre-gl": {
|
||||||
|
"version": "6.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
|
||||||
|
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.2.0",
|
||||||
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
|
"@mapbox/vector-tile": "^3.0.0",
|
||||||
|
"@maplibre/geojson-vt": "^6.1.1",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||||
|
"@maplibre/mlt": "^1.3.0",
|
||||||
|
"@maplibre/vt-pbf": "^4.3.2",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"bidi-js": "^1.1.0",
|
||||||
|
"earcut": "^3.2.3",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
|
"kdbush": "^4.1.0",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^5.1.2",
|
||||||
|
"potpack": "^2.1.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14.0",
|
||||||
|
"npm": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/memoize-one": {
|
"node_modules/memoize-one": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
@@ -3796,6 +3963,15 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "11.18.1",
|
"version": "11.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||||
@@ -3817,6 +3993,12 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/murmurhash-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -4042,6 +4224,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -4081,6 +4275,15 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pmtiles": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
@@ -4253,6 +4456,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/potpack": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -4413,6 +4622,12 @@
|
|||||||
"prosemirror-transform": "^1.1.0"
|
"prosemirror-transform": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -4444,6 +4659,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -4501,6 +4722,15 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||||
|
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -4623,6 +4853,15 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -4653,6 +4892,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -5046,6 +5294,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx vite",
|
"dev": "npx vite",
|
||||||
"build": "npx vite build",
|
"build": "npx vite build",
|
||||||
|
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
|
||||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||||
"preview": "npx vite preview"
|
"preview": "npx vite preview"
|
||||||
},
|
},
|
||||||
@@ -19,9 +20,12 @@
|
|||||||
"@tiptap/static-renderer": "^3.31.3",
|
"@tiptap/static-renderer": "^3.31.3",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"lucide-react": "0.522.0",
|
"lucide-react": "0.522.0",
|
||||||
|
"maplibre-gl": "^6.10.0",
|
||||||
|
"pmtiles": "^4.5.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.88.0",
|
"react-hook-form": "^7.88.0",
|
||||||
|
"react-icons": "^5.7.0",
|
||||||
"react-multi-date-picker": "^4.5.2",
|
"react-multi-date-picker": "^4.5.2",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
|
|||||||
@@ -0,0 +1,836 @@
|
|||||||
|
// node_modules/fflate/esm/browser.js
|
||||||
|
var u8 = Uint8Array;
|
||||||
|
var u16 = Uint16Array;
|
||||||
|
var i32 = Int32Array;
|
||||||
|
var fleb = new u8([
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
/* unused */
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
/* impossible */
|
||||||
|
0
|
||||||
|
]);
|
||||||
|
var fdeb = new u8([
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
13,
|
||||||
|
/* unused */
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]);
|
||||||
|
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||||
|
var freb = function(eb, start) {
|
||||||
|
var b2 = new u16(31);
|
||||||
|
for (var i = 0; i < 31; ++i) {
|
||||||
|
b2[i] = start += 1 << eb[i - 1];
|
||||||
|
}
|
||||||
|
var r = new i32(b2[30]);
|
||||||
|
for (var i = 1; i < 30; ++i) {
|
||||||
|
for (var j2 = b2[i]; j2 < b2[i + 1]; ++j2) {
|
||||||
|
r[j2] = j2 - b2[i] << 5 | i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { b: b2, r };
|
||||||
|
};
|
||||||
|
var _a = freb(fleb, 2);
|
||||||
|
var fl = _a.b;
|
||||||
|
var revfl = _a.r;
|
||||||
|
fl[28] = 258, revfl[258] = 28;
|
||||||
|
var _b = freb(fdeb, 0);
|
||||||
|
var fd = _b.b;
|
||||||
|
var revfd = _b.r;
|
||||||
|
var rev = new u16(32768);
|
||||||
|
for (i = 0; i < 32768; ++i) {
|
||||||
|
x2 = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||||
|
x2 = (x2 & 52428) >> 2 | (x2 & 13107) << 2;
|
||||||
|
x2 = (x2 & 61680) >> 4 | (x2 & 3855) << 4;
|
||||||
|
rev[i] = ((x2 & 65280) >> 8 | (x2 & 255) << 8) >> 1;
|
||||||
|
}
|
||||||
|
var x2;
|
||||||
|
var i;
|
||||||
|
var hMap = function(cd, mb, r) {
|
||||||
|
var s = cd.length;
|
||||||
|
var i = 0;
|
||||||
|
var l = new u16(mb);
|
||||||
|
for (; i < s; ++i) {
|
||||||
|
if (cd[i])
|
||||||
|
++l[cd[i] - 1];
|
||||||
|
}
|
||||||
|
var le = new u16(mb);
|
||||||
|
for (i = 1; i < mb; ++i) {
|
||||||
|
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||||
|
}
|
||||||
|
var co;
|
||||||
|
if (r) {
|
||||||
|
co = new u16(1 << mb);
|
||||||
|
var rvb = 15 - mb;
|
||||||
|
for (i = 0; i < s; ++i) {
|
||||||
|
if (cd[i]) {
|
||||||
|
var sv = i << 4 | cd[i];
|
||||||
|
var r_1 = mb - cd[i];
|
||||||
|
var v2 = le[cd[i] - 1]++ << r_1;
|
||||||
|
for (var m = v2 | (1 << r_1) - 1; v2 <= m; ++v2) {
|
||||||
|
co[rev[v2] >> rvb] = sv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
co = new u16(s);
|
||||||
|
for (i = 0; i < s; ++i) {
|
||||||
|
if (cd[i]) {
|
||||||
|
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return co;
|
||||||
|
};
|
||||||
|
var flt = new u8(288);
|
||||||
|
for (i = 0; i < 144; ++i)
|
||||||
|
flt[i] = 8;
|
||||||
|
var i;
|
||||||
|
for (i = 144; i < 256; ++i)
|
||||||
|
flt[i] = 9;
|
||||||
|
var i;
|
||||||
|
for (i = 256; i < 280; ++i)
|
||||||
|
flt[i] = 7;
|
||||||
|
var i;
|
||||||
|
for (i = 280; i < 288; ++i)
|
||||||
|
flt[i] = 8;
|
||||||
|
var i;
|
||||||
|
var fdt = new u8(32);
|
||||||
|
for (i = 0; i < 32; ++i)
|
||||||
|
fdt[i] = 5;
|
||||||
|
var i;
|
||||||
|
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||||
|
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||||
|
var max = function(a) {
|
||||||
|
var m = a[0];
|
||||||
|
for (var i = 1; i < a.length; ++i) {
|
||||||
|
if (a[i] > m)
|
||||||
|
m = a[i];
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
var bits = function(d2, p, m) {
|
||||||
|
var o = p / 8 | 0;
|
||||||
|
return (d2[o] | d2[o + 1] << 8) >> (p & 7) & m;
|
||||||
|
};
|
||||||
|
var bits16 = function(d2, p) {
|
||||||
|
var o = p / 8 | 0;
|
||||||
|
return (d2[o] | d2[o + 1] << 8 | d2[o + 2] << 16) >> (p & 7);
|
||||||
|
};
|
||||||
|
var shft = function(p) {
|
||||||
|
return (p + 7) / 8 | 0;
|
||||||
|
};
|
||||||
|
var slc = function(v2, s, e) {
|
||||||
|
if (s == null || s < 0)
|
||||||
|
s = 0;
|
||||||
|
if (e == null || e > v2.length)
|
||||||
|
e = v2.length;
|
||||||
|
return new u8(v2.subarray(s, e));
|
||||||
|
};
|
||||||
|
var ec = [
|
||||||
|
"unexpected EOF",
|
||||||
|
"invalid block type",
|
||||||
|
"invalid length/literal",
|
||||||
|
"invalid distance",
|
||||||
|
"stream finished",
|
||||||
|
"no stream handler",
|
||||||
|
,
|
||||||
|
// determined by compression function
|
||||||
|
"no callback",
|
||||||
|
"invalid UTF-8 data",
|
||||||
|
"extra field too long",
|
||||||
|
"date not in range 1980-2099",
|
||||||
|
"filename too long",
|
||||||
|
"stream finishing",
|
||||||
|
"invalid zip data"
|
||||||
|
// determined by unknown compression method
|
||||||
|
];
|
||||||
|
var err = function(ind, msg, nt) {
|
||||||
|
var e = new Error(msg || ec[ind]);
|
||||||
|
e.code = ind;
|
||||||
|
if (Error.captureStackTrace)
|
||||||
|
Error.captureStackTrace(e, err);
|
||||||
|
if (!nt)
|
||||||
|
throw e;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
var inflt = function(dat, st, buf, dict) {
|
||||||
|
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||||
|
if (!sl || st.f && !st.l)
|
||||||
|
return buf || new u8(0);
|
||||||
|
var noBuf = !buf;
|
||||||
|
var resize = noBuf || st.i != 2;
|
||||||
|
var noSt = st.i;
|
||||||
|
if (noBuf)
|
||||||
|
buf = new u8(sl * 3);
|
||||||
|
var cbuf = function(l2) {
|
||||||
|
var bl = buf.length;
|
||||||
|
if (l2 > bl) {
|
||||||
|
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||||
|
nbuf.set(buf);
|
||||||
|
buf = nbuf;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||||
|
var tbts = sl * 8;
|
||||||
|
do {
|
||||||
|
if (!lm) {
|
||||||
|
final = bits(dat, pos, 1);
|
||||||
|
var type = bits(dat, pos + 1, 3);
|
||||||
|
pos += 3;
|
||||||
|
if (!type) {
|
||||||
|
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||||
|
if (t > sl) {
|
||||||
|
if (noSt)
|
||||||
|
err(0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (resize)
|
||||||
|
cbuf(bt + l);
|
||||||
|
buf.set(dat.subarray(s, t), bt);
|
||||||
|
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||||
|
continue;
|
||||||
|
} else if (type == 1)
|
||||||
|
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||||
|
else if (type == 2) {
|
||||||
|
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||||
|
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||||
|
pos += 14;
|
||||||
|
var ldt = new u8(tl);
|
||||||
|
var clt = new u8(19);
|
||||||
|
for (var i = 0; i < hcLen; ++i) {
|
||||||
|
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||||
|
}
|
||||||
|
pos += hcLen * 3;
|
||||||
|
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||||
|
var clm = hMap(clt, clb, 1);
|
||||||
|
for (var i = 0; i < tl; ) {
|
||||||
|
var r = clm[bits(dat, pos, clbmsk)];
|
||||||
|
pos += r & 15;
|
||||||
|
var s = r >> 4;
|
||||||
|
if (s < 16) {
|
||||||
|
ldt[i++] = s;
|
||||||
|
} else {
|
||||||
|
var c = 0, n = 0;
|
||||||
|
if (s == 16)
|
||||||
|
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||||
|
else if (s == 17)
|
||||||
|
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||||
|
else if (s == 18)
|
||||||
|
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||||
|
while (n--)
|
||||||
|
ldt[i++] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||||
|
lbt = max(lt);
|
||||||
|
dbt = max(dt);
|
||||||
|
lm = hMap(lt, lbt, 1);
|
||||||
|
dm = hMap(dt, dbt, 1);
|
||||||
|
} else
|
||||||
|
err(1);
|
||||||
|
if (pos > tbts) {
|
||||||
|
if (noSt)
|
||||||
|
err(0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resize)
|
||||||
|
cbuf(bt + 131072);
|
||||||
|
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||||
|
var lpos = pos;
|
||||||
|
for (; ; lpos = pos) {
|
||||||
|
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||||
|
pos += c & 15;
|
||||||
|
if (pos > tbts) {
|
||||||
|
if (noSt)
|
||||||
|
err(0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!c)
|
||||||
|
err(2);
|
||||||
|
if (sym < 256)
|
||||||
|
buf[bt++] = sym;
|
||||||
|
else if (sym == 256) {
|
||||||
|
lpos = pos, lm = null;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
var add = sym - 254;
|
||||||
|
if (sym > 264) {
|
||||||
|
var i = sym - 257, b2 = fleb[i];
|
||||||
|
add = bits(dat, pos, (1 << b2) - 1) + fl[i];
|
||||||
|
pos += b2;
|
||||||
|
}
|
||||||
|
var d2 = dm[bits16(dat, pos) & dms], dsym = d2 >> 4;
|
||||||
|
if (!d2)
|
||||||
|
err(3);
|
||||||
|
pos += d2 & 15;
|
||||||
|
var dt = fd[dsym];
|
||||||
|
if (dsym > 3) {
|
||||||
|
var b2 = fdeb[dsym];
|
||||||
|
dt += bits16(dat, pos) & (1 << b2) - 1, pos += b2;
|
||||||
|
}
|
||||||
|
if (pos > tbts) {
|
||||||
|
if (noSt)
|
||||||
|
err(0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (resize)
|
||||||
|
cbuf(bt + 131072);
|
||||||
|
var end = bt + add;
|
||||||
|
if (bt < dt) {
|
||||||
|
var shift = dl - dt, dend = Math.min(dt, end);
|
||||||
|
if (shift + bt < 0)
|
||||||
|
err(3);
|
||||||
|
for (; bt < dend; ++bt)
|
||||||
|
buf[bt] = dict[shift + bt];
|
||||||
|
}
|
||||||
|
for (; bt < end; ++bt)
|
||||||
|
buf[bt] = buf[bt - dt];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||||
|
if (lm)
|
||||||
|
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||||
|
} while (!final);
|
||||||
|
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||||
|
};
|
||||||
|
var et = /* @__PURE__ */ new u8(0);
|
||||||
|
var gzs = function(d2) {
|
||||||
|
if (d2[0] != 31 || d2[1] != 139 || d2[2] != 8)
|
||||||
|
err(6, "invalid gzip data");
|
||||||
|
var flg = d2[3];
|
||||||
|
var st = 10;
|
||||||
|
if (flg & 4)
|
||||||
|
st += (d2[10] | d2[11] << 8) + 2;
|
||||||
|
for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d2[st++])
|
||||||
|
;
|
||||||
|
return st + (flg & 2);
|
||||||
|
};
|
||||||
|
var gzl = function(d2) {
|
||||||
|
var l = d2.length;
|
||||||
|
return (d2[l - 4] | d2[l - 3] << 8 | d2[l - 2] << 16 | d2[l - 1] << 24) >>> 0;
|
||||||
|
};
|
||||||
|
var zls = function(d2, dict) {
|
||||||
|
if ((d2[0] & 15) != 8 || d2[0] >> 4 > 7 || (d2[0] << 8 | d2[1]) % 31)
|
||||||
|
err(6, "invalid zlib data");
|
||||||
|
if ((d2[1] >> 5 & 1) == +!dict)
|
||||||
|
err(6, "invalid zlib data: " + (d2[1] & 32 ? "need" : "unexpected") + " dictionary");
|
||||||
|
return (d2[1] >> 3 & 4) + 2;
|
||||||
|
};
|
||||||
|
function inflateSync(data, opts) {
|
||||||
|
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||||
|
}
|
||||||
|
function gunzipSync(data, opts) {
|
||||||
|
var st = gzs(data);
|
||||||
|
if (st + 8 > data.length)
|
||||||
|
err(6, "invalid gzip data");
|
||||||
|
return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
|
||||||
|
}
|
||||||
|
function unzlibSync(data, opts) {
|
||||||
|
return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||||
|
}
|
||||||
|
function decompressSync(data, opts) {
|
||||||
|
return data[0] == 31 && data[1] == 139 && data[2] == 8 ? gunzipSync(data, opts) : (data[0] & 15) != 8 || data[0] >> 4 > 7 || (data[0] << 8 | data[1]) % 31 ? inflateSync(data, opts) : unzlibSync(data, opts);
|
||||||
|
}
|
||||||
|
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||||
|
var tds = 0;
|
||||||
|
try {
|
||||||
|
td.decode(et, { stream: true });
|
||||||
|
tds = 1;
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// node_modules/pmtiles/dist/esm/index.js
|
||||||
|
var j = Object.defineProperty;
|
||||||
|
var S = Math.pow;
|
||||||
|
var d = (o, t) => j(o, "name", { value: t, configurable: true });
|
||||||
|
var h = (o, t, e) => new Promise((r, n) => {
|
||||||
|
var s = (c) => {
|
||||||
|
try {
|
||||||
|
a(e.next(c));
|
||||||
|
} catch (l) {
|
||||||
|
n(l);
|
||||||
|
}
|
||||||
|
}, i = (c) => {
|
||||||
|
try {
|
||||||
|
a(e.throw(c));
|
||||||
|
} catch (l) {
|
||||||
|
n(l);
|
||||||
|
}
|
||||||
|
}, a = (c) => c.done ? r(c.value) : Promise.resolve(c.value).then(s, i);
|
||||||
|
a((e = e.apply(o, t)).next());
|
||||||
|
});
|
||||||
|
var re = d((o, t) => {
|
||||||
|
let e = false, r = "", n = L.GridLayer.extend({ createTile: d((s, i) => {
|
||||||
|
let a = document.createElement("img"), c = new AbortController(), l = c.signal;
|
||||||
|
return a.cancel = () => {
|
||||||
|
c.abort();
|
||||||
|
}, e || (o.getHeader().then((u) => {
|
||||||
|
u.tileType === 1 || u.tileType === 6 ? console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details.") : u.tileType === 2 ? r = "image/png" : u.tileType === 3 ? r = "image/jpeg" : u.tileType === 4 ? r = "image/webp" : u.tileType === 5 && (r = "image/avif");
|
||||||
|
}), e = true), o.getZxy(s.z, s.x, s.y, l).then((u) => {
|
||||||
|
if (u) {
|
||||||
|
let m = new Blob([u.data], { type: r }), g = window.URL.createObjectURL(m);
|
||||||
|
a.src = g;
|
||||||
|
} else a.style.display = "none";
|
||||||
|
a.cancel = void 0, i(void 0, a);
|
||||||
|
}).catch((u) => {
|
||||||
|
if (u.name !== "AbortError") throw u;
|
||||||
|
}), a;
|
||||||
|
}, "createTile"), _removeTile: d(function(s) {
|
||||||
|
let i = this._tiles[s];
|
||||||
|
i && (i.el.cancel && i.el.cancel(), i.el.src && window.URL.revokeObjectURL(i.el.src), i.el.width = 0, i.el.height = 0, i.el.deleted = true, L.DomUtil.remove(i.el), delete this._tiles[s], this.fire("tileunload", { tile: i.el, coords: this._keyToTileCoords(s) }));
|
||||||
|
}, "_removeTile") });
|
||||||
|
return new n(t);
|
||||||
|
}, "leafletRasterLayer");
|
||||||
|
var z = d((o) => (t, e) => {
|
||||||
|
if (e instanceof AbortController) return o(t, e);
|
||||||
|
let r = new AbortController();
|
||||||
|
return o(t, r).then((n) => e(void 0, n.data, n.cacheControl || "", n.expires || ""), (n) => e(n)).catch((n) => e(n)), { cancel: d(() => r.abort(), "cancel") };
|
||||||
|
}, "v3compat");
|
||||||
|
var A = class A2 {
|
||||||
|
constructor(t) {
|
||||||
|
this.tilev4 = d((t2, e) => h(this, null, function* () {
|
||||||
|
if (t2.type === "json") {
|
||||||
|
let g = t2.url.substr(10), p = this.tiles.get(g);
|
||||||
|
if (p || (p = new w(g), this.tiles.set(g, p)), this.metadata) {
|
||||||
|
let K = yield p.getTileJson(t2.url);
|
||||||
|
return e.signal.throwIfAborted(), { data: K };
|
||||||
|
}
|
||||||
|
let f = yield p.getHeader();
|
||||||
|
return e.signal.throwIfAborted(), (f.minLon >= f.maxLon || f.minLat >= f.maxLat) && console.error(`Bounds of PMTiles archive ${f.minLon},${f.minLat},${f.maxLon},${f.maxLat} are not valid.`), { data: { tiles: [`${t2.url}/{z}/{x}/{y}`], minzoom: f.minZoom, maxzoom: f.maxZoom, bounds: [f.minLon, f.minLat, f.maxLon, f.maxLat] } };
|
||||||
|
}
|
||||||
|
let r = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/), n = t2.url.match(r);
|
||||||
|
if (!n) throw new Error("Invalid PMTiles protocol URL");
|
||||||
|
let s = n[1], i = this.tiles.get(s);
|
||||||
|
i || (i = new w(s), this.tiles.set(s, i));
|
||||||
|
let a = n[2], c = n[3], l = n[4], u = yield i == null ? void 0 : i.getZxy(+a, +c, +l, e.signal);
|
||||||
|
if (e.signal.throwIfAborted(), u) return { data: new Uint8Array(u.data), cacheControl: u.cacheControl, expires: u.expires };
|
||||||
|
let m = yield i.getHeader();
|
||||||
|
if (m.tileType === 1 || m.tileType === 6) {
|
||||||
|
if (this.errorOnMissingTile) throw new Error("Tile not found.");
|
||||||
|
return { data: new Uint8Array() };
|
||||||
|
}
|
||||||
|
return { data: null };
|
||||||
|
}), "tilev4");
|
||||||
|
this.tile = z(this.tilev4);
|
||||||
|
this.tiles = /* @__PURE__ */ new Map(), this.metadata = (t == null ? void 0 : t.metadata) || false, this.errorOnMissingTile = (t == null ? void 0 : t.errorOnMissingTile) || false;
|
||||||
|
}
|
||||||
|
add(t) {
|
||||||
|
this.tiles.set(t.source.getKey(), t);
|
||||||
|
}
|
||||||
|
get(t) {
|
||||||
|
return this.tiles.get(t);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d(A, "Protocol");
|
||||||
|
var B = A;
|
||||||
|
function b(o, t) {
|
||||||
|
return (t >>> 0) * 4294967296 + (o >>> 0);
|
||||||
|
}
|
||||||
|
d(b, "toNum");
|
||||||
|
function N(o, t) {
|
||||||
|
let e = t.buf, r = e[t.pos++], n = (r & 112) >> 4;
|
||||||
|
if (r < 128 || (r = e[t.pos++], n |= (r & 127) << 3, r < 128) || (r = e[t.pos++], n |= (r & 127) << 10, r < 128) || (r = e[t.pos++], n |= (r & 127) << 17, r < 128) || (r = e[t.pos++], n |= (r & 127) << 24, r < 128) || (r = e[t.pos++], n |= (r & 1) << 31, r < 128)) return b(o, n);
|
||||||
|
throw new Error("Expected varint not more than 10 bytes");
|
||||||
|
}
|
||||||
|
d(N, "readVarintRemainder");
|
||||||
|
function x(o) {
|
||||||
|
let t = o.buf, e = t[o.pos++], r = e & 127;
|
||||||
|
return e < 128 || (e = t[o.pos++], r |= (e & 127) << 7, e < 128) || (e = t[o.pos++], r |= (e & 127) << 14, e < 128) || (e = t[o.pos++], r |= (e & 127) << 21, e < 128) ? r : (e = t[o.pos], r |= (e & 15) << 28, N(r, o));
|
||||||
|
}
|
||||||
|
d(x, "readVarint");
|
||||||
|
function I(o, t, e, r, n) {
|
||||||
|
return n === 0 ? r !== 0 ? [o - 1 - e, o - 1 - t] : [e, t] : [t, e];
|
||||||
|
}
|
||||||
|
d(I, "rotate");
|
||||||
|
function q(o, t, e) {
|
||||||
|
if (o > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||||
|
if (t >= 1 << o || e >= 1 << o) throw new Error("tile x/y outside zoom level bounds");
|
||||||
|
let r = ((1 << o) * (1 << o) - 1) / 3, n = o - 1, [s, i] = [t, e];
|
||||||
|
for (let a = 1 << n; a > 0; a >>= 1) {
|
||||||
|
let c = s & a, l = i & a;
|
||||||
|
r += (3 * c ^ l) * (1 << n), [s, i] = I(a, s, i, c, l), n--;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
d(q, "zxyToTileId");
|
||||||
|
function G(o) {
|
||||||
|
let t = 3 * o + 1;
|
||||||
|
return t < 4294967296 ? 31 - Math.clz32(t) : 63 - Math.clz32(t / 4294967296);
|
||||||
|
}
|
||||||
|
d(G, "tileIdToZ");
|
||||||
|
function ie(o) {
|
||||||
|
let t = G(o) >> 1;
|
||||||
|
if (t > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||||
|
let e = ((1 << t) * (1 << t) - 1) / 3, r = o - e, n = 0, s = 0, i = 1 << t;
|
||||||
|
for (let a = 1; a < i; a <<= 1) {
|
||||||
|
let c = a & r / 2, l = a & (r ^ c);
|
||||||
|
[n, s] = I(a, n, s, c, l), r = r / 2, n += c, s += l;
|
||||||
|
}
|
||||||
|
return [t, n, s];
|
||||||
|
}
|
||||||
|
d(ie, "tileIdToZxy");
|
||||||
|
var J = ((s) => (s[s.Unknown = 0] = "Unknown", s[s.None = 1] = "None", s[s.Gzip = 2] = "Gzip", s[s.Brotli = 3] = "Brotli", s[s.Zstd = 4] = "Zstd", s))(J || {});
|
||||||
|
function P(o, t) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
if (t === 1 || t === 0) return o;
|
||||||
|
if (t === 2) {
|
||||||
|
if (typeof globalThis.DecompressionStream == "undefined") return decompressSync(new Uint8Array(o));
|
||||||
|
let e = new Response(o).body;
|
||||||
|
if (!e) throw new Error("Failed to read response stream");
|
||||||
|
let r = e.pipeThrough(new globalThis.DecompressionStream("gzip"));
|
||||||
|
return new Response(r).arrayBuffer();
|
||||||
|
}
|
||||||
|
throw new Error("Compression method not supported");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
d(P, "defaultDecompress");
|
||||||
|
var O = ((a) => (a[a.Unknown = 0] = "Unknown", a[a.Mvt = 1] = "Mvt", a[a.Png = 2] = "Png", a[a.Jpeg = 3] = "Jpeg", a[a.Webp = 4] = "Webp", a[a.Avif = 5] = "Avif", a[a.Mlt = 6] = "Mlt", a))(O || {});
|
||||||
|
function _(o) {
|
||||||
|
return o === 1 ? ".mvt" : o === 2 ? ".png" : o === 3 ? ".jpg" : o === 4 ? ".webp" : o === 5 ? ".avif" : o === 6 ? ".mlt" : "";
|
||||||
|
}
|
||||||
|
d(_, "tileTypeExt");
|
||||||
|
var Y = 127;
|
||||||
|
function Q(o, t) {
|
||||||
|
let e = 0, r = o.length - 1;
|
||||||
|
for (; e <= r; ) {
|
||||||
|
let n = r + e >> 1, s = t - o[n].tileId;
|
||||||
|
if (s > 0) e = n + 1;
|
||||||
|
else if (s < 0) r = n - 1;
|
||||||
|
else return o[n];
|
||||||
|
}
|
||||||
|
return r >= 0 && (o[r].runLength === 0 || t - o[r].tileId < o[r].runLength) ? o[r] : null;
|
||||||
|
}
|
||||||
|
d(Q, "findTile");
|
||||||
|
var T = class T2 {
|
||||||
|
constructor(t) {
|
||||||
|
this.file = t;
|
||||||
|
}
|
||||||
|
getKey() {
|
||||||
|
return this.file.name;
|
||||||
|
}
|
||||||
|
getBytes(t, e) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
return { data: yield this.file.slice(t, t + e).arrayBuffer() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d(T, "FileSource");
|
||||||
|
var D = class D2 {
|
||||||
|
constructor(t, e = new Headers(), r = void 0) {
|
||||||
|
var a, c;
|
||||||
|
this.url = t, this.customHeaders = e, this.credentials = r, this.mustReload = false;
|
||||||
|
let n = "";
|
||||||
|
"navigator" in globalThis && (n = (c = (a = globalThis.navigator) == null ? void 0 : a.userAgent) != null ? c : "");
|
||||||
|
let s = n.indexOf("Windows") > -1, i = /Chrome|Chromium|Edg|OPR|Brave/.test(n);
|
||||||
|
this.chromeWindowsNoCache = false, s && i && (this.chromeWindowsNoCache = true);
|
||||||
|
}
|
||||||
|
getKey() {
|
||||||
|
return this.url;
|
||||||
|
}
|
||||||
|
setHeaders(t) {
|
||||||
|
this.customHeaders = t;
|
||||||
|
}
|
||||||
|
getBytes(t, e, r, n) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let s, i;
|
||||||
|
r ? i = r : (s = new AbortController(), i = s.signal);
|
||||||
|
let a = new Headers(this.customHeaders);
|
||||||
|
a.set("range", `bytes=${t}-${t + e - 1}`);
|
||||||
|
let c;
|
||||||
|
this.mustReload ? c = "reload" : this.chromeWindowsNoCache && (c = "no-store");
|
||||||
|
let l = yield fetch(this.url, { signal: i, cache: c, headers: a, credentials: this.credentials });
|
||||||
|
if (t === 0 && l.status === 416) {
|
||||||
|
let p = l.headers.get("Content-Range");
|
||||||
|
if (!p || !p.startsWith("bytes */")) throw new Error("Missing content-length on 416 response");
|
||||||
|
let f = +p.substr(8);
|
||||||
|
a.set("range", `bytes=0-${f - 1}`), l = yield fetch(this.url, { signal: i, cache: "reload", headers: a, credentials: this.credentials });
|
||||||
|
}
|
||||||
|
let u = l.headers.get("Etag");
|
||||||
|
if (u != null && u.startsWith("W/") && (u = null), l.status === 416 || n && u && u !== n) throw this.mustReload = true, new v(`Server returned non-matching ETag ${n} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);
|
||||||
|
if (l.status >= 300) throw new Error(`Bad response code: ${l.status}`);
|
||||||
|
let m = l.headers.get("Content-Length");
|
||||||
|
if (l.status === 200 && (!m || +m > e)) throw s && s.abort(), new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");
|
||||||
|
return { data: yield l.arrayBuffer(), etag: u || void 0, cacheControl: l.headers.get("Cache-Control") || void 0, expires: l.headers.get("Expires") || void 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d(D, "FetchSource");
|
||||||
|
var E = D;
|
||||||
|
function y(o, t) {
|
||||||
|
let e = o.getUint32(t + 4, true), r = o.getUint32(t + 0, true);
|
||||||
|
return e * S(2, 32) + r;
|
||||||
|
}
|
||||||
|
d(y, "getUint64");
|
||||||
|
function X(o, t) {
|
||||||
|
let e = new DataView(o), r = e.getUint8(7);
|
||||||
|
if (r > 3) throw new Error(`Archive is spec version ${r} but this library supports up to spec version 3`);
|
||||||
|
return { specVersion: r, rootDirectoryOffset: y(e, 8), rootDirectoryLength: y(e, 16), jsonMetadataOffset: y(e, 24), jsonMetadataLength: y(e, 32), leafDirectoryOffset: y(e, 40), leafDirectoryLength: y(e, 48), tileDataOffset: y(e, 56), tileDataLength: y(e, 64), numAddressedTiles: y(e, 72), numTileEntries: y(e, 80), numTileContents: y(e, 88), clustered: e.getUint8(96) === 1, internalCompression: e.getUint8(97), tileCompression: e.getUint8(98), tileType: e.getUint8(99), minZoom: e.getUint8(100), maxZoom: e.getUint8(101), minLon: e.getInt32(102, true) / 1e7, minLat: e.getInt32(106, true) / 1e7, maxLon: e.getInt32(110, true) / 1e7, maxLat: e.getInt32(114, true) / 1e7, centerZoom: e.getUint8(118), centerLon: e.getInt32(119, true) / 1e7, centerLat: e.getInt32(123, true) / 1e7, etag: t };
|
||||||
|
}
|
||||||
|
d(X, "bytesToHeader");
|
||||||
|
function Z(o) {
|
||||||
|
let t = { buf: new Uint8Array(o), pos: 0 }, e = x(t), r = [], n = 0;
|
||||||
|
for (let s = 0; s < e; s++) {
|
||||||
|
let i = x(t);
|
||||||
|
r.push({ tileId: n + i, offset: 0, length: 0, runLength: 1 }), n += i;
|
||||||
|
}
|
||||||
|
for (let s = 0; s < e; s++) r[s].runLength = x(t);
|
||||||
|
for (let s = 0; s < e; s++) r[s].length = x(t);
|
||||||
|
for (let s = 0; s < e; s++) {
|
||||||
|
let i = x(t);
|
||||||
|
i === 0 && s > 0 ? r[s].offset = r[s - 1].offset + r[s - 1].length : r[s].offset = i - 1;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
d(Z, "deserializeIndex");
|
||||||
|
var R = class R2 extends Error {
|
||||||
|
};
|
||||||
|
d(R, "EtagMismatch");
|
||||||
|
var v = R;
|
||||||
|
function V(o, t) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let e = yield o.getBytes(0, 16384);
|
||||||
|
if (new DataView(e.data).getUint16(0, true) !== 19792) throw new Error("Wrong magic number for PMTiles archive");
|
||||||
|
let n = e.data.slice(0, Y), s = X(n, e.etag), i = e.data.slice(s.rootDirectoryOffset, s.rootDirectoryOffset + s.rootDirectoryLength), a = `${o.getKey()}|${s.etag || ""}|${s.rootDirectoryOffset}|${s.rootDirectoryLength}`, c = Z(yield t(i, s.internalCompression));
|
||||||
|
return [s, [a, c.length, c]];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
d(V, "getHeaderAndRoot");
|
||||||
|
function F(o, t, e, r, n, s) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let i = yield o.getBytes(e, r, s, n.etag), a = yield t(i.data, n.internalCompression), c = Z(a);
|
||||||
|
if (c.length === 0) throw new Error("Empty directory is invalid");
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
d(F, "getDirectory");
|
||||||
|
var U = class U2 {
|
||||||
|
constructor(t = 100, e = true, r = P) {
|
||||||
|
this.cache = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||||
|
}
|
||||||
|
getHeader(t) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let e = t.getKey(), r = this.cache.get(e);
|
||||||
|
if (r) return r.lastUsed = this.counter++, r.data;
|
||||||
|
let n = yield V(t, this.decompress);
|
||||||
|
return n[1] && this.cache.set(n[1][0], { lastUsed: this.counter++, data: n[1][2] }), this.cache.set(e, { lastUsed: this.counter++, data: n[0] }), this.prune(), n[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getDirectory(t, e, r, n, s) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||||
|
if (a) return a.lastUsed = this.counter++, a.data;
|
||||||
|
let c = yield F(t, this.decompress, e, r, n, s);
|
||||||
|
return this.cache.set(i, { lastUsed: this.counter++, data: c }), this.prune(), c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prune() {
|
||||||
|
if (this.cache.size > this.maxCacheEntries) {
|
||||||
|
let t = 1 / 0, e;
|
||||||
|
this.cache.forEach((r, n) => {
|
||||||
|
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||||
|
}), e && this.cache.delete(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalidate(t) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
this.cache.delete(t.getKey());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d(U, "ResolvedValueCache");
|
||||||
|
var M = class M2 {
|
||||||
|
constructor(t = 100, e = true, r = P) {
|
||||||
|
this.cache = /* @__PURE__ */ new Map(), this.invalidations = /* @__PURE__ */ new Map(), this.pendingFetches = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||||
|
}
|
||||||
|
getHeader(t) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let e = t.getKey(), r = this.cache.get(e);
|
||||||
|
if (r) return r.lastUsed = this.counter++, yield r.data;
|
||||||
|
let n = new Promise((s, i) => {
|
||||||
|
V(t, this.decompress).then((a) => {
|
||||||
|
a[1] && this.cache.set(a[1][0], { lastUsed: this.counter++, data: Promise.resolve(a[1][2]) }), s(a[0]), this.prune();
|
||||||
|
}).catch((a) => {
|
||||||
|
i(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return this.cache.set(e, { lastUsed: this.counter++, data: n }), n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
trackSignal(t, e, r) {
|
||||||
|
e.refs++, r.addEventListener("abort", () => {
|
||||||
|
--e.refs <= 0 && this.pendingFetches.get(t) === e && (e.controller.abort(), this.cache.delete(t), this.pendingFetches.delete(t));
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
getDirectory(t, e, r, n, s) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||||
|
if (a) {
|
||||||
|
a.lastUsed = this.counter++;
|
||||||
|
let m = this.pendingFetches.get(i);
|
||||||
|
return m && this.trackSignal(i, m, s != null ? s : new AbortController().signal), yield a.data;
|
||||||
|
}
|
||||||
|
let c = new AbortController(), l = { controller: c, refs: 0 };
|
||||||
|
this.trackSignal(i, l, s != null ? s : new AbortController().signal), this.pendingFetches.set(i, l);
|
||||||
|
let u = new Promise((m, g) => {
|
||||||
|
F(t, this.decompress, e, r, n, c.signal).then((p) => {
|
||||||
|
this.pendingFetches.delete(i), m(p), this.prune();
|
||||||
|
}).catch((p) => {
|
||||||
|
g(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return this.cache.set(i, { lastUsed: this.counter++, data: u }), u;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prune() {
|
||||||
|
if (this.cache.size >= this.maxCacheEntries) {
|
||||||
|
let t = 1 / 0, e;
|
||||||
|
this.cache.forEach((r, n) => {
|
||||||
|
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||||
|
}), e && this.cache.delete(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalidate(t) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let e = t.getKey();
|
||||||
|
if (this.invalidations.get(e)) return yield this.invalidations.get(e);
|
||||||
|
this.cache.delete(t.getKey());
|
||||||
|
let r = new Promise((n, s) => {
|
||||||
|
this.getHeader(t).then((i) => {
|
||||||
|
n(), this.invalidations.delete(e);
|
||||||
|
}).catch((i) => {
|
||||||
|
s(i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.invalidations.set(e, r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d(M, "SharedPromiseCache");
|
||||||
|
var C = M;
|
||||||
|
var H = class H2 {
|
||||||
|
constructor(t, e, r) {
|
||||||
|
typeof t == "string" ? this.source = new E(t) : this.source = t, r ? this.decompress = r : this.decompress = P, e ? this.cache = e : this.cache = new C();
|
||||||
|
}
|
||||||
|
getHeader() {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
return yield this.cache.getHeader(this.source);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getZxyAttempt(t, e, r, n) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let s = q(t, e, r), i = yield this.cache.getHeader(this.source);
|
||||||
|
if (n == null || n.throwIfAborted(), t < i.minZoom || t > i.maxZoom) return;
|
||||||
|
let a = i.rootDirectoryOffset, c = i.rootDirectoryLength;
|
||||||
|
for (let l = 0; l <= 3; l++) {
|
||||||
|
let u = yield this.cache.getDirectory(this.source, a, c, i, n);
|
||||||
|
n == null || n.throwIfAborted();
|
||||||
|
let m = Q(u, s);
|
||||||
|
if (m) {
|
||||||
|
if (m.runLength > 0) {
|
||||||
|
let g = yield this.source.getBytes(i.tileDataOffset + m.offset, m.length, n, i.etag);
|
||||||
|
return { data: yield this.decompress(g.data, i.tileCompression), cacheControl: g.cacheControl, expires: g.expires };
|
||||||
|
}
|
||||||
|
a = i.leafDirectoryOffset + m.offset, c = m.length;
|
||||||
|
} else return;
|
||||||
|
}
|
||||||
|
throw new Error("Maximum directory depth exceeded");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getZxy(t, e, r, n) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
try {
|
||||||
|
return yield this.getZxyAttempt(t, e, r, n);
|
||||||
|
} catch (s) {
|
||||||
|
if (s instanceof v) return this.cache.invalidate(this.source), yield this.getZxyAttempt(t, e, r, n);
|
||||||
|
throw s;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getMetadataAttempt() {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let t = yield this.cache.getHeader(this.source), e = yield this.source.getBytes(t.jsonMetadataOffset, t.jsonMetadataLength, void 0, t.etag), r = yield this.decompress(e.data, t.internalCompression), n = new TextDecoder("utf-8");
|
||||||
|
return JSON.parse(n.decode(r));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getMetadata() {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
try {
|
||||||
|
return yield this.getMetadataAttempt();
|
||||||
|
} catch (t) {
|
||||||
|
if (t instanceof v) return this.cache.invalidate(this.source), yield this.getMetadataAttempt();
|
||||||
|
throw t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getTileJson(t) {
|
||||||
|
return h(this, null, function* () {
|
||||||
|
let e = yield this.getHeader(), r = yield this.getMetadata(), n = _(e.tileType);
|
||||||
|
return { tilejson: "3.0.0", scheme: "xyz", tiles: [`${t}/{z}/{x}/{y}${n}`], vector_layers: r.vector_layers, attribution: r.attribution, description: r.description, name: r.name, version: r.version, bounds: [e.minLon, e.minLat, e.maxLon, e.maxLat], center: [e.centerLon, e.centerLat, e.centerZoom], minzoom: e.minZoom, maxzoom: e.maxZoom };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
d(H, "PMTiles");
|
||||||
|
var w = H;
|
||||||
|
|
||||||
|
// src/map/pmtiles.worker.ts
|
||||||
|
var workerScope = self;
|
||||||
|
workerScope.addProtocol("pmtiles", new B().tile);
|
||||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 283 KiB |
@@ -0,0 +1,584 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* بهروزرسانی یکبارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
|
||||||
|
*
|
||||||
|
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» میشود — هم روی دیسک
|
||||||
|
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
|
||||||
|
* موفقیت پشتیبان، پالت تازه نوشته و آپلود میشود.
|
||||||
|
*
|
||||||
|
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
|
||||||
|
* میشوند، نه JSON خام؛ پس تنها راه درست بهروزرسانی، PUT روی همان API است.
|
||||||
|
*
|
||||||
|
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیشفرض هیچ تغییری نمیدهد:
|
||||||
|
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
|
||||||
|
* node --experimental-strip-types scripts/update-map-palette.ts --palette
|
||||||
|
* node --experimental-strip-types scripts/update-map-palette.ts --restore
|
||||||
|
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
|
||||||
|
*
|
||||||
|
* در همهٔ حالتها (جز restore) ترتیب امن رعایت میشود: خواندن و اعتبارسنجی →
|
||||||
|
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
|
||||||
|
*
|
||||||
|
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده میشوند (MINIO_ROOT_USER /
|
||||||
|
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نامها.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, createHmac } from 'node:crypto'
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* پالت تازه */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** نگاشت دستهها به رنگهای تازهٔ برند — تنها منبع حقیقت این اسکریپت */
|
||||||
|
const PALETTE = {
|
||||||
|
background: '#181512',
|
||||||
|
urban: '#27221c',
|
||||||
|
highway: '#6a5544',
|
||||||
|
street: '#4a3a2d',
|
||||||
|
alley: '#352c25',
|
||||||
|
water: '#172c32',
|
||||||
|
parks: '#20312b',
|
||||||
|
nature: '#314238',
|
||||||
|
mainText: '#ead9c6',
|
||||||
|
subText: '#a99584',
|
||||||
|
borders: '#3e362e',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type Category = keyof typeof PALETTE
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* قواعد دستهبندی لایهها */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type Layer = {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
'source-layer'?: string
|
||||||
|
paint?: Record<string, unknown>
|
||||||
|
layout?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
type StyleDocument = {
|
||||||
|
version?: number
|
||||||
|
layers?: Layer[]
|
||||||
|
sources?: Record<string, unknown>
|
||||||
|
/** الگوی نشانی گلیفها — باید به پوشهٔ v2.0 باکت اشاره کند */
|
||||||
|
glyphs?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پوشهٔ فونتهای آفلاین در باکت MinIO */
|
||||||
|
const GLYPHS_FOLDER = 'v2.0'
|
||||||
|
/** الگوی استاندارد گلیف — جانگهدارها دستنخورده میمانند */
|
||||||
|
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اصلاح مسیر گلیفها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
|
||||||
|
* «v2.0/{fontstack}/{range}.pbf» تبدیل میشود تا با ساختار پوشههای باکت
|
||||||
|
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمیکند.
|
||||||
|
*/
|
||||||
|
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
|
||||||
|
const current = (style.glyphs ?? '').trim()
|
||||||
|
const marker = current.indexOf('{fontstack}')
|
||||||
|
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
|
||||||
|
if (next === current) return null
|
||||||
|
style.glyphs = next
|
||||||
|
return { from: current || '(خالی)', to: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** آیا مقدار text-field به یک فیلد «نام» اشاره میکند؟ (name، name_en، name:latin و…) */
|
||||||
|
const NAME_REFERENCE = /name(:[a-z-]+)?/i
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فونتهای محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
|
||||||
|
* تنها پوشههای Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
|
||||||
|
* فقط لاتیناند.
|
||||||
|
*/
|
||||||
|
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بازگردانی متن برچسبها به قالب دوزبانهٔ اصلی، بهتفکیک لایه.
|
||||||
|
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راهها)
|
||||||
|
*/
|
||||||
|
const TEXT_FIELD_RESTORE: Record<string, string> = {
|
||||||
|
water_name: '{name:latin}\n{name:nonlatin}',
|
||||||
|
place_other: '{name:latin}\n{name:nonlatin}',
|
||||||
|
place_suburb: '{name:latin}\n{name:nonlatin}',
|
||||||
|
place_village: '{name:latin}\n{name:nonlatin}',
|
||||||
|
place_town: '{name:latin}\n{name:nonlatin}',
|
||||||
|
place_city: '{name:latin}\n{name:nonlatin}',
|
||||||
|
place_city_large: '{name:latin}\n{name:nonlatin}',
|
||||||
|
place_state: '{name:latin}\n{name:nonlatin}',
|
||||||
|
highway_name_other: '{name:latin} {name:nonlatin}',
|
||||||
|
place_country_other: '{name:latin}',
|
||||||
|
place_country_minor: '{name:latin}',
|
||||||
|
place_country_major: '{name:latin}',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** قالب پیشفرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
|
||||||
|
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
|
||||||
|
|
||||||
|
function referencesName(value: unknown): boolean {
|
||||||
|
if (typeof value === 'string') return NAME_REFERENCE.test(value)
|
||||||
|
if (Array.isArray(value)) return value.some(referencesName)
|
||||||
|
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
|
||||||
|
function persianFontFor(stack: string[]): string {
|
||||||
|
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
|
||||||
|
if (local) return local
|
||||||
|
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آمادهسازی برچسبها برای فارسی:
|
||||||
|
* ۱) متن به قالب دوزبانهٔ اصلی برمیگردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
|
||||||
|
* ۲) پشتهٔ فونت بازچینی میشود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
|
||||||
|
* چون مپلیبر برای هر پشته فقط یک فایل گلیف میگیرد، همان فونت اول باید هر دو خط
|
||||||
|
* را پوشش دهد و Noto Sans لاتین را هم دارد.
|
||||||
|
*/
|
||||||
|
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
|
||||||
|
const changed: LabelChange[] = []
|
||||||
|
const skipped: string[] = []
|
||||||
|
|
||||||
|
for (const layer of style.layers ?? []) {
|
||||||
|
const layout = layer.layout
|
||||||
|
if (layer.type !== 'symbol' || !layout) continue
|
||||||
|
|
||||||
|
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
|
||||||
|
if (!stack.length) continue
|
||||||
|
|
||||||
|
const persianFont = persianFontFor(stack)
|
||||||
|
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
|
||||||
|
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
|
||||||
|
|
||||||
|
if (!('text-field' in layout)) {
|
||||||
|
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح میشود
|
||||||
|
layout['text-font'] = restacked
|
||||||
|
changed.push({ id: layer.id, font: fontChange })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!referencesName(layout['text-field'])) {
|
||||||
|
layout['text-font'] = restacked
|
||||||
|
skipped.push(layer.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = JSON.stringify(layout['text-field']).slice(0, 48)
|
||||||
|
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
|
||||||
|
layout['text-field'] = to
|
||||||
|
layout['text-font'] = restacked
|
||||||
|
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { changed, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بررسی فونتها: هر فونتی که در text-font لایههای برچسب استفاده شده باید در
|
||||||
|
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
|
||||||
|
*/
|
||||||
|
async function verifyFonts(style: StyleDocument): Promise<void> {
|
||||||
|
const fonts = new Set<string>()
|
||||||
|
for (const layer of style.layers ?? []) {
|
||||||
|
if (layer.type !== 'symbol') continue
|
||||||
|
const stack = layer.layout?.['text-font']
|
||||||
|
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fonts.size) {
|
||||||
|
console.log('• فونتی در لایههای برچسب پیدا نشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
|
||||||
|
const missing: string[] = []
|
||||||
|
for (const font of [...fonts].sort()) {
|
||||||
|
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
|
||||||
|
const ok = (await fetch(url, { method: 'HEAD' })).ok
|
||||||
|
if (!ok) missing.push(font)
|
||||||
|
console.log(` ${ok ? '✓' : '✗'} ${font}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
missing.length
|
||||||
|
? `✗ فونتهای ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
|
||||||
|
: `✓ همهٔ فونتها در ${GLYPHS_FOLDER} باکت موجودند`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* بررسی پوشش فونتهای محلی (کدام فونت فارسی را رندر میکند؟) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** بازههای کلیدی گلیف: لاتین پایه، عربی/فارسی، فرمهای نمایشی عربی */
|
||||||
|
const COVERAGE_RANGES = [
|
||||||
|
{ label: 'لاتین', range: '0-255' },
|
||||||
|
{ label: 'فارسی', range: '1536-1791' },
|
||||||
|
{ label: 'فرم فارسی', range: '65024-65279' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** فهرست پوشههای فونت داخل v2.0 باکت */
|
||||||
|
async function listFontFolders(): Promise<string[]> {
|
||||||
|
const response = await fetch(
|
||||||
|
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
|
||||||
|
)
|
||||||
|
const xml = await response.text()
|
||||||
|
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
|
||||||
|
return [...xml.matchAll(pattern)].map((match) => match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
|
||||||
|
async function rangeCoverage(font: string, range: string): Promise<string> {
|
||||||
|
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
|
||||||
|
const response = await fetch(url, { method: 'HEAD' })
|
||||||
|
if (!response.ok) return 'نیست'
|
||||||
|
const bytes = Number(response.headers.get('content-length') ?? 0)
|
||||||
|
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
|
||||||
|
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گزارش پوشش خطها برای همهٔ فونتهای باکت — ورودی تصمیم انتخاب فونت برچسبها */
|
||||||
|
async function auditFonts(): Promise<void> {
|
||||||
|
const fonts = await listFontFolders()
|
||||||
|
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
|
||||||
|
for (const font of fonts) {
|
||||||
|
const cells: string[] = []
|
||||||
|
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
|
||||||
|
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
|
||||||
|
}
|
||||||
|
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر میکند.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا میشود تا مطمئن شویم سند
|
||||||
|
* سالم است و اسکیمای سبک (نسخه، لایهها، منابع) نشکسته است.
|
||||||
|
*/
|
||||||
|
function validateStyle(style: StyleDocument, label: string): void {
|
||||||
|
const problems: string[] = []
|
||||||
|
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
|
||||||
|
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
|
||||||
|
else {
|
||||||
|
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
|
||||||
|
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
|
||||||
|
}
|
||||||
|
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
|
||||||
|
problems.push('sources باید یک شیء باشد')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
problems.length
|
||||||
|
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
|
||||||
|
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
|
||||||
|
)
|
||||||
|
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule = {
|
||||||
|
category: Category
|
||||||
|
/** با این الگو روی شناسهٔ لایه تطبیق داده میشود */
|
||||||
|
test: RegExp
|
||||||
|
/** کدام ویژگیهای paint رنگآمیزی شوند */
|
||||||
|
properties: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جدول قواعد — به ترتیب بررسی میشود و نخستین تطبیق برنده است.
|
||||||
|
* رنگهای داخل عبارتها (interpolate/match) هم بازنویسی میشوند تا پالت
|
||||||
|
* یکدست بماند.
|
||||||
|
*/
|
||||||
|
const RULES: Rule[] = [
|
||||||
|
{ category: 'background', test: /^background$/, properties: ['background-color'] },
|
||||||
|
|
||||||
|
// آب: خود لایههای آب و راههای آبی (برچسبها قاعدهٔ جدا دارند)
|
||||||
|
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
|
||||||
|
|
||||||
|
// پارکها پیش از طبیعت بررسی میشوند تا چمن/پارک زیر «طبیعت» نرود
|
||||||
|
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
|
||||||
|
|
||||||
|
// طبیعت: پوشش زمینِ جنگلی/علفزارِ بیرون از شهر
|
||||||
|
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
|
||||||
|
|
||||||
|
// کاربری شهری و ساختمانها
|
||||||
|
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
|
||||||
|
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
|
||||||
|
|
||||||
|
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
|
||||||
|
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
|
||||||
|
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
|
||||||
|
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
|
||||||
|
|
||||||
|
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
|
||||||
|
|
||||||
|
// برچسبهای اصلی: شهر/شهرک و نام بزرگراهها
|
||||||
|
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
|
||||||
|
|
||||||
|
// بقیهٔ برچسبها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
|
||||||
|
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** ویژگیهای فرعی که همیشه با رنگ پسزمینه هماهنگ میشوند تا خوانایی بماند */
|
||||||
|
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
|
||||||
|
/** ویژگیهای آیکون برچسبها که با متن همرنگ میشوند */
|
||||||
|
const ICON_PROPERTIES = ['icon-color']
|
||||||
|
|
||||||
|
const COLOR_PATTERN = /^(#|rgb|hsl)/i
|
||||||
|
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* بازنویسی رنگها (توابع خالص) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارتها — را با رنگ تازه عوض میکند */
|
||||||
|
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
|
||||||
|
if (isColor(value)) {
|
||||||
|
stats.seen.add(value.trim())
|
||||||
|
stats.replaced += 1
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
|
||||||
|
|
||||||
|
/** یک لایه را بر پایهٔ قواعد رنگآمیزی میکند و گزارش تغییرها را برمیگرداند */
|
||||||
|
function recolorLayer(layer: Layer): LayerReport {
|
||||||
|
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
|
||||||
|
const stats = { replaced: 0, seen: new Set<string>() }
|
||||||
|
|
||||||
|
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
|
||||||
|
|
||||||
|
if (rule) {
|
||||||
|
for (const property of rule.properties) {
|
||||||
|
if (!(property in layer.paint)) continue
|
||||||
|
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// لایههای برچسب: هاله و آیکونها با پالت هماهنگ میشوند
|
||||||
|
if (layer.type === 'symbol') {
|
||||||
|
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
|
||||||
|
? PALETTE[rule.category]
|
||||||
|
: PALETTE.subText
|
||||||
|
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
|
||||||
|
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
|
||||||
|
}
|
||||||
|
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
|
||||||
|
if (!(property in layer.paint)) continue
|
||||||
|
const target = property === 'icon-color' ? textColor : PALETTE.background
|
||||||
|
layer.paint[property] = paintColor(layer.paint[property], target, stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// لبهٔ ساختمانها با رنگ مرزها همخانواده میشود
|
||||||
|
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
|
||||||
|
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** کل سند سبک را بازنویسی میکند (بدون دستزدن به ساختار و ویژگیهای غیررنگی) */
|
||||||
|
function recolorStyle(style: StyleDocument): LayerReport[] {
|
||||||
|
const layers = style.layers ?? []
|
||||||
|
return layers.map(recolorLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** گزارش فشردهٔ تغییرهای پالت — بهتفکیک دسته و لایههای دستنخورده */
|
||||||
|
function reportPalette(reports: LayerReport[]): void {
|
||||||
|
const byCategory = new Map<string, number>()
|
||||||
|
for (const report of reports) {
|
||||||
|
if (!report.replaced) continue
|
||||||
|
const category = report.category ?? 'بدوندسته'
|
||||||
|
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nگزارش تغییرها:')
|
||||||
|
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
|
||||||
|
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const untouched = reports.filter((report) => !report.replaced)
|
||||||
|
if (untouched.length) {
|
||||||
|
console.log(`\nلایههای بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type Credentials = { user: string; secret: string }
|
||||||
|
|
||||||
|
async function readCredentials(): Promise<Credentials> {
|
||||||
|
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
|
||||||
|
const fromFile: Record<string, string> = {}
|
||||||
|
try {
|
||||||
|
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
|
||||||
|
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
|
||||||
|
if (match) fromFile[match[1]] = match[2].trim()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین میشوند */
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
|
||||||
|
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
|
||||||
|
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
|
||||||
|
return { user, secret }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
|
||||||
|
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
|
||||||
|
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
|
||||||
|
|
||||||
|
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام میدهند */
|
||||||
|
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
|
||||||
|
const url = new URL(objectPath, endpoint)
|
||||||
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
||||||
|
const dateStamp = amzDate.slice(0, 8)
|
||||||
|
const payloadHash = sha256(body)
|
||||||
|
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
|
||||||
|
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
||||||
|
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
|
||||||
|
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
|
||||||
|
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
|
||||||
|
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
|
||||||
|
const authorization =
|
||||||
|
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
|
||||||
|
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
'x-amz-content-sha256': payloadHash,
|
||||||
|
'x-amz-date': amzDate,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* اجرا */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
|
||||||
|
const STYLE_PATH = '/maps/style.json'
|
||||||
|
const BACKUP_PATH = '/maps/style.backup.json'
|
||||||
|
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
|
||||||
|
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
|
||||||
|
|
||||||
|
/** بازگردانی: پشتیبان دقیقاً بهجای style.json نوشته میشود */
|
||||||
|
async function restore(): Promise<void> {
|
||||||
|
const backup = await readFile(LOCAL_BACKUP, 'utf8')
|
||||||
|
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
|
||||||
|
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
|
||||||
|
|
||||||
|
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
|
||||||
|
console.log(
|
||||||
|
restored === backup
|
||||||
|
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
|
||||||
|
: '✗ محتوای بازگرداندهشده با پشتیبان یکسان نیست — دستی بررسی کنید',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
const action = ACTIONS.find((flag) => process.argv.includes(flag))
|
||||||
|
|
||||||
|
// پیشفرض امن: بدون پرچم هیچ نوشتنی انجام نمیشود
|
||||||
|
if (!action) {
|
||||||
|
console.log(
|
||||||
|
'کاری انجام نشد — یکی از پرچمها را بدهید:\n' +
|
||||||
|
' --audit-fonts گزارش پوشش خطهای هر فونت در v2.0 (فقط خواندن)\n' +
|
||||||
|
' --fix-labels برچسبهای دوزبانه + پشتهٔ فونت محلی\n' +
|
||||||
|
' --fix-glyphs اصلاح مسیر گلیفها به v2.0\n' +
|
||||||
|
' --palette اعمال پالت تازه\n' +
|
||||||
|
' --restore بازگردانی از پشتیبان\n' +
|
||||||
|
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === '--audit-fonts') {
|
||||||
|
await auditFonts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === '--restore') {
|
||||||
|
await restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۱) خواندن سبک فعلی
|
||||||
|
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
|
||||||
|
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
|
||||||
|
const original = await response.text()
|
||||||
|
const style = JSON.parse(original) as StyleDocument
|
||||||
|
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
|
||||||
|
validateStyle(style, 'نسخهٔ فعلی')
|
||||||
|
|
||||||
|
// ۲) پشتیبانگیری، پیش از هر تغییر
|
||||||
|
await mkdir(OUTPUT_DIR, { recursive: true })
|
||||||
|
await writeFile(LOCAL_BACKUP, original, 'utf8')
|
||||||
|
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
|
||||||
|
if (!dryRun) {
|
||||||
|
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
|
||||||
|
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
|
||||||
|
if (action === '--fix-labels') {
|
||||||
|
const { changed, skipped } = fixLabels(style)
|
||||||
|
console.log(`\nلایههای برچسب اصلاحشده (${changed.length}):`)
|
||||||
|
for (const layer of changed) {
|
||||||
|
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
|
||||||
|
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
|
||||||
|
}
|
||||||
|
if (skipped.length) {
|
||||||
|
console.log(`\nلایههای بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
|
||||||
|
}
|
||||||
|
await verifyFonts(style)
|
||||||
|
} else if (action === '--fix-glyphs') {
|
||||||
|
const change = fixGlyphsPath(style)
|
||||||
|
console.log(
|
||||||
|
change ? `\nمسیر گلیفها: ${change.from} → ${change.to}` : '\nمسیر گلیفها از قبل به v2.0 اشاره میکند',
|
||||||
|
)
|
||||||
|
await verifyFonts(style)
|
||||||
|
} else {
|
||||||
|
reportPalette(recolorStyle(style))
|
||||||
|
}
|
||||||
|
validateStyle(style, 'نسخهٔ تازه')
|
||||||
|
|
||||||
|
// ۴) نوشتن و آپلود نسخهٔ تازه
|
||||||
|
const updated = JSON.stringify(style, null, 2)
|
||||||
|
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
|
||||||
|
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error('\nخطا:', error instanceof Error ? error.message : error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
|
|||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import type { SiteData } from '../api'
|
import type { SiteData } from '../api'
|
||||||
import { useSiteData } from '../api'
|
import { useSiteData } from '../api'
|
||||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
|
||||||
import { ENTITIES, isTranslatable } from './entities'
|
import { ENTITIES, isTranslatable } from './entities'
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||||
@@ -129,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => document.removeEventListener('click', onClick, true)
|
return () => document.removeEventListener('click', onClick, true)
|
||||||
}, [isEditMode])
|
}, [isEditMode])
|
||||||
|
|
||||||
|
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||||
|
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||||
|
useEffect(() => {
|
||||||
|
const syncAuth = () => setIsAuthenticated(!!getToken())
|
||||||
|
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||||
|
window.addEventListener('storage', syncAuth)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||||
|
window.removeEventListener('storage', syncAuth)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveStatus !== 'saving') return
|
if (saveStatus !== 'saving') return
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Controller, useController, useForm } from 'react-hook-form'
|
import { Controller, useController, useForm } from 'react-hook-form'
|
||||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
|
import { ArrowRightIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { useSiteData } from '../api'
|
import { useSiteData } from '../api'
|
||||||
import { useAdmin } from './AdminContext'
|
import { useAdmin } from './AdminContext'
|
||||||
@@ -54,6 +54,75 @@ function normalizeTags(value: unknown): string[] {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** یک ردیف پرسشوپاسخ مقاله */
|
||||||
|
type ArticleFaq = { question: string; answer: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پنل پرسشوپاسخهای پایان مقاله — مثل بخش متا دیسکریپشن زیر ویرایشگر.
|
||||||
|
* در زبان فارسی ستون faqs و در زبان انگلیسی faqs_en را ویرایش میکند؛
|
||||||
|
* نسخه انگلیسیِ خالی در سایت به فارسی برمیگردد. ذخیرهسازی خودکار است.
|
||||||
|
*/
|
||||||
|
function FaqPanel({
|
||||||
|
faqs,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
faqs: ArticleFaq[]
|
||||||
|
onChange: (next: ArticleFaq[]) => void
|
||||||
|
}) {
|
||||||
|
const update = (index: number, patch: Partial<ArticleFaq>) => {
|
||||||
|
const next = faqs.map((f, i) => (i === index ? { ...f, ...patch } : f))
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
const remove = (index: number) => onChange(faqs.filter((_, i) => i !== index))
|
||||||
|
const add = () => onChange([...faqs, { question: '', answer: '' }])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 grid gap-4">
|
||||||
|
{faqs.map((faq, i) => (
|
||||||
|
<div key={i} className="grid gap-3 border border-ivory/10 bg-ink/60 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-[11px] font-light text-copper">پرسش {i + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
aria-label={`حذف پرسش ${i + 1}`}
|
||||||
|
className="flex h-7 w-7 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={faq.question}
|
||||||
|
onChange={(e) => update(i, { question: e.target.value })}
|
||||||
|
placeholder="پرسش مخاطب…"
|
||||||
|
aria-label={`متن پرسش ${i + 1}`}
|
||||||
|
className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
dir="auto"
|
||||||
|
value={faq.answer}
|
||||||
|
onChange={(e) => update(i, { answer: e.target.value })}
|
||||||
|
placeholder="پاسخ کوتاه و روشن…"
|
||||||
|
aria-label={`پاسخ پرسش ${i + 1}`}
|
||||||
|
className="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>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
className="flex w-fit items-center gap-2 border border-dashed border-ivory/25 px-4 py-2 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-3.5 w-3.5" />
|
||||||
|
افزودن پرسشوپاسخ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_TEXT: Record<string, string> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
idle: 'ذخیره شد',
|
idle: 'ذخیره شد',
|
||||||
saving: 'در حال ذخیره…',
|
saving: 'در حال ذخیره…',
|
||||||
@@ -458,6 +527,20 @@ export function ArticleWritePage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* پرسشوپاسخهای پایان مقاله — سئوی FAQPage */}
|
||||||
|
<section className="mt-8 border border-ivory/15 bg-char p-6">
|
||||||
|
<h2 className="text-sm font-bold text-copper">پرسشهای متداول مقاله</h2>
|
||||||
|
<p className="mt-1 text-xs font-light text-ivory/50">
|
||||||
|
در پایان مقاله با ظاهر آکاردئونی سایت نمایش داده میشود و با اسکیمای FAQPage
|
||||||
|
شانس نمایش در نتایج غنی گوگل را بالا میبرد.
|
||||||
|
{editLang === 'en' && ' — ویرایش نسخه انگلیسی (خالی = نمایش متن فارسی)'}
|
||||||
|
</p>
|
||||||
|
<FaqPanel
|
||||||
|
faqs={(editLang === 'en' ? draft.faqs_en : draft.faqs) ?? []}
|
||||||
|
onChange={(next) => queueSave({ [suf('faqs')]: next })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* چکیده و متادیتا */}
|
{/* چکیده و متادیتا */}
|
||||||
<SeoChecklist
|
<SeoChecklist
|
||||||
input={{
|
input={{
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
|
|||||||
|
|
||||||
const TOKEN_KEY = 'aramesh_admin_token'
|
const TOKEN_KEY = 'aramesh_admin_token'
|
||||||
|
|
||||||
|
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
||||||
|
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
||||||
|
|
||||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||||
export const setToken = (token: string | null): void => {
|
export const setToken = (token: string | null): void => {
|
||||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||||
else localStorage.removeItem(TOKEN_KEY)
|
else localStorage.removeItem(TOKEN_KEY)
|
||||||
|
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
@@ -83,12 +87,17 @@ export const adminApi = {
|
|||||||
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
reorder: <T = unknown>(resource: string, ids: number[]) =>
|
||||||
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||||
|
|
||||||
upload: async (file: File): Promise<{ url: string; key: string }> => {
|
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
|
if (name && name.trim()) fd.append('name', name.trim())
|
||||||
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** درونریزی تصویر از نشانی خارجی — دانلود و ذخیره در Object Storage خود سایت */
|
||||||
|
importImage: async (url: string): Promise<{ url: string; key: string }> =>
|
||||||
|
authFetch('/api/admin/import-image', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||||
|
|
||||||
changePassword: (currentPassword: string, newPassword: string) =>
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
authFetch<{ ok: boolean }>('/api/auth/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Article, Project, Service } from './data/site'
|
import type { Article, Project, Service } from './data/site'
|
||||||
import { API_URL } from './config'
|
import { API_URL } from './config'
|
||||||
import { getToken } from './admin/adminClient'
|
import { getToken, setToken } from './admin/adminClient'
|
||||||
import { localizeSite } from './i18n/localize'
|
import { localizeSite } from './i18n/localize'
|
||||||
import { useLang } from './i18n/lang'
|
import { useLang } from './i18n/lang'
|
||||||
|
|
||||||
@@ -230,10 +230,16 @@ interface SiteContextValue {
|
|||||||
const SiteContext = createContext<SiteContextValue>({
|
const SiteContext = createContext<SiteContextValue>({
|
||||||
data: null,
|
data: null,
|
||||||
error: false,
|
error: false,
|
||||||
|
// پیشفرضهای بیاثر — مصرفکننده بیرون از Provider هم بدون خطا رندر میشود
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
reload: () => {},
|
reload: () => {},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
updateSite: () => {},
|
updateSite: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** سقف انتظار داده سایت — سرور بیپاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بیپایان */
|
||||||
|
const SITE_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||||
const [data, setData] = useState<SiteData | null>(null)
|
const [data, setData] = useState<SiteData | null>(null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
@@ -241,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setError(false)
|
setError(false)
|
||||||
fetch(`${API_URL}/api/site`)
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
||||||
|
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json() as Promise<SiteData>
|
return res.json() as Promise<SiteData>
|
||||||
@@ -255,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.status === 401) {
|
||||||
|
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
||||||
|
setToken(null)
|
||||||
|
} else if (res.ok) {
|
||||||
const all = (await res.json()) as Article[]
|
const all = (await res.json()) as Article[]
|
||||||
const published = new Set(json.articles.map((a) => a.id))
|
const published = new Set(json.articles.map((a) => a.id))
|
||||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||||
json.articles = [...json.articles, ...drafts]
|
json.articles = [...json.articles, ...drafts]
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* بدون پیشنویسها ادامه بده */
|
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -275,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||||
import type { Article } from '../data/site'
|
import type { Article } from '../data/site'
|
||||||
@@ -11,43 +10,43 @@ export function ArticleCard({ article }: { article: Article }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href(`/blog/${article.slug}`)}
|
to={href(`/blog/${article.slug}`)}
|
||||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 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.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
className="group flex h-full flex-col overflow-hidden rounded-xl md:rounded-2xl border border-white/10 bg-char/80 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.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden aspect-[16/10] w-full">
|
||||||
<img
|
<img
|
||||||
src={article.image}
|
src={article.image}
|
||||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
<div className="absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-2.5 py-0.5 md:px-3.5 md:py-1 text-[11px] md:text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||||
{article.category}
|
{article.category}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-7">
|
<div className="flex flex-1 flex-col p-3.5 md:p-8">
|
||||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
<h3 className="mb-1.5 md:mb-6 text-xs md:text-xl font-bold leading-snug md:leading-relaxed text-ivory line-clamp-2 transition-colors duration-300 group-hover:text-copper">
|
||||||
{article.title}
|
{article.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
<p className="mb-2.5 md:mb-10 text-[11px] md:text-[15px] font-light leading-relaxed md:leading-loose text-ivory/60 line-clamp-2 md:line-clamp-3">
|
||||||
{article.excerpt}
|
{article.excerpt}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-2.5 md:pt-8 text-[11px] md:text-xs font-light text-ivory/50">
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-2.5 md:gap-4">
|
||||||
<span>{formatJalali(article.date)}</span>
|
<span>{formatJalali(article.date)}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5 md:gap-2">
|
||||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
<ClockIcon className="h-3 w-3 md:h-3.5 md:w-3.5 text-copper/80" />
|
||||||
{toPersianDigits(article.read)}
|
{toPersianDigits(article.read)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||||
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
|
<span className="inline-flex items-center gap-1.5 md:gap-2 rounded-full border border-copper/40 bg-copper/5 px-2.5 py-1 md:px-3.5 md:py-1.5 text-[11px] md: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)] rtl:group-hover:[&>svg]:-translate-x-1 ltr:group-hover:[&>svg]:translate-x-1">
|
||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
<ArrowLeftIcon className="h-3 w-3 md:h-3.5 md:w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
/** یک پرسشوپاسخ مقاله — همان شکل داده ستون faqs مقاله در دیتابیس */
|
||||||
|
export type ArticleFaq = { question: string; answer: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پرسشوپاسخهای پایان مقاله — همان رفتار آکاردئونی بخش FAQ سایت، بدون ویرایش مدیریتی.
|
||||||
|
* در سرچ کنسول/نتایج غنی گوگل، اسکیمای FAQPage آن جداگانه تزریق میشود.
|
||||||
|
*/
|
||||||
|
export function ArticleFaq({ faqs }: { faqs: ArticleFaq[] }) {
|
||||||
|
const [open, setOpen] = useState<number | null>(0)
|
||||||
|
|
||||||
|
if (!faqs.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-16" aria-label="پرسشهای متداول مقاله">
|
||||||
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
|
<ul className="mt-2">
|
||||||
|
{faqs.map((faq, i) => {
|
||||||
|
const isOpen = open === i
|
||||||
|
return (
|
||||||
|
<li key={i} className="border-b border-ivory/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{faq.question}
|
||||||
|
</span>
|
||||||
|
<PlusIcon
|
||||||
|
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
|
||||||
|
isOpen ? 'rotate-45' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<p className="pb-7 ps-2 text-sm font-light leading-9 text-ivory/60">
|
||||||
|
{faq.answer}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
|
|
||||||
|
|
||||||
type BaseProps = {
|
|
||||||
children: React.ReactNode
|
|
||||||
variant?: Variant
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
|
||||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
|
||||||
copper: {
|
|
||||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
|
||||||
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
|
||||||
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
|
||||||
text: 'group-hover:text-char font-semibold',
|
|
||||||
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
|
||||||
},
|
|
||||||
outline: {
|
|
||||||
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
|
||||||
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
|
||||||
fill: 'bg-white/10',
|
|
||||||
text: 'group-hover:text-copper',
|
|
||||||
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
|
||||||
fill: 'bg-copper',
|
|
||||||
text: 'group-hover:text-char',
|
|
||||||
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
|
||||||
},
|
|
||||||
'dark-ivory': {
|
|
||||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
|
||||||
fill: 'bg-ivory',
|
|
||||||
text: 'group-hover:text-char',
|
|
||||||
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
|
|
||||||
const style = STYLES[variant]
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
|
||||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
|
||||||
|
|
||||||
{/* انیمیشن پر شدن موجی و نرم */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* متن و آیکون دکمه */}
|
|
||||||
<span
|
|
||||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
|
||||||
const SHELL =
|
|
||||||
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
|
|
||||||
|
|
||||||
export function ButtonLink({
|
|
||||||
to,
|
|
||||||
children,
|
|
||||||
variant = 'copper',
|
|
||||||
className = '',
|
|
||||||
}: BaseProps & { to: string }) {
|
|
||||||
const style = STYLES[variant]
|
|
||||||
return (
|
|
||||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
|
||||||
<Inner variant={variant}>{children}</Inner>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ButtonAnchor({
|
|
||||||
href,
|
|
||||||
children,
|
|
||||||
variant = 'copper',
|
|
||||||
className = '',
|
|
||||||
...rest
|
|
||||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
||||||
const style = STYLES[variant]
|
|
||||||
return (
|
|
||||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
|
||||||
<Inner variant={variant}>{children}</Inner>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button({
|
|
||||||
children,
|
|
||||||
variant = 'copper',
|
|
||||||
className = '',
|
|
||||||
...rest
|
|
||||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
||||||
const style = STYLES[variant]
|
|
||||||
return (
|
|
||||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
|
||||||
<Inner variant={variant}>{children}</Inner>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { Reveal } from './Reveal'
|
import { Reveal } from './Reveal'
|
||||||
import { Button } from './Button'
|
import { PremiumButton } from './PremiumButton'
|
||||||
import { useSite } from '../api'
|
import { useSite } from '../api'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { useFieldSaver } from '../admin/AdminContext'
|
import { useFieldSaver } from '../admin/AdminContext'
|
||||||
@@ -100,18 +100,139 @@ export function ContactSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleanOptionalLabel = `(${content.note_optional_label?.replace(/^\(+|\)+$/g, '') || 'اختیاری'})`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
|
<section id="contact" className="relative w-full max-w-full overflow-x-clip border-t border-copper/15 py-16 md:py-24 lg:py-32" aria-labelledby="contact-title">
|
||||||
<img
|
<img
|
||||||
src={brand.contact_bg}
|
src={brand.contact_bg}
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
<div className="absolute inset-0 bg-gradient-to-b from-ink/90 via-ink/75 to-ink backdrop-blur-[2px]" />
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-site px-6 lg:px-10">
|
<div className="relative mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
|
||||||
|
{/* Mobile View: High-Conversion Streamlined Layout */}
|
||||||
|
<div className="block md:hidden px-1 py-1">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<p className="text-xs font-light tracking-wide text-copper">
|
||||||
|
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1.5 text-xl font-bold text-ivory">
|
||||||
|
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1.5 text-xs font-light leading-relaxed text-ivory/70 max-w-sm mx-auto">
|
||||||
|
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* High-Conversion Form Box */}
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-char/60 p-4 backdrop-blur-xl shadow-lg space-y-3">
|
||||||
|
{sent ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper">
|
||||||
|
<CheckCircle2Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-base font-bold text-ivory">
|
||||||
|
{content.success_title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/70">
|
||||||
|
{content.success_text}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSent(false)
|
||||||
|
setForm({ name: '', phone: '', note: '', website: '' })
|
||||||
|
}}
|
||||||
|
className="mt-4 rounded-xl bg-copper px-6 py-2.5 text-xs font-bold text-char transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{content.success_button_label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit} noValidate className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website_mobile"
|
||||||
|
value={form.website}
|
||||||
|
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contact-name-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||||
|
{content.name_label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-name-mobile"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder={content.name_placeholder}
|
||||||
|
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contact-phone-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||||
|
{content.phone_field_label} <span className="text-copper">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-phone-mobile"
|
||||||
|
type="tel"
|
||||||
|
dir="ltr"
|
||||||
|
required
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
|
placeholder={content.phone_placeholder}
|
||||||
|
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none text-left font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contact-note-mobile" className="text-[11px] font-semibold text-ivory/80 mb-1 block">
|
||||||
|
{content.note_label} <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-note-mobile"
|
||||||
|
rows={2}
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||||
|
placeholder={content.note_placeholder}
|
||||||
|
className="w-full py-2.5 px-3 text-xs rounded-xl border border-white/10 bg-ink/60 text-ivory placeholder:text-ivory/35 backdrop-blur-md focus:border-copper/70 focus:outline-none resize-none leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={sending}
|
||||||
|
className="w-full py-3 rounded-xl bg-copper text-char font-bold text-xs shadow-md hover:brightness-110 active:scale-95 transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{sending ? content.sending_label : content.submit_label}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Direct Call Fallback */}
|
||||||
|
<a
|
||||||
|
href={brand.phone_href}
|
||||||
|
onClick={() => track('phone-click', { from: 'contact-section-mobile-hotline' })}
|
||||||
|
className="flex items-center justify-center gap-1.5 pt-2 text-[11px] text-ivory/60 hover:text-copper transition-colors"
|
||||||
|
>
|
||||||
|
<span>نیاز به گفتگوی فوری دارید؟</span>
|
||||||
|
<span className="font-bold text-copper dir-ltr" dir="ltr">{brand.phone}</span>
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View */}
|
||||||
|
<div className="hidden md:grid mx-auto max-w-6xl items-stretch gap-8 lg:grid-cols-12">
|
||||||
|
|
||||||
{/* باکس اطلاعات تماس */}
|
{/* باکس اطلاعات تماس */}
|
||||||
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
|
||||||
@@ -197,17 +318,15 @@ export function ContactSection() {
|
|||||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<PremiumButton
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-8 font-light"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSent(false)
|
setSent(false)
|
||||||
setForm({ name: '', phone: '', note: '' })
|
setForm({ name: '', phone: '', note: '', website: '' })
|
||||||
}}
|
}}
|
||||||
|
className="mt-8 font-light"
|
||||||
>
|
>
|
||||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||||
</Button>
|
</PremiumButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||||
@@ -254,7 +373,7 @@ export function ContactSection() {
|
|||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
|
||||||
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
|
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{cleanOptionalLabel}</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="contact-note"
|
id="contact-note"
|
||||||
@@ -267,26 +386,23 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex justify-start">
|
<div className="md:col-span-2 flex justify-start">
|
||||||
<Button
|
<PremiumButton
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="copper"
|
|
||||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
content.sending_label
|
content.sending_label
|
||||||
) : (
|
) : (
|
||||||
<>
|
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
|
||||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</PremiumButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
@@ -17,6 +17,7 @@ export function Footer() {
|
|||||||
const { brand, services, nav, footer } = useSite();
|
const { brand, services, nav, footer } = useSite();
|
||||||
const { t, href } = useLang();
|
const { t, href } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||||
|
|
||||||
const saveFooter = (field: string, value: string) =>
|
const saveFooter = (field: string, value: string) =>
|
||||||
save('footer', footer.id, { [field]: value }, (d) => {
|
save('footer', footer.id, { [field]: value }, (d) => {
|
||||||
@@ -34,19 +35,22 @@ export function Footer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
<footer className="relative w-full max-w-full overflow-x-clip border-t border-white/10 bg-char/95 backdrop-blur-2xl">
|
||||||
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
|
<div className="mx-auto max-w-site px-4 py-8 md:px-6 md:py-16 lg:px-10">
|
||||||
<div className="grid gap-12 lg:grid-cols-12">
|
|
||||||
<div className="lg:col-span-4">
|
{/* Mobile View: Collapsible Luxury Accordions */}
|
||||||
|
<div className="block md:hidden">
|
||||||
|
{/* Top Brand Block */}
|
||||||
|
<div className="mb-6 flex flex-col items-center text-center">
|
||||||
<div className="relative w-fit">
|
<div className="relative w-fit">
|
||||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
<img src={brand.logo} alt={brand.name} className="h-16 w-auto object-contain" />
|
||||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
<SingletonPill resource="brand" label="ویرایش برند" className="absolute top-1/2 -translate-y-1/2 ltr:-right-8 rtl:-left-8" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
<p className="mt-4 text-xs font-light leading-relaxed text-ivory/60 max-w-xs">
|
||||||
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
{footer.about_text}
|
||||||
</p>
|
</p>
|
||||||
{/* آیکونهای شبکههای اجتماعی: گویهای شیشهای شناور */}
|
{/* Social icons */}
|
||||||
<div className="mt-8 flex items-center gap-3">
|
<div className="mt-4 flex items-center justify-center gap-3">
|
||||||
{footer.socials.map(({ label, href, icon }) => {
|
{footer.socials.map(({ label, href, icon }) => {
|
||||||
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||||
return (
|
return (
|
||||||
@@ -54,100 +58,234 @@ export function Footer() {
|
|||||||
key={label}
|
key={label}
|
||||||
href={href}
|
href={href}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
className="group flex h-10 w-10 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper/70 hover:bg-copper/10 hover:text-copper active:scale-95"
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
<Icon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:scale-110" />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
{/* Collapsible Link Groups */}
|
||||||
<h3 className="text-sm font-bold text-copper">
|
<div className="border-t border-white/10">
|
||||||
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
{/* Pages Accordion */}
|
||||||
</h3>
|
<div className="border-b border-white/10">
|
||||||
<ul className="mt-5 space-y-3">
|
<button
|
||||||
{nav.map((item) => (
|
type="button"
|
||||||
<EditableItem key={item.id} resource="nav" item={item}>
|
onClick={() => setOpenSection(openSection === 'pages' ? null : 'pages')}
|
||||||
<li>
|
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||||
<Link
|
>
|
||||||
to={href(item.href)}
|
<span>{footer.pages_heading}</span>
|
||||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'pages' ? 'rotate-180' : ''}`}>
|
||||||
>
|
▼
|
||||||
<InlineEdit
|
</span>
|
||||||
value={item.label}
|
</button>
|
||||||
onSave={(v) => saveNav(item.id, { label: v })}
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'pages' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
/>
|
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||||
</Link>
|
{nav.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<Link
|
||||||
|
to={href(item.href)}
|
||||||
|
className="block py-1 transition-colors hover:text-copper"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services Accordion */}
|
||||||
|
<div className="border-b border-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenSection(openSection === 'services' ? null : 'services')}
|
||||||
|
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>{footer.services_heading}</span>
|
||||||
|
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'services' ? 'rotate-180' : ''}`}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'services' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
|
<ul className="space-y-2 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||||
|
{services.map((service) => (
|
||||||
|
<li key={service.id} className="py-1">
|
||||||
|
{service.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Accordion */}
|
||||||
|
<div className="border-b border-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenSection(openSection === 'contact' ? null : 'contact')}
|
||||||
|
className="flex w-full items-center justify-between py-3.5 text-xs font-bold text-copper text-start cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>{footer.contact_heading}</span>
|
||||||
|
<span className={`text-[10px] text-copper transition-transform duration-300 ${openSection === 'contact' ? 'rotate-180' : ''}`}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openSection === 'contact' ? 'max-h-96 pb-3 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||||
|
<ul className="space-y-3 rtl:pr-2 ltr:pl-2 text-xs text-ivory/70">
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<MapPinIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-copper" />
|
||||||
|
<span className="leading-relaxed">{brand.address}</span>
|
||||||
</li>
|
</li>
|
||||||
</EditableItem>
|
<li className="flex items-center gap-2.5">
|
||||||
))}
|
<PhoneIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||||
</ul>
|
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
<div className="mt-4">
|
{brand.phone}
|
||||||
<AddItemButton resource="nav" />
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2.5">
|
||||||
|
<MailIcon className="h-3.5 w-3.5 shrink-0 text-copper" />
|
||||||
|
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
|
{brand.email}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
{/* Bottom Copyright & Meta for Mobile */}
|
||||||
<h3 className="text-sm font-bold text-copper">
|
<div className="mt-6 flex flex-col items-center gap-3 border-t border-white/10 pt-4 text-[11px] text-ivory/50 text-center">
|
||||||
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
<button
|
||||||
</h3>
|
type="button"
|
||||||
<ul className="mt-5 space-y-3">
|
onClick={scrollToTop}
|
||||||
{services.map((service) => (
|
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-3.5 py-1.5 text-xs font-medium text-ivory/70 backdrop-blur-xl transition-all hover:border-copper hover:bg-copper/10 hover:text-copper active:scale-95 cursor-pointer mb-1"
|
||||||
<li key={service.id} className="text-sm font-light text-ivory/60">
|
>
|
||||||
{service.title}
|
<span>{t('backToTop')}</span>
|
||||||
|
<ArrowUpIcon className="h-3 w-3 text-copper transition-transform duration-300 group-hover:-translate-y-0.5" />
|
||||||
|
</button>
|
||||||
|
<p>{footer.copyright}</p>
|
||||||
|
<p className="text-[10px] text-ivory/40">{footer.tagline}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View: Multi-Column Layout */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<div className="relative w-fit">
|
||||||
|
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||||
|
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||||
|
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
|
||||||
|
</p>
|
||||||
|
{/* آیکونهای شبکههای اجتماعی */}
|
||||||
|
<div className="mt-8 flex items-center gap-3">
|
||||||
|
{footer.socials.map(({ label, href, icon }) => {
|
||||||
|
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={label}
|
||||||
|
href={href}
|
||||||
|
aria-label={label}
|
||||||
|
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h3 className="text-sm font-bold text-copper">
|
||||||
|
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-5 space-y-3">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<EditableItem key={item.id} resource="nav" item={item}>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to={href(item.href)}
|
||||||
|
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||||
|
>
|
||||||
|
<InlineEdit
|
||||||
|
value={item.label}
|
||||||
|
onSave={(v) => saveNav(item.id, { label: v })}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddItemButton resource="nav" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<h3 className="text-sm font-bold text-copper">
|
||||||
|
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-5 space-y-3">
|
||||||
|
{services.map((service) => (
|
||||||
|
<li key={service.id} className="text-sm font-light text-ivory/60">
|
||||||
|
{service.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<h3 className="text-sm font-bold text-copper">
|
||||||
|
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
||||||
|
<span className="leading-7">{brand.address}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
<li className="flex items-center gap-3">
|
||||||
</ul>
|
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||||
|
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
|
{brand.phone}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-3">
|
||||||
|
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
||||||
|
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
||||||
|
{brand.email}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-3">
|
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
||||||
<h3 className="text-sm font-bold text-copper">
|
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
||||||
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
|
<p>
|
||||||
</h3>
|
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
||||||
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
|
</p>
|
||||||
<li className="flex items-start gap-3">
|
|
||||||
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
|
<button
|
||||||
<span className="leading-7">{brand.address}</span>
|
type="button"
|
||||||
</li>
|
onClick={scrollToTop}
|
||||||
<li className="flex items-center gap-3">
|
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
||||||
<PhoneIcon className="h-4 w-4 shrink-0 text-copper" />
|
>
|
||||||
<a href={brand.phone_href} dir="ltr" className="transition-colors hover:text-copper">
|
<span>{t('backToTop')}</span>
|
||||||
{brand.phone}
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
||||||
</a>
|
<ArrowUpIcon className="h-3 w-3" />
|
||||||
</li>
|
</span>
|
||||||
<li className="flex items-center gap-3">
|
</button>
|
||||||
<MailIcon className="h-4 w-4 shrink-0 text-copper" />
|
|
||||||
<a href={`mailto:${brand.email}`} dir="ltr" className="transition-colors hover:text-copper">
|
<p>
|
||||||
{brand.email}
|
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
||||||
</a>
|
</p>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
|
|
||||||
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
|
|
||||||
<p>
|
|
||||||
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={scrollToTop}
|
|
||||||
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>{t('backToTop')}</span>
|
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
|
|
||||||
<ArrowUpIcon className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { LanguagesIcon } from 'lucide-react'
|
|
||||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||||
import { track } from '../analytics'
|
import { track } from '../analytics'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تنظیمات ثابت هر زبان.
|
||||||
|
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
|
||||||
|
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
|
||||||
|
*/
|
||||||
|
const LANGS = {
|
||||||
|
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
|
||||||
|
en: { label: 'English', sliderTranslate: 'translate-x-full' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type LangKey = keyof typeof LANGS
|
||||||
|
|
||||||
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||||
export function LangToggle({ className = '' }: { className?: string }) {
|
export function LangToggle({ className = '' }: { className?: string }) {
|
||||||
const { lang, t } = useLang()
|
const { lang, t } = useLang()
|
||||||
@@ -25,10 +36,61 @@ export function LangToggle({ className = '' }: { className?: string }) {
|
|||||||
onClick={switchLang}
|
onClick={switchLang}
|
||||||
aria-label={t('switchLangAria')}
|
aria-label={t('switchLangAria')}
|
||||||
title={t('switchLangAria')}
|
title={t('switchLangAria')}
|
||||||
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
|
dir="ltr"
|
||||||
|
className={[
|
||||||
|
// ── Capsule shell ──────────────────────────────────────────────
|
||||||
|
'relative flex items-center',
|
||||||
|
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
|
||||||
|
'bg-[#1a1715]',
|
||||||
|
// deep inset shadow → recessed track feel
|
||||||
|
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
|
||||||
|
// interaction
|
||||||
|
'cursor-pointer select-none',
|
||||||
|
'active:scale-[0.975] transition-transform duration-150 ease-lux',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
|
{/* ── Inner Track ───────────────────────────────────────────────
|
||||||
<span>{t('switchLang')}</span>
|
این دیو داخلی باعث میشود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
|
||||||
|
*/}
|
||||||
|
<div className="relative w-full h-full flex items-center">
|
||||||
|
|
||||||
|
{/* ── Slider pill ─────────────────────────────────────────────── */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={[
|
||||||
|
'absolute top-0 left-0', // چسبیده به لبههای Track داخلی
|
||||||
|
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
|
||||||
|
'bg-[#27221c] border border-[#d5874c]',
|
||||||
|
// subtle raised shadow — tactile without being flashy
|
||||||
|
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
|
||||||
|
'transition-transform duration-[380ms] ease-lux',
|
||||||
|
LANGS[lang as LangKey].sliderTranslate,
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Labels ──────────────────────────────────────────────────── */}
|
||||||
|
<span className="relative z-10 flex w-full h-full">
|
||||||
|
{(Object.keys(LANGS) as LangKey[]).map((key) => {
|
||||||
|
const isActive = lang === key
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
className={[
|
||||||
|
// each label occupies exactly half the capsule
|
||||||
|
'w-1/2 h-full flex justify-center items-center',
|
||||||
|
'text-[11.5px] font-semibold tracking-wide leading-none',
|
||||||
|
'transition-colors duration-[380ms] ease-lux',
|
||||||
|
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{LANGS[key].label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect } from 'react'
|
import React, { useCallback, useEffect } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
images: string[]
|
images: string[]
|
||||||
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
|||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
if (e.key === 'ArrowRight') prev()
|
if (e.key === 'ArrowRight') next()
|
||||||
if (e.key === 'ArrowLeft') next()
|
if (e.key === 'ArrowLeft') prev()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
|||||||
<motion.div
|
<motion.div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
|
className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
>
|
>
|
||||||
|
{/* Solid Brand Background */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
|
className="absolute inset-0 bg-[#27221c]"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Close Button at Top Right */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
|
className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||||
>
|
>
|
||||||
<XIcon className="h-5 w-5" />
|
<MdClose size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<motion.img
|
{/* Main Content Area */}
|
||||||
key={index}
|
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
|
||||||
src={images[index as number]}
|
|
||||||
alt=""
|
{/* Prev Button */}
|
||||||
className="relative max-h-[80vh] w-auto max-w-full object-contain"
|
|
||||||
initial={{ opacity: 0, scale: 0.96 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.96 }}
|
|
||||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={prev}
|
onClick={prev}
|
||||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="h-5 w-5" />
|
<MdChevronRight size={28} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm font-light text-ivory/70" dir="ltr">
|
|
||||||
{(index as number) + 1} / {images.length}
|
{/* Image */}
|
||||||
</span>
|
<motion.img
|
||||||
|
key={index}
|
||||||
|
src={images[index as number]}
|
||||||
|
alt=""
|
||||||
|
className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Next Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={next}
|
onClick={next}
|
||||||
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-5 w-5" />
|
<MdChevronLeft size={28} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-6 z-10 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-light text-copper" dir="ltr">
|
||||||
|
{(index as number) + 1} / {images.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
to?: string
|
||||||
|
href?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
type?: 'button' | 'submit' | 'reset'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Premium Button replicating the cinematic, high-end documentary aesthetic.
|
||||||
|
* Features an exact cubic-bezier background fill from the bottom, transforming
|
||||||
|
* text color smoothly to maintain contrast.
|
||||||
|
*/
|
||||||
|
export function PremiumButton({ to, href, children, className = '', onClick, disabled, type = 'button' }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Primary: ink, Secondary: copper
|
||||||
|
const baseClasses = `
|
||||||
|
group/btn relative inline-flex items-center justify-center overflow-hidden
|
||||||
|
rounded-full px-10 py-4 text-sm font-bold tracking-widest text-copper
|
||||||
|
border border-copper bg-ink
|
||||||
|
transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg
|
||||||
|
cursor-pointer select-none
|
||||||
|
${className}
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
const innerEffect = (
|
||||||
|
<>
|
||||||
|
<span className="absolute inset-0 z-0 bg-copper scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
||||||
|
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (onClick) onClick();
|
||||||
|
if (to) {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a href={href} className={baseClasses} onClick={handleClick}>
|
||||||
|
{innerEffect}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type={type} disabled={disabled} className={baseClasses} onClick={handleClick}>
|
||||||
|
{innerEffect}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,60 +1,89 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { useStatusLabel } from '../api'
|
import { useStatusLabel } from '../api'
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
|
import { PremiumButton } from './PremiumButton'
|
||||||
|
import type { StaticProject } from '../data/projectsData'
|
||||||
|
import type { ProjectCardItem } from '../data/site'
|
||||||
|
|
||||||
export function ProjectCard({ project }: any) {
|
type ProjectCardProps = {
|
||||||
|
project: StaticProject | ProjectCardItem;
|
||||||
|
imageUrl?: string;
|
||||||
|
detailHref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps) {
|
||||||
const isOngoing = project.status === 'progress'
|
const isOngoing = project.status === 'progress'
|
||||||
const statusText = useStatusLabel(project.status)
|
const statusText = useStatusLabel(project.status)
|
||||||
const { t, href, lang } = useLang()
|
const { t, lang } = useLang()
|
||||||
|
|
||||||
|
// Type guards for backward compatibility
|
||||||
|
const isStatic = 'name' in project;
|
||||||
|
|
||||||
|
// Resolve localized texts
|
||||||
|
const title = isStatic
|
||||||
|
? (project.name[lang as 'en' | 'fa'] || project.name.en)
|
||||||
|
: (project as ProjectCardItem).title;
|
||||||
|
|
||||||
|
const locRaw = isStatic ? project.location : (project as ProjectCardItem).location;
|
||||||
|
const location = typeof locRaw === 'string' ? locRaw : (locRaw?.[lang as 'en' | 'fa'] || locRaw?.en);
|
||||||
|
|
||||||
|
const areaRaw = isStatic ? project.area : (project as ProjectCardItem).area;
|
||||||
|
const area = typeof areaRaw === 'string' ? areaRaw : (areaRaw?.[lang as 'en' | 'fa'] || areaRaw?.en);
|
||||||
|
|
||||||
|
const finalImageUrl = imageUrl || (project as ProjectCardItem).image;
|
||||||
|
const finalDetailHref = detailHref || (project as ProjectCardItem).detailHref || '#';
|
||||||
|
|
||||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
|
||||||
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none">
|
<Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
|
||||||
|
{/* Fixed height image wrapper */}
|
||||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
<div className="relative w-full overflow-hidden">
|
||||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
{finalImageUrl && (
|
||||||
<img
|
<img
|
||||||
src={project.image}
|
src={finalImageUrl}
|
||||||
alt={project.title}
|
alt={title}
|
||||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
className="w-full h-44 md: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-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 flex items-center gap-1.5 md:gap-2 rounded-full border border-white/15 bg-ink/75 px-3 py-1 md:px-4 md:py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
|
||||||
|
>
|
||||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||||
{statusText}
|
{statusText}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
|
||||||
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
|
|
||||||
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-7">
|
<div className="flex flex-1 flex-col p-4 pb-3 md:p-7 md:pb-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
<h3 className="font-pinar text-lg font-bold text-ivory transition-colors duration-300 group-hover:text-copper md:text-xl">
|
||||||
{project.title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
{location && <p className="mt-2 text-xs font-light text-ivory/60 md:mt-4 md:text-sm">{location}</p>}
|
||||||
{project.location}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-6 h-px w-full bg-white/10" />
|
{area && (
|
||||||
|
<>
|
||||||
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
|
<div className="my-3 h-px w-full bg-white/10 md:my-6" />
|
||||||
<span>{t('yearLabel')} {project.year}</span>
|
<div className="mt-auto flex items-center justify-end text-xs font-light text-ivory/60 md:text-sm">
|
||||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||||
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
|
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* پایهٔ کارت: یک دکمه واحد */}
|
||||||
|
<div className="flex flex-col gap-2 px-4 pb-4 md:px-7 md:pb-7">
|
||||||
|
<PremiumButton to={finalDetailHref} className="w-full">
|
||||||
|
{t('viewProject')}
|
||||||
|
</PremiumButton>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { MdApartment } from 'react-icons/md'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جاینگهدار تصویر پروژه — همان زبان بصری کارت پاپآپ، برای هر پروژهای که
|
||||||
|
* تصویری در داده ندارد (مثل ردیفهای شیت). تکمنبع این ظاهر همینجاست.
|
||||||
|
*/
|
||||||
|
export function ProjectImagePlaceholder({ className = '' }: { className?: string }) {
|
||||||
|
const { t } = useLang()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-char to-ink text-ivory/25 ${className}`}
|
||||||
|
>
|
||||||
|
<MdApartment className="h-8 w-8" aria-hidden="true" />
|
||||||
|
<span className="text-[10px] font-light">{t('mapImagePlaceholder')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -102,6 +102,42 @@ const CLOSED_LINK_MODAL: LinkModalState = {
|
|||||||
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپشده بررسی میشود */
|
||||||
const URL_LOOKBACK = 300
|
const URL_LOOKBACK = 300
|
||||||
|
|
||||||
|
/** آیا src تصویر خارج از میزبانی سایت است (مثل چسباندهشده از Outline)؟ */
|
||||||
|
function isExternalImageSrc(src: string): boolean {
|
||||||
|
if (!src || src.startsWith('data:')) return false
|
||||||
|
if (src.startsWith('/')) return false // مسیر نسبی/داخلی
|
||||||
|
try {
|
||||||
|
return new URL(src).origin !== window.location.origin
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* میزبانی مجدد تصاویر خارجی یک HTML — هر <img> با نشانی خارجی از سرور دانلود و در
|
||||||
|
* Object Storage خود سایت ذخیره و src آن بازنویسی میشود (چسباندن از Outline و مانند آن).
|
||||||
|
* تصویری که درونریزیاش شکست بخورد با src اصلی میماند.
|
||||||
|
*/
|
||||||
|
async function rehostExternalImages(html: string): Promise<string> {
|
||||||
|
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const imgs = Array.from(parsed.querySelectorAll('img[src]'))
|
||||||
|
const externals = imgs.filter((img) => isExternalImageSrc(img.getAttribute('src') ?? ''))
|
||||||
|
if (!externals.length) return html
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
externals.map(async (img) => {
|
||||||
|
const original = img.getAttribute('src') ?? ''
|
||||||
|
try {
|
||||||
|
const { url } = await adminApi.importImage(original)
|
||||||
|
img.setAttribute('src', url)
|
||||||
|
} catch {
|
||||||
|
// نشانی خارجی میماند — شاید خودش کار کند
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return parsed.body.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
function ToolButton({
|
function ToolButton({
|
||||||
onClick,
|
onClick,
|
||||||
active = false,
|
active = false,
|
||||||
@@ -144,6 +180,8 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [direction, setDirection] = useState<TextDirection>('rtl')
|
const [direction, setDirection] = useState<TextDirection>('rtl')
|
||||||
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
|
||||||
|
// مرحله نامگذاری درج تصویر از نوار ابزار — پیشفرض از نام خود فایل
|
||||||
|
const [pendingImage, setPendingImage] = useState<{ file: File; name: string } | null>(null)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: ARTICLE_EXTENSIONS,
|
extensions: ARTICLE_EXTENSIONS,
|
||||||
@@ -163,6 +201,22 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// محتوای HTML با تصویر خارجی (Outline و مانند آن) — تصاویر به میزبانی سایت منتقل میشوند
|
||||||
|
const html = event.clipboardData?.getData('text/html') ?? ''
|
||||||
|
if (html.includes('<img')) {
|
||||||
|
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
|
||||||
|
isExternalImageSrc(img.getAttribute('src') ?? ''),
|
||||||
|
)
|
||||||
|
if (hasExternal) {
|
||||||
|
event.preventDefault()
|
||||||
|
void (async () => {
|
||||||
|
const transformed = await rehostExternalImages(html)
|
||||||
|
editor?.chain().focus().insertContent(transformed).run()
|
||||||
|
})()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
// چسباندن یک نشانی → مودال لینک بلافاصله باز میشود
|
||||||
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
|
||||||
if (isUrlLike(text)) {
|
if (isUrlLike(text)) {
|
||||||
@@ -186,6 +240,21 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
void uploadAndInsert(file)
|
void uploadAndInsert(file)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
const html = event.dataTransfer?.getData('text/html') ?? ''
|
||||||
|
if (html.includes('<img')) {
|
||||||
|
const parsed = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
const hasExternal = Array.from(parsed.querySelectorAll('img[src]')).some((img) =>
|
||||||
|
isExternalImageSrc(img.getAttribute('src') ?? ''),
|
||||||
|
)
|
||||||
|
if (hasExternal) {
|
||||||
|
event.preventDefault()
|
||||||
|
void (async () => {
|
||||||
|
const transformed = await rehostExternalImages(html)
|
||||||
|
editor?.chain().focus().insertContent(transformed).run()
|
||||||
|
})()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز میشود
|
||||||
@@ -237,11 +306,11 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
editor.commands.setContent(incoming, { emitUpdate: false })
|
editor.commands.setContent(incoming, { emitUpdate: false })
|
||||||
}, [editor, contentKey, doc])
|
}, [editor, contentKey, doc])
|
||||||
|
|
||||||
async function uploadAndInsert(file: File) {
|
async function uploadAndInsert(file: File, name?: string) {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file)
|
const { url } = await adminApi.upload(file, name)
|
||||||
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
// نام پیشفرض از نام فایل ساخته میشود و در نبود آن، مقدار پیشفرض ثابت میماند
|
||||||
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
|
||||||
editor
|
editor
|
||||||
@@ -411,12 +480,72 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (file) void uploadAndInsert(file)
|
if (file) {
|
||||||
|
setPendingImage({ file, name: file.name.replace(/\.[^.]+$/, '') })
|
||||||
|
}
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* مرحله نامگذاری تصویر — نام خوانای ذخیرهسازی با یک کلیک تایید میشود */}
|
||||||
|
{pendingImage && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="نام تصویر"
|
||||||
|
onClick={() => setPendingImage(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="w-full max-w-sm border border-ivory/15 bg-ink p-6 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-bold text-copper">درج تصویر</h2>
|
||||||
|
<label htmlFor="image-name-input" className="mt-4 block text-xs font-light text-ivory/60">
|
||||||
|
نام فایل تصویر (برای نشانی ذخیرهسازی — اختیاری)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="image-name-input"
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
autoFocus
|
||||||
|
value={pendingImage.name}
|
||||||
|
onChange={(e) => setPendingImage({ ...pendingImage, name: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const { file, name } = pendingImage
|
||||||
|
setPendingImage(null)
|
||||||
|
void uploadAndInsert(file, name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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 className="mt-5 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingImage(null)}
|
||||||
|
className="border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const { file, name } = pendingImage
|
||||||
|
setPendingImage(null)
|
||||||
|
void uploadAndInsert(file, name)
|
||||||
|
}}
|
||||||
|
className="bg-copper px-6 py-2 text-xs font-bold text-char transition-transform hover:scale-105"
|
||||||
|
>
|
||||||
|
درج تصویر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاسهای Tailwind مدیریت میکند */}
|
||||||
{linkModal.open && (
|
{linkModal.open && (
|
||||||
<LinkModal
|
<LinkModal
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export function ImageField({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
const altId = useId()
|
const altId = useId()
|
||||||
|
const nameId = useId()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
/** نام دلخواه فایل برای ذخیرهسازی خوانا — پیشفرض از نام فایل انتخابی ساخته میشود */
|
||||||
|
const [fileBaseName, setFileBaseName] = useState('')
|
||||||
|
|
||||||
const showTitle = title !== undefined || !!onSaveTitle
|
const showTitle = title !== undefined || !!onSaveTitle
|
||||||
const showAlt = alt !== undefined || !!onSaveAlt
|
const showAlt = alt !== undefined || !!onSaveAlt
|
||||||
@@ -73,13 +76,16 @@ export function ImageField({
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
await runSave(async () => {
|
await runSave(async () => {
|
||||||
const { url } = await adminApi.upload(file)
|
// نام ذخیرهسازی: نام تایپشده کاربر، وگرنه نام خود فایل بدون پسوند
|
||||||
|
const name = fileBaseName.trim() || file.name.replace(/\.[^.]+$/, '')
|
||||||
|
const { url } = await adminApi.upload(file, name)
|
||||||
const ok = await onSave(url)
|
const ok = await onSave(url)
|
||||||
if (!ok) throw new Error('ذخیره نشد')
|
if (!ok) throw new Error('ذخیره نشد')
|
||||||
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
// پیشفرضهای Alt/Title همان لحظه ثبت میشوند تا تصویر بدون متادیتا نماند
|
||||||
if (showTitle) await titleField.commit()
|
if (showTitle) await titleField.commit()
|
||||||
if (showAlt) await altField.commit()
|
if (showAlt) await altField.commit()
|
||||||
})
|
})
|
||||||
|
setFileBaseName('')
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
@@ -106,7 +112,7 @@ export function ImageField({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
@@ -120,6 +126,22 @@ export function ImageField({
|
|||||||
)}
|
)}
|
||||||
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* نام ذخیرهسازی فایل — خالی یعنی از نام خود فایل استفاده شود */}
|
||||||
|
<div className="w-56">
|
||||||
|
<label htmlFor={nameId} className="text-[11px] font-light text-ivory/50">
|
||||||
|
نام فایل تصویر (اختیاری)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={nameId}
|
||||||
|
type="text"
|
||||||
|
dir="auto"
|
||||||
|
value={fileBaseName}
|
||||||
|
onChange={(event) => setFileBaseName(event.target.value)}
|
||||||
|
placeholder="مثلاً: elahieh-tower"
|
||||||
|
className={META_FIELD_CLASS.replace('mt-1.5 ', '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(showTitle || showAlt) && (
|
{(showTitle || showAlt) && (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { NodeViewWrapper } from '@tiptap/react'
|
import { NodeViewWrapper } from '@tiptap/react'
|
||||||
import type { NodeViewProps } from '@tiptap/react'
|
import type { NodeViewProps } from '@tiptap/react'
|
||||||
import { PlusIcon, XIcon } from 'lucide-react'
|
import { CloudUploadIcon, PlusIcon, XIcon } from 'lucide-react'
|
||||||
|
import { adminApi } from '../../admin/adminClient'
|
||||||
|
import { useAdmin } from '../../admin/AdminContext'
|
||||||
|
|
||||||
type ImageAttrs = {
|
type ImageAttrs = {
|
||||||
src: string
|
src: string
|
||||||
@@ -19,25 +21,39 @@ const INLINE_FIELD_CLASS =
|
|||||||
const ADD_CAPTION_CLASS =
|
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'
|
'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'
|
||||||
|
|
||||||
|
/** تصاویر میزبان خارجی (مثل چسباندهشده از Outline) قابل میزبانی مجدد هستند */
|
||||||
|
function isExternalImage(src: string): boolean {
|
||||||
|
if (!src) return false
|
||||||
|
if (src.startsWith('/media/') || src.startsWith('/')) return false
|
||||||
|
try {
|
||||||
|
return new URL(src, window.location.origin).origin !== window.location.origin
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
|
||||||
* <figure><img><figcaption>کپشن</figcaption></figure>
|
* <figure><img><figcaption>کپشن</figcaption></figure>
|
||||||
*
|
*
|
||||||
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
* ویرایش کاملاً درونخطی است (بدون پاپاور/مودال):
|
||||||
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره میشود.
|
||||||
* - دکمه X کپشن را کامل حذف میکند: صفت کپشن به مقدار پیشفرض برمیگردد و عنصر
|
* - دکمه X کپشن را کامل حذف میکند؛ «افزودن کپشن» دوباره جعبه را باز میکند.
|
||||||
* figcaption با ورودیاش از DOM ویرایشگر (و از خروجی ذخیرهشده) حذف میشود؛
|
* - Alt و Title تصویر (نام تصویر برای سئو/دسترسپذیری) پس از درج هم قابل ویرایشاند.
|
||||||
* فقط تصویر میماند و «افزودن کپشن» دوباره جعبه را باز میکند.
|
* - تصویر میزبان خارجی دکمه «میزبانی مجدد» دارد: تصویر دانلود و در Object Storage
|
||||||
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت میشود.
|
* خود سایت ذخیره میشود (ترمیم تصاویر چسباندهشده از Outline).
|
||||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||||
*/
|
*/
|
||||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||||
|
const { runSave } = useAdmin()
|
||||||
const hasCaption = Boolean((caption ?? '').trim())
|
const hasCaption = Boolean((caption ?? '').trim())
|
||||||
const [captionOpen, setCaptionOpen] = useState(true)
|
const [captionOpen, setCaptionOpen] = useState(true)
|
||||||
|
const [rehosting, setRehosting] = useState(false)
|
||||||
const captionInputRef = useRef<HTMLInputElement>(null)
|
const captionInputRef = useRef<HTMLInputElement>(null)
|
||||||
const focusOnOpen = useRef(false)
|
const focusOnOpen = useRef(false)
|
||||||
const showCaption = captionOpen || hasCaption
|
const showCaption = captionOpen || hasCaption
|
||||||
|
const external = isExternalImage(src)
|
||||||
|
|
||||||
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
// فقط وقتی کاربر «افزودن کپشن» را میزند ورودی فوکوس میگیرد (نه هنگام بارگذاری مقاله)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,6 +73,16 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
|||||||
setCaptionOpen(true)
|
setCaptionOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** میزبانی مجدد تصویر خارجی — دانلود از منبع و ذخیره در Object Storage سایت */
|
||||||
|
const rehost = () => {
|
||||||
|
if (rehosting) return
|
||||||
|
setRehosting(true)
|
||||||
|
void runSave(async () => {
|
||||||
|
const { url } = await adminApi.importImage(src)
|
||||||
|
updateAttributes({ src: url })
|
||||||
|
}).finally(() => setRehosting(false))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
|
||||||
@@ -77,6 +103,42 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
|||||||
<XIcon className="h-3.5 w-3.5" />
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* تصویر خارجی (مثل Outline) — منتقل به میزبانی سایت */}
|
||||||
|
{external && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={rehost}
|
||||||
|
disabled={rehosting}
|
||||||
|
aria-label="میزبانی مجدد تصویر"
|
||||||
|
title="تصویر از منبع خارجی دانلود و در سایت ذخیره میشود"
|
||||||
|
className="absolute right-2 top-2 z-10 flex items-center gap-1.5 border border-copper/60 bg-ink/85 px-2.5 py-1 text-[11px] font-light text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<CloudUploadIcon className={`h-3.5 w-3.5 ${rehosting ? 'animate-pulse' : ''}`} />
|
||||||
|
{rehosting ? 'در حال انتقال…' : 'میزبانی مجدد'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* نام تصویر — Alt و Title پس از درج هم قابل ویرایشاند */}
|
||||||
|
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={alt ?? ''}
|
||||||
|
onChange={(event) => updateAttributes({ alt: event.target.value })}
|
||||||
|
placeholder="نام تصویر / متن جایگزین (Alt)…"
|
||||||
|
aria-label="متن جایگزین تصویر"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title ?? ''}
|
||||||
|
onChange={(event) => updateAttributes({ title: event.target.value })}
|
||||||
|
placeholder="عنوان تصویر (Title)…"
|
||||||
|
aria-label="عنوان تصویر"
|
||||||
|
className={INLINE_FIELD_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showCaption ? (
|
{showCaption ? (
|
||||||
<figcaption className="article-figure__caption" contentEditable={false}>
|
<figcaption className="article-figure__caption" contentEditable={false}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowLeftIcon } from 'lucide-react';
|
|
||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { ButtonLink } from '../Button';
|
import { PremiumButton } from '../PremiumButton';
|
||||||
import { ArticleCard } from '../ArticleCard';
|
import { ArticleCard } from '../ArticleCard';
|
||||||
import { useSectionHeading, useSite } from '../../api';
|
import { useSectionHeading, useSite } from '../../api';
|
||||||
import { useLang } from '../../i18n/lang';
|
import { useLang } from '../../i18n/lang';
|
||||||
import { useFieldSaver } from '../../admin/AdminContext';
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
import { InlineEdit } from '../admin/InlineEdit';
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
|
import { formatJalali } from '../../utils/date';
|
||||||
|
|
||||||
export function Articles() {
|
export function Articles() {
|
||||||
const { articles } = useSite();
|
const { articles } = useSite();
|
||||||
@@ -17,6 +17,9 @@ export function Articles() {
|
|||||||
|
|
||||||
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
// پیشنویسها فقط در صفحه وبلاگ (برای ادمین) دیده میشوند، نه در خانه
|
||||||
const published = articles.filter((a) => a.status !== 'draft');
|
const published = articles.filter((a) => a.status !== 'draft');
|
||||||
|
const topArticles = published.slice(0, 3);
|
||||||
|
const spotlight = topArticles[0];
|
||||||
|
const compactList = topArticles.slice(1, 3);
|
||||||
|
|
||||||
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
|
||||||
save('section-headings', heading.id, { [field]: value }, (d) => {
|
save('section-headings', heading.id, { [field]: value }, (d) => {
|
||||||
@@ -25,24 +28,97 @@ export function Articles() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
|
<section className="w-full max-w-full overflow-x-clip bg-char py-16 md:py-24 lg:py-32" aria-labelledby="articles-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
||||||
<p className="text-sm font-light text-copper">
|
<p className="text-sm font-light text-copper">
|
||||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||||
</p>
|
</p>
|
||||||
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
|
<h2 id="articles-title" className="mt-3 md:mt-5 text-section font-bold text-ivory">
|
||||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
<p className="mt-3 md:mt-5 text-sm md:text-base font-light leading-7 md:leading-9 text-ivory/60">
|
||||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-4 md:mt-6 h-px w-24 bg-copper/70" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-6 lg:grid-cols-3">
|
{/* Mobile View: Magazine Editorial layout (1 Featured Spotlight + Compact Rows) */}
|
||||||
{published.slice(0, 3).map((article, i) =>
|
<div className="block md:hidden mt-8">
|
||||||
<Reveal key={article.id} delay={0.06 * i} className="h-full">
|
{/* First Article (Spotlight) */}
|
||||||
|
{spotlight && (
|
||||||
|
<EditableItem resource="articles" item={spotlight} editHref={`/admin/write/${spotlight.id}`}>
|
||||||
|
<Link
|
||||||
|
className="block rounded-xl border border-ivory/10 bg-char/40 overflow-hidden mb-3 group"
|
||||||
|
to={href(`/blog/${spotlight.slug}`)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={spotlight.image}
|
||||||
|
alt={spotlight.title}
|
||||||
|
className="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="p-3.5">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-copper font-medium mb-1">
|
||||||
|
<span>{spotlight.category}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatJalali(spotlight.date)}</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-bold text-ivory leading-snug line-clamp-2 mb-1 group-hover:text-copper transition-colors">
|
||||||
|
{spotlight.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-ivory/60 line-clamp-2 leading-relaxed">
|
||||||
|
{spotlight.excerpt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</EditableItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remaining Articles (Compact List Rows) */}
|
||||||
|
{compactList.length > 0 && (
|
||||||
|
<div className="divide-y divide-ivory/15 border-t border-b border-ivory/15 mb-4">
|
||||||
|
{compactList.map((article) => (
|
||||||
|
<EditableItem key={article.id} resource="articles" item={article} editHref={`/admin/write/${article.id}`}>
|
||||||
|
<Link
|
||||||
|
className="py-2.5 flex items-center gap-3 group"
|
||||||
|
to={href(`/blog/${article.slug}`)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={article.image}
|
||||||
|
alt={article.title}
|
||||||
|
className="w-14 h-14 rounded-lg object-cover shrink-0 border border-ivory/10"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-[10px] text-copper block mb-0.5">{formatJalali(article.date)}</span>
|
||||||
|
<h5 className="text-xs font-semibold text-ivory line-clamp-2 leading-snug group-hover:text-copper transition-colors">
|
||||||
|
{article.title}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom Button ("همه مقالات") */}
|
||||||
|
{heading.cta_label && heading.cta_href && (
|
||||||
|
<Link
|
||||||
|
to={href(heading.cta_href)}
|
||||||
|
className="w-full py-2.5 rounded-xl border border-ivory/20 bg-char/60 hover:bg-copper/10 hover:border-copper/40 text-xs font-semibold text-ivory text-center block transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View: 3-Column Grid */}
|
||||||
|
<div className="hidden md:grid mt-14 grid-cols-3 gap-6">
|
||||||
|
{topArticles.map((article, i) =>
|
||||||
|
<Reveal
|
||||||
|
key={article.id}
|
||||||
|
delay={0.06 * i}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
|
||||||
<ArticleCard article={article} />
|
<ArticleCard article={article} />
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
@@ -52,11 +128,10 @@ export function Articles() {
|
|||||||
|
|
||||||
{heading.cta_label && heading.cta_href && (
|
{heading.cta_label && heading.cta_href && (
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<div className="mt-12 hidden md:flex justify-center">
|
||||||
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
|
<PremiumButton to={href(heading.cta_href)} className="font-light">
|
||||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
</PremiumButton>
|
||||||
</ButtonLink>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
@@ -64,6 +139,6 @@ export function Articles() {
|
|||||||
<AddItemButton resource="articles" />
|
<AddItemButton resource="articles" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -18,20 +18,22 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||||
|
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
|
||||||
|
|
||||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
{/* Header — centered, full width */}
|
||||||
|
|
||||||
{!hideHeading && (
|
{!hideHeading && (
|
||||||
<div className="lg:col-span-4">
|
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackFaqTitle')}
|
title={t('fallbackFaqTitle')}
|
||||||
lead={t('fallbackFaqLead')}
|
lead={t('fallbackFaqLead')}
|
||||||
headingKey="faq"
|
headingKey="faq"
|
||||||
|
align="center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
{/* Accordion list — constrained width, direction-aware */}
|
||||||
|
<div className="w-full max-w-3xl mx-auto">
|
||||||
<ul>
|
<ul>
|
||||||
{faqs.map((faq, i) => {
|
{faqs.map((faq, i) => {
|
||||||
const isOpen = open === i
|
const isOpen = open === i
|
||||||
@@ -45,7 +47,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||||
>
|
>
|
||||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||||
{faq.question}
|
{faq.question}
|
||||||
</span>
|
</span>
|
||||||
<PlusIcon
|
<PlusIcon
|
||||||
@@ -63,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
|||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ButtonLink } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { ProjectCard } from '../ProjectCard'
|
import { ProjectCard } from '../ProjectCard'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { InlineEdit } from '../admin/InlineEdit'
|
import { InlineEdit } from '../admin/InlineEdit'
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
|
||||||
import type { ProjectStatus } from '../../data/site'
|
import type { ProjectStatus } from '../../data/site'
|
||||||
|
import { projectsData } from '../../data/projectsData'
|
||||||
|
|
||||||
export function FeaturedProjects() {
|
export function FeaturedProjects() {
|
||||||
const { projects, status_labels } = useSite()
|
const { status_labels } = useSite()
|
||||||
const { t, href } = useLang()
|
const { t, href } = useLang()
|
||||||
const heading = useSectionHeading('featured_projects')
|
const heading = useSectionHeading('featured_projects')
|
||||||
const save = useFieldSaver()
|
const save = useFieldSaver()
|
||||||
|
|
||||||
const saveStatusLabel = (key: string, value: string) => {
|
const saveStatusLabel = (key: string, value: string) => {
|
||||||
const label = status_labels.find((s) => s.key === key)
|
const label = status_labels.find((s) => s.key === key)
|
||||||
if (!label) return
|
if (!label) return
|
||||||
@@ -30,23 +30,26 @@ export function FeaturedProjects() {
|
|||||||
{ key: 'done', label: useStatusLabel('done') },
|
{ key: 'done', label: useStatusLabel('done') },
|
||||||
{ key: 'progress', label: useStatusLabel('progress') },
|
{ key: 'progress', label: useStatusLabel('progress') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
|
||||||
|
// Filter and slice static project data
|
||||||
|
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
|
||||||
|
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
<section className="w-full max-w-full overflow-x-clip bg-ink py-16 md:py-24 lg:py-32" aria-labelledby="projects-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
|
||||||
|
|
||||||
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
{/* دکمههای فیلتر مینیمال و شیشهای (بدون کادر) */}
|
||||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
<div className="mt-8 flex flex-wrap items-center justify-center gap-3 md:mt-10 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||||
{FILTERS.map((filter, index) => {
|
{FILTERS.map((filter, index) => {
|
||||||
const isSelected = status === filter.key
|
const isSelected = status === filter.key
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={filter.key}>
|
<React.Fragment key={filter.key}>
|
||||||
{/* خط جداکننده بین دکمهها */}
|
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
<span className="hidden h-6 w-[2px] rounded-full bg-ivory/10 sm:inline-block" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -54,7 +57,7 @@ export function FeaturedProjects() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
onClick={() => setStatus(filter.key)}
|
onClick={() => setStatus(filter.key)}
|
||||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
className={`group relative rounded-lg px-4 py-2 text-xs font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md md:px-6 md:py-2.5 md:text-sm ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||||
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
|
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
|
||||||
@@ -72,7 +75,7 @@ export function FeaturedProjects() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 min-h-[420px]">
|
<div className="mt-8 min-h-[380px] md:mt-10 md:min-h-[420px]">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={status}
|
key={status}
|
||||||
@@ -80,21 +83,28 @@ export function FeaturedProjects() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -15 }}
|
exit={{ opacity: 0, y: -15 }}
|
||||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="grid gap-6 lg:grid-cols-3"
|
className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] md:grid md:grid-cols-3 md:gap-6 md:overflow-visible md:pb-0"
|
||||||
>
|
>
|
||||||
{list.map((project, i) => (
|
{list.map((project, i) => {
|
||||||
<motion.div
|
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||||
key={project.id}
|
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
return (
|
||||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
<motion.div
|
||||||
className="h-full"
|
key={project.id}
|
||||||
>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<EditableItem resource="projects" item={project} className="h-full">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<ProjectCard project={project} />
|
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||||
</EditableItem>
|
className="h-full w-[82vw] max-w-[320px] shrink-0 snap-center md:w-auto md:max-w-none md:shrink"
|
||||||
</motion.div>
|
>
|
||||||
))}
|
<ProjectCard
|
||||||
|
project={project}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
detailHref={`/projects/${project.slug}`}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,12 +112,8 @@ export function FeaturedProjects() {
|
|||||||
{/* دکمه مشاهده همه پروژهها */}
|
{/* دکمه مشاهده همه پروژهها */}
|
||||||
{heading.cta_label && heading.cta_href && (
|
{heading.cta_label && heading.cta_href && (
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<div className="mt-12 flex justify-center">
|
<div className="mt-10 flex justify-center md:mt-12">
|
||||||
<ButtonLink
|
<PremiumButton to={href(heading.cta_href)}>
|
||||||
to={href(heading.cta_href)}
|
|
||||||
variant="outline"
|
|
||||||
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
|
|
||||||
>
|
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={heading.cta_label}
|
value={heading.cta_label}
|
||||||
onSave={(v) =>
|
onSave={(v) =>
|
||||||
@@ -117,10 +123,7 @@ export function FeaturedProjects() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char">
|
</PremiumButton>
|
||||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" />
|
|
||||||
</span>
|
|
||||||
</ButtonLink>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function Gallery() {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{active !== null && gallery[active] && (
|
{active !== null && gallery[active] && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
@@ -74,49 +74,71 @@ export function Gallery() {
|
|||||||
aria-label={t('viewImage')}
|
aria-label={t('viewImage')}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
>
|
>
|
||||||
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||||
aria-label={t('close')}
|
aria-label={t('close')}
|
||||||
>
|
>
|
||||||
<XIcon className="h-5 w-5" />
|
<XIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{/* Flex Container for Prev, Image, Next */}
|
||||||
type="button"
|
<div
|
||||||
onClick={(e) => {
|
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
|
||||||
e.stopPropagation()
|
|
||||||
step(-1)
|
|
||||||
}}
|
|
||||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10"
|
|
||||||
aria-label={t('prevImage')}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
step(1)
|
|
||||||
}}
|
|
||||||
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10"
|
|
||||||
aria-label={t('nextImage')}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<motion.img
|
|
||||||
key={active}
|
|
||||||
src={gallery[active].src}
|
|
||||||
alt=""
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||||
|
aria-label={t('prevImage')}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<motion.img
|
||||||
|
key={active}
|
||||||
|
src={gallery[active].src}
|
||||||
|
alt={gallery[active].alt}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||||
|
aria-label={t('nextImage')}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modern Dot Pagination */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{gallery.map((_, idx) => {
|
||||||
|
const isActive = idx === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(idx)}
|
||||||
|
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
|
||||||
|
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
|
||||||
|
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
import { ChevronDownIcon } from 'lucide-react'
|
||||||
import { ButtonLink } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
@@ -84,45 +84,20 @@ export function Hero() {
|
|||||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="mt-10 flex flex-wrap items-center gap-4"
|
className="mt-10 flex flex-wrap items-center gap-4"
|
||||||
>
|
>
|
||||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
{/* دکمه اصلی */}
|
||||||
<ButtonLink
|
<PremiumButton to={href(hero.cta_primary_href)}>
|
||||||
to={href(hero.cta_primary_href)}
|
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||||
variant="copper"
|
</PremiumButton>
|
||||||
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium tracking-wide text-ivory">
|
|
||||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
|
||||||
</span>
|
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
|
|
||||||
</span>
|
|
||||||
</ButtonLink>
|
|
||||||
|
|
||||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
{/* دکمه ثانویه */}
|
||||||
<ButtonLink
|
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
|
||||||
to={href(hero.cta_secondary_href)}
|
|
||||||
variant="outline"
|
|
||||||
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
|
|
||||||
>
|
|
||||||
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||||
</ButtonLink>
|
</PremiumButton>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -24 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
|
||||||
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10"
|
|
||||||
>
|
|
||||||
<p className="text-3xl font-bold text-copper font-mono">
|
|
||||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
|
||||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { CheckIcon, SendIcon } from 'lucide-react'
|
import { CheckIcon } from 'lucide-react'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { Button } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
@@ -101,7 +101,7 @@ export function Newsletter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||||
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||||
|
|
||||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||||
@@ -156,19 +156,18 @@ export function Newsletter() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="dark"
|
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
|
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
|
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
||||||
<span>
|
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||||
{sending ? newsletter.sending_label : (
|
{sending ? newsletter.sending_label : (
|
||||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ServiceIcon } from '../ServiceIcon'
|
import { ServiceIcon } from '../ServiceIcon'
|
||||||
import { useSectionHeading, useSite } from '../../api'
|
import { useSectionHeading, useSite } from '../../api'
|
||||||
@@ -18,32 +17,35 @@ export function Services() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
<section className="w-full max-w-full overflow-hidden bg-ink py-12 md:py-24 lg:py-32" aria-labelledby="services-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<Reveal className="max-w-2xl text-start">
|
<Reveal className="max-w-2xl text-start">
|
||||||
|
|
||||||
<h2 id="services-title" className="text-section font-bold text-ivory">
|
<h2 id="services-title" className="text-section font-bold text-ivory">
|
||||||
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
<p className="mt-3 text-sm font-light leading-7 text-ivory/60 md:mt-5 md:text-base md:leading-9">
|
||||||
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-4 h-px w-24 bg-copper/70 md:mt-6" />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-8 grid grid-cols-2 gap-3 md:mt-14 md:gap-5 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{services.map((service, i) => (
|
{services.map((service, i) => (
|
||||||
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
||||||
<EditableItem resource="services" item={service} className="h-full">
|
<EditableItem resource="services" item={service} className="h-full">
|
||||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
<article className="group flex h-full flex-col items-center rounded-xl border border-ivory/10 bg-char p-3.5 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform md:rounded-2xl md:p-8">
|
||||||
<ServiceIcon
|
<div className="flex h-10 w-10 items-center justify-center md:h-14 md:w-14">
|
||||||
name={service.icon}
|
<ServiceIcon
|
||||||
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
name={service.icon}
|
||||||
/>
|
className="h-10 w-10 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper md:h-14 md:w-14"
|
||||||
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-sm font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper md:mt-6 md:text-lg">
|
||||||
{service.title}
|
{service.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/55 line-clamp-2 md:mt-3 md:text-sm md:leading-8 md:line-clamp-none">
|
||||||
|
{service.desc}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</EditableItem>
|
</EditableItem>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
@@ -1,31 +1,68 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useInView } from 'framer-motion';
|
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
import { useLang, formatNumber } from '../../i18n/lang';
|
import { useLang, formatNumber } from '../../i18n/lang';
|
||||||
import { useFieldSaver } from '../../admin/AdminContext';
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
import { InlineEdit } from '../admin/InlineEdit';
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
|
|
||||||
function Counter({ value }: {value: number;}) {
|
function useCounter(targetNumber: number, duration: number = 2000) {
|
||||||
const { lang } = useLang();
|
const [count, setCount] = useState(0);
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
|
||||||
const [display, setDisplay] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inView) return;
|
const element = ref.current;
|
||||||
let frame = 0;
|
if (!element) return;
|
||||||
const total = 60;
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
frame += 1;
|
|
||||||
const progress = 1 - Math.pow(1 - frame / total, 3);
|
|
||||||
setDisplay(Math.round(value * progress));
|
|
||||||
if (frame >= total) window.clearInterval(id);
|
|
||||||
}, 20);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, [inView, value]);
|
|
||||||
|
|
||||||
return <span ref={ref}>{formatNumber(display, lang)}</span>;
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
observer.disconnect();
|
||||||
|
|
||||||
|
let startTime: number | null = null;
|
||||||
|
const step = (timestamp: number) => {
|
||||||
|
if (!startTime) startTime = timestamp;
|
||||||
|
const progress = timestamp - startTime;
|
||||||
|
|
||||||
|
// ease-out cubic
|
||||||
|
const easeOut = progress >= duration ? 1 : 1 - Math.pow(1 - progress / duration, 3);
|
||||||
|
|
||||||
|
setCount(Math.floor(easeOut * targetNumber));
|
||||||
|
|
||||||
|
if (progress < duration) {
|
||||||
|
window.requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
setCount(targetNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [targetNumber, duration]);
|
||||||
|
|
||||||
|
return { count, ref };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Counter({ value }: { value: string | number }) {
|
||||||
|
const { lang } = useLang();
|
||||||
|
|
||||||
|
const strValue = String(value);
|
||||||
|
// Match the first sequence of digits
|
||||||
|
const numMatch = strValue.match(/(\d+)/);
|
||||||
|
const targetNumber = numMatch ? parseInt(numMatch[0], 10) : 0;
|
||||||
|
|
||||||
|
const { count, ref } = useCounter(targetNumber, 1500);
|
||||||
|
|
||||||
|
// If there's a number, replace it with the animated (and localized) count, otherwise render string as is.
|
||||||
|
const formattedDisplay = formatNumber(count, lang);
|
||||||
|
const renderValue = numMatch ? strValue.replace(numMatch[0], formattedDisplay) : strValue;
|
||||||
|
|
||||||
|
return <span ref={ref}>{renderValue}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Stats() {
|
export function Stats() {
|
||||||
@@ -34,12 +71,12 @@ export function Stats() {
|
|||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-16" aria-label={t('statsAria')}>
|
<section className="w-full max-w-full overflow-x-clip bg-char py-8 md:py-16" aria-label={t('statsAria')}>
|
||||||
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
|
<div className="mx-auto grid max-w-site grid-cols-2 gap-3.5 p-4 md:grid-cols-4 md:gap-y-12 md:px-10 md:py-0">
|
||||||
{stats.map((stat) =>
|
{stats.map((stat) =>
|
||||||
<EditableItem key={stat.id} resource="stats" item={stat}>
|
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||||
<div className="text-center">
|
<div className="p-3.5 text-center md:p-0">
|
||||||
<p className="text-4xl font-bold text-copper md:text-5xl">
|
<p className="text-2xl font-bold text-copper md:text-5xl">
|
||||||
<Counter value={stat.value} />
|
<Counter value={stat.value} />
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={stat.suffix}
|
value={stat.suffix}
|
||||||
@@ -47,10 +84,10 @@ export function Stats() {
|
|||||||
const s = d.stats.find((x) => x.id === stat.id);
|
const s = d.stats.find((x) => x.id === stat.id);
|
||||||
if (s) s.suffix = v;
|
if (s) s.suffix = v;
|
||||||
})}
|
})}
|
||||||
className="ltr:ml-1 rtl:mr-1 text-2xl"
|
className="ltr:ml-1 rtl:mr-1 text-lg md:text-2xl"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-sm font-light text-ivory/55">
|
<p className="mt-2 text-xs font-light text-ivory/55 md:mt-3 md:text-sm">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={stat.label}
|
value={stat.label}
|
||||||
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
onSave={(v) => save('stats', stat.id, { label: v }, (d) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { ButtonLink } from '../Button'
|
import { PremiumButton } from '../PremiumButton'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
import { useFieldSaver } from '../../admin/AdminContext'
|
import { useFieldSaver } from '../../admin/AdminContext'
|
||||||
@@ -89,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
|||||||
|
|
||||||
{showLink && (
|
{showLink && (
|
||||||
<Reveal delay={0.22}>
|
<Reveal delay={0.22}>
|
||||||
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
|
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
|
||||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
</PremiumButton>
|
||||||
</ButtonLink>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Reveal } from '../Reveal'
|
import { Reveal } from '../Reveal'
|
||||||
import { SectionHeading } from '../SectionHeading'
|
import { SectionHeading } from '../SectionHeading'
|
||||||
import { useSite } from '../../api'
|
import { useSite } from '../../api'
|
||||||
@@ -9,8 +8,8 @@ export function Team() {
|
|||||||
const { team } = useSite()
|
const { team } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
|
<section className="w-full max-w-full overflow-x-clip bg-ink py-16 md:py-24 lg:py-32" aria-labelledby="team-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackTeamTitle')}
|
title={t('fallbackTeamTitle')}
|
||||||
lead={t('fallbackTeamLead')}
|
lead={t('fallbackTeamLead')}
|
||||||
@@ -18,17 +17,17 @@ export function Team() {
|
|||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 md:mt-16 md:gap-8">
|
||||||
{team.map((member, i) => (
|
{team.map((member, i) => (
|
||||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||||
<EditableItem resource="team" item={member} className="h-full">
|
<EditableItem resource="team" item={member} className="h-full">
|
||||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
<article className="group flex h-full flex-col items-center rounded-xl border border-ivory/10 bg-char p-3 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift md:rounded-2xl md:p-10">
|
||||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-copper/30 text-sm font-bold text-copper transition-colors duration-500 group-hover:border-copper md:h-24 md:w-24 md:text-3xl">
|
||||||
{member.name.trim().charAt(0)}
|
{member.name.trim().charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-8 text-lg font-bold text-ivory">{member.name}</h3>
|
<h3 className="mt-2 text-xs font-bold text-ivory md:mt-8 md:text-lg">{member.name}</h3>
|
||||||
<p className="mt-2 text-sm font-light text-copper">{member.role}</p>
|
<p className="mt-0.5 text-[11px] font-medium text-copper line-clamp-1 md:mt-2 md:text-sm md:font-light md:line-clamp-none">{member.role}</p>
|
||||||
<p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
|
<p className="mt-2 w-full border-t border-ivory/10 pt-2 text-[10px] font-light leading-relaxed text-ivory/60 line-clamp-2 md:mt-6 md:pt-6 md:text-sm md:line-clamp-none">
|
||||||
{member.bio}
|
{member.bio}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -40,4 +39,4 @@ export function Team() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion, PanInfo } from 'framer-motion';
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||||
import { SectionHeading } from '../SectionHeading';
|
import { SectionHeading } from '../SectionHeading';
|
||||||
import { useSite } from '../../api';
|
import { useSite } from '../../api';
|
||||||
@@ -17,39 +17,57 @@ export function Testimonials() {
|
|||||||
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
setIndex((prev) => (prev + dir + testimonials.length) % testimonials.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||||
|
if (info.offset.x > 50) {
|
||||||
|
go(1); // سوایپ به راست
|
||||||
|
} else if (info.offset.x < -50) {
|
||||||
|
go(-1); // سوایپ به چپ
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-y border-ivory/10 bg-char py-24 lg:py-32" aria-labelledby="testimonials-title">
|
<section className="border-y border-ivory/10 bg-char py-14 lg:py-28" aria-labelledby="testimonials-title">
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<SectionHeading headingKey="testimonials" align="center" />
|
<SectionHeading headingKey="testimonials" align="center" />
|
||||||
|
|
||||||
<div className="relative mx-auto mt-14 max-w-3xl text-center">
|
<div className="relative mx-auto mt-8 max-w-3xl text-center select-none">
|
||||||
<QuoteIcon className="mx-auto h-8 w-8 text-copper/60" aria-hidden="true" />
|
<QuoteIcon className="mx-auto h-6 w-6 text-copper/60 md:h-8 md:w-8" aria-hidden="true" />
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.blockquote
|
<div className="relative min-h-[170px] overflow-hidden md:min-h-[190px]">
|
||||||
key={current.id}
|
<AnimatePresence mode="wait">
|
||||||
initial={{ opacity: 0, y: 16 }}
|
<motion.blockquote
|
||||||
animate={{ opacity: 1, y: 0 }}
|
key={current.id}
|
||||||
exit={{ opacity: 0, y: -12 }}
|
drag="x"
|
||||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
|
dragElastic={0.2}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -12 }}
|
||||||
|
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||||
|
className="cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<EditableItem resource="testimonials" item={current}>
|
||||||
|
<p className="mt-5 text-base font-light leading-8 text-ivory/85 md:mt-8 md:text-xl md:leading-[2.4rem]">
|
||||||
|
«{current.quote}»
|
||||||
|
</p>
|
||||||
|
<footer className="mt-5 md:mt-8">
|
||||||
|
<p className="text-sm font-bold text-copper md:text-base">{current.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs font-light text-ivory/45 md:text-sm">{current.role}</p>
|
||||||
|
</footer>
|
||||||
|
</EditableItem>
|
||||||
|
</motion.blockquote>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EditableItem resource="testimonials" item={current}>
|
<div className="mt-6 flex items-center justify-center gap-5 md:mt-10">
|
||||||
<p className="mt-8 text-lg font-light leading-[2.4rem] text-ivory/85 md:text-xl">«{current.quote}»</p>
|
|
||||||
<footer className="mt-8">
|
|
||||||
<p className="text-base font-bold text-copper">{current.name}</p>
|
|
||||||
<p className="mt-1 text-sm font-light text-ivory/45">{current.role}</p>
|
|
||||||
</footer>
|
|
||||||
</EditableItem>
|
|
||||||
</motion.blockquote>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="mt-12 flex items-center justify-center gap-5">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(1)}
|
onClick={() => go(1)}
|
||||||
aria-label={t('prevTestimonial')}
|
aria-label={t('prevTestimonial')}
|
||||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
className="group flex h-10 w-10 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 shadow-[0_4px_20px_rgba(0,0,0,0.35)] backdrop-blur-md transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95 md:h-12 md:w-12"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5" />
|
<ChevronRightIcon className="h-4 w-4 transition-transform duration-300 ease-lux ltr:rotate-180 rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5 md:h-5 md:w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
||||||
@@ -63,8 +81,8 @@ export function Testimonials() {
|
|||||||
onClick={() => setIndex(i)}
|
onClick={() => setIndex(i)}
|
||||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||||
i === index
|
i === index
|
||||||
? 'w-8 bg-copper shadow-[0_0_12px_rgba(197,143,92,0.6)]'
|
? 'w-7 bg-copper shadow-[0_0_12px_rgba(197,143,92,0.6)] md:w-8'
|
||||||
: 'w-2.5 bg-ivory/20 hover:bg-ivory/40'
|
: 'w-2 bg-ivory/20 hover:bg-ivory/40 md:w-2.5'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -74,9 +92,9 @@ export function Testimonials() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(-1)}
|
onClick={() => go(-1)}
|
||||||
aria-label={t('nextTestimonial')}
|
aria-label={t('nextTestimonial')}
|
||||||
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
|
className="group flex h-10 w-10 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 shadow-[0_4px_20px_rgba(0,0,0,0.35)] backdrop-blur-md transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95 md:h-12 md:w-12"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" />
|
<ChevronLeftIcon className="h-4 w-4 transition-transform duration-300 ease-lux ltr:rotate-180 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5 md:h-5 md:w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||||
import { useSite, type TimelineItem } from '../../api'
|
import { useSite, type TimelineItem } from '../../api'
|
||||||
import { useLang } from '../../i18n/lang'
|
import { useLang } from '../../i18n/lang'
|
||||||
@@ -9,16 +9,17 @@ const ease = [0.23, 1, 0.32, 1] as const
|
|||||||
|
|
||||||
type Milestone = TimelineItem
|
type Milestone = TimelineItem
|
||||||
|
|
||||||
function Item({ item, index }: { item: Milestone; index: number }) {
|
function DesktopTimelineItem({ item, index }: { item: Milestone; index: number }) {
|
||||||
const ref = useRef<HTMLLIElement>(null)
|
const ref = useRef<HTMLLIElement>(null)
|
||||||
const active = useInView(ref, { once: true, amount: 0.6 })
|
const active = useInView(ref, { once: true, amount: 0.6 })
|
||||||
const onRight = index % 2 === 0
|
const onRight = index % 2 === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||||
|
{/* دایرهها: مرکز دایره دقیقاً روی مرکز خط 2px است */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
className={`absolute right-1/2 top-8 z-10 h-3 w-3 translate-x-1/2 rounded-full transition-all duration-500 ease-lux ${
|
||||||
active
|
active
|
||||||
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
|
||||||
: 'bg-ivory/20'
|
: 'bg-ivory/20'
|
||||||
@@ -26,16 +27,13 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`pr-8 md:pr-0 ${
|
className={onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'}
|
||||||
onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'
|
|
||||||
}`}
|
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||||
transition={{ duration: 0.55, ease }}
|
transition={{ duration: 0.55, ease }}
|
||||||
>
|
>
|
||||||
<EditableItem resource="timeline" item={item}>
|
<EditableItem resource="timeline" item={item}>
|
||||||
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
|
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-8 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)]">
|
||||||
{/* لایه نوری ظریف که در هاور روشن میشود */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||||
|
|
||||||
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
||||||
@@ -51,18 +49,19 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
|||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
const { timeline } = useSite()
|
const { timeline } = useSite()
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
const desktopRef = useRef<HTMLDivElement>(null)
|
||||||
const { scrollYProgress } = useScroll({
|
const { scrollYProgress } = useScroll({
|
||||||
target: ref,
|
target: desktopRef,
|
||||||
offset: ['start 70%', 'end 60%'],
|
offset: ['start 70%', 'end 60%'],
|
||||||
})
|
})
|
||||||
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
|
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-char py-24 md:py-32" aria-labelledby="timeline-title">
|
<section className="w-full max-w-full overflow-x-clip bg-char py-16 md:py-24 lg:py-32" aria-labelledby="timeline-title">
|
||||||
<div className="mx-auto w-full max-w-site px-6 lg:px-10">
|
<div className="mx-auto w-full max-w-site px-4 md:px-6 lg:px-10">
|
||||||
|
|
||||||
<div className="mb-16">
|
<div className="mb-8 md:mb-16">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t('fallbackTimelineTitle')}
|
title={t('fallbackTimelineTitle')}
|
||||||
lead={t('fallbackTimelineLead')}
|
lead={t('fallbackTimelineLead')}
|
||||||
@@ -71,26 +70,65 @@ export function Timeline() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
{/* Mobile View: Minimalist Editorial Spine */}
|
||||||
|
<div className="block md:hidden px-5 py-4">
|
||||||
|
<div className="relative rtl:pr-6 rtl:border-r ltr:pl-6 ltr:border-l border-copper/25 space-y-6 my-2">
|
||||||
|
{timeline.map((item) => (
|
||||||
|
<EditableItem key={item.id} resource="timeline" item={item}>
|
||||||
|
<div className="relative group">
|
||||||
|
{/* Timeline Node on the vertical spine */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute rtl:-right-[29px] ltr:-left-[29px] top-1.5 h-2.5 w-2.5 rounded-full bg-copper ring-4 ring-copper/15"
|
||||||
|
/>
|
||||||
|
{/* Milestone Content */}
|
||||||
|
<div className="p-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-[11px] font-bold tracking-wider text-copper bg-copper/10 px-2 py-0.5 rounded-md">
|
||||||
|
{item.year}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-semibold text-ivory">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ivory/60 leading-relaxed line-clamp-2 rtl:pr-0.5 ltr:pl-0.5">
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<AddItemButton resource="timeline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View: Original Vertical Timeline */}
|
||||||
|
<div ref={desktopRef} className="relative mx-auto mt-20 hidden max-w-4xl md:block">
|
||||||
|
{/* خط پسزمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
|
className="absolute inset-y-0 right-1/2 w-[2px] translate-x-1/2 bg-ivory/10"
|
||||||
/>
|
/>
|
||||||
|
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
||||||
<motion.span
|
<motion.span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
|
className="absolute inset-y-0 right-1/2 w-[2px] origin-top translate-x-1/2 bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)]"
|
||||||
style={{ scaleY }}
|
style={{ scaleY }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ol className="flex flex-col gap-12 md:gap-16">
|
<ol className="flex flex-col gap-16">
|
||||||
{timeline.map((item, i) => (
|
{timeline.map((item, i) => (
|
||||||
<Item key={item.id} item={item} index={i} />
|
<DesktopTimelineItem key={item.id} item={item} index={i} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<AddItemButton resource="timeline" />
|
<AddItemButton resource="timeline" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Reveal } from '../Reveal';
|
import { Reveal } from '../Reveal';
|
||||||
import { SectionHeading } from '../SectionHeading';
|
import { SectionHeading } from '../SectionHeading';
|
||||||
import { useSectionHeading, useSite } from '../../api';
|
import { useSectionHeading, useSite } from '../../api';
|
||||||
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||||
import { InlineEdit } from '../admin/InlineEdit';
|
import { InlineEdit } from '../admin/InlineEdit';
|
||||||
import { useFieldSaver } from '../../admin/AdminContext';
|
import { useFieldSaver } from '../../admin/AdminContext';
|
||||||
import { useLang, formatNumber } from '../../i18n/lang';
|
import { useLang, formatNumber } from '../../i18n/lang';
|
||||||
@@ -19,9 +18,15 @@ export function WhyUs() {
|
|||||||
if (it) Object.assign(it, changes);
|
if (it) Object.assign(it, changes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formatIndex = (index: number) => {
|
||||||
|
return lang === 'fa'
|
||||||
|
? `۰${formatNumber(index + 1, lang)}`
|
||||||
|
: String(index + 1).padStart(2, '0');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="why-title">
|
<section className="w-full max-w-full overflow-x-clip bg-ink py-16 md:py-24 lg:py-32" aria-labelledby="why-title">
|
||||||
<div className="mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10">
|
<div className="mx-auto grid max-w-site gap-8 px-4 md:gap-14 md:px-6 lg:grid-cols-12 lg:px-10">
|
||||||
<div className="lg:col-span-5">
|
<div className="lg:col-span-5">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={heading.title}
|
title={heading.title}
|
||||||
@@ -33,42 +38,84 @@ export function WhyUs() {
|
|||||||
<img
|
<img
|
||||||
src={heading.image}
|
src={heading.image}
|
||||||
alt={heading.title}
|
alt={heading.title}
|
||||||
className="mt-10 h-80 w-full object-cover" />
|
className="mt-6 hidden h-80 w-full object-cover md:mt-10 md:block" />
|
||||||
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
{/* Mobile 2-Column Luxury Micro-Card Grid */}
|
||||||
{why_us.map((item, i) =>
|
<div className="block md:hidden px-3.5 py-2">
|
||||||
<Reveal key={item.id} delay={0.05 * i}>
|
<div className="grid grid-cols-2 gap-2.5 items-stretch">
|
||||||
<EditableItem resource="why_us" item={item}>
|
{why_us.map((item, i) => (
|
||||||
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
<EditableItem key={item.id} resource="why_us" item={item} className="h-full">
|
||||||
<span className="text-xs font-light text-copper">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</span>
|
<div className="h-full p-3 rounded-xl border border-ivory/10 bg-char/40 flex flex-col justify-between hover:border-copper/40 transition-all duration-200">
|
||||||
<h3 className="mt-4 text-lg font-bold text-ivory">
|
<div>
|
||||||
<InlineEdit
|
{/* Top Row: Index Badge */}
|
||||||
value={item.title}
|
<div className="flex items-center justify-between mb-2">
|
||||||
onSave={(v) => saveItem(item.id, { title: v })}
|
<span className="text-[11px] font-mono font-bold text-copper tracking-wider px-1.5 py-0.5 rounded bg-copper/10 border border-copper/20">
|
||||||
/>
|
{`0${i + 1}`}
|
||||||
</h3>
|
</span>
|
||||||
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
|
</div>
|
||||||
<InlineEdit
|
|
||||||
multiline
|
{/* Title */}
|
||||||
value={item.desc}
|
<h4 className="text-xs font-bold text-ivory mb-1 line-clamp-1 leading-snug">
|
||||||
onSave={(v) => saveItem(item.id, { desc: v })}
|
<InlineEdit
|
||||||
/>
|
value={item.title}
|
||||||
</p>
|
onSave={(v) => saveItem(item.id, { title: v })}
|
||||||
</div>
|
/>
|
||||||
</EditableItem>
|
</h4>
|
||||||
</Reveal>
|
|
||||||
)}
|
{/* Description */}
|
||||||
|
<p className="text-[10px] text-ivory/60 leading-relaxed line-clamp-2">
|
||||||
|
<InlineEdit
|
||||||
|
multiline
|
||||||
|
value={item.desc}
|
||||||
|
onSave={(v) => saveItem(item.id, { desc: v })}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<AddItemButton resource="why_us" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8">
|
|
||||||
<AddItemButton resource="why_us" />
|
{/* Desktop Grid Layout */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="grid gap-x-10 gap-y-px sm:grid-cols-2">
|
||||||
|
{why_us.map((item, i) => (
|
||||||
|
<Reveal key={item.id} delay={0.05 * i}>
|
||||||
|
<EditableItem resource="why_us" item={item}>
|
||||||
|
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||||
|
<span className="text-xs font-light text-copper">{formatIndex(i)}</span>
|
||||||
|
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||||
|
<InlineEdit
|
||||||
|
value={item.title}
|
||||||
|
onSave={(v) => saveItem(item.id, { title: v })}
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">
|
||||||
|
<InlineEdit
|
||||||
|
multiline
|
||||||
|
value={item.desc}
|
||||||
|
onSave={(v) => saveItem(item.id, { desc: v })}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</EditableItem>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<AddItemButton resource="why_us" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>);
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
||||||
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
||||||
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
||||||
|
|
||||||
|
// آدرس پایه کاشیهای نقشه (MinIO: style.json، iran.pmtiles، فونتها و اسپرایت).
|
||||||
|
// در صورت تغییر مبدا سرو کاشیها VITE_MAP_URL را تنظیم کنید.
|
||||||
|
export const MAP_BASE_URL: string =
|
||||||
|
(import.meta.env.VITE_MAP_URL as string | undefined) ?? 'http://localhost:19000/maps'
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* مختصات پروژههای گروه ساختمانی آرامش — برگرفته از شیت «Sheet1.csv».
|
||||||
|
*
|
||||||
|
* دو نکتهی دادهای که در همانجا حل میشوند:
|
||||||
|
* ۱) ستونهای neshan_url و google_maps_url در شیت فقط برچسب بودند
|
||||||
|
* («Open in Neshan») و نشانی واقعی نداشتند؛ پس پیوندها از روی مختصات
|
||||||
|
* ساخته میشوند که الگوی تثبیتشدهی هر دو سرویس است.
|
||||||
|
* ۲) ستون توضیحات خالی است؛ متن پیشفرض در لایهی رابط کاربری جایگزین میشود.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Project, ProjectCardItem } from './site'
|
||||||
|
|
||||||
|
export type MapProject = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
/** سال شمسی — ممکن است خالی باشد */
|
||||||
|
year: string
|
||||||
|
/** پروژهی در جریان (ستون new شیت) */
|
||||||
|
isNew: boolean
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مرکز پیشفرض نقشه — اصفهان */
|
||||||
|
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
|
||||||
|
|
||||||
|
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ستونهای برچسبی شیت حذف شدهاند (نشانی واقعی نداشتند) و ستون توضیحات به
|
||||||
|
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
|
||||||
|
*/
|
||||||
|
const RAW_CSV = `name,year,new,lat,lng,description
|
||||||
|
پروژه سروش,1381,no,32.6662842,51.701735,
|
||||||
|
چهرازی-زیتون,1383,no,32.6700428,51.69985,
|
||||||
|
جعفریان,1384,no,32.6669259,51.701085,
|
||||||
|
بهار,1385,yes,32.6468963,51.6899119,
|
||||||
|
حسن مرادی,1385,no,32.6704278,51.6985567,
|
||||||
|
کوثر,1386,no,32.6522044,51.6856474,
|
||||||
|
صبا,1387,no,32.6541363,51.6847828,
|
||||||
|
پورمعالی - بهشت,1387,no,32.6500402,51.6919823,
|
||||||
|
2 پروژه- فردوس,1388,no,32.6614576,51.706429,
|
||||||
|
ارم,1388,no,32.6563712,51.710694,
|
||||||
|
سینا,1388,no,32.6469299,51.6855403,
|
||||||
|
مسجد سید,1389,yes,32.6669953,51.6557085,
|
||||||
|
هفده شهریور,1389,yes,32.7249998,51.6543819,
|
||||||
|
آرامش رفیع زاده,1390,no,32.6467107,51.6873398,
|
||||||
|
بن بست شریعت,1390,yes,32.6459939,51.6897556,
|
||||||
|
قصر - یاسین,1390,no,32.6663549,51.7065512,
|
||||||
|
پردیس,1390,no,32.6518961,51.6973621,
|
||||||
|
آرامش حکیم,1392,no,32.6426783,51.6813477,
|
||||||
|
آرامش دخانیات,1393,no,32.6411924,51.6797555,
|
||||||
|
بهشتی فرشادی,1393,no,32.6531989,51.6790984,
|
||||||
|
کاکتوس,1394,no,32.6486747,51.6859003,
|
||||||
|
آرامش - صیاد,1395,no,32.6486798,51.712659,
|
||||||
|
کوثر,1395,yes,32.6506204,51.6344053,
|
||||||
|
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424,
|
||||||
|
میرزا نصیر,1396,no,32.668195,51.7173079,
|
||||||
|
نیکان,1396,yes,32.6504663,51.6355982,
|
||||||
|
آرامش-رسا,1397,no,32.6522126,51.6857297,
|
||||||
|
نگین,1397,yes,32.6507084,51.6313395,
|
||||||
|
آرامش - بوعلی 2,1398,no,32.645391,51.6917828,
|
||||||
|
آرامش استاد همایی,1398,no,32.6563505,51.691988,
|
||||||
|
آرامش صدر,1398,no,32.6469234,51.6826176,
|
||||||
|
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268,
|
||||||
|
آرامش- صدر,1399,no,32.6469205,51.6829887,
|
||||||
|
آرامش - سنایی,1400,yes,32.6973339,51.6194128,
|
||||||
|
مغازه لباس فروشی,1400,no,32.6516965,51.7020497,
|
||||||
|
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244,
|
||||||
|
آرامش - بزرگمهر,1401,no,32.651381,51.6993575,
|
||||||
|
آرامش - سعدی,1401,yes,32.6970115,51.6196909,
|
||||||
|
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248,
|
||||||
|
آرامش 2,1401,yes,32.6505704,51.6337863,
|
||||||
|
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827,
|
||||||
|
آرامش احمدآباد,1401,no,32.6626101,51.6969828,
|
||||||
|
تجاری آتشگاه,1401,yes,32.6515087,51.6033278,
|
||||||
|
آرامش - شهیدان,1402,yes,32.6854968,51.6171964,
|
||||||
|
آرامش خواجو,1402,no,32.6419187,51.6831873,
|
||||||
|
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519,
|
||||||
|
آرامش - انقلاب,1403,yes,32.719769,51.645921,
|
||||||
|
آرامش - شریف,1404,no,32.6473319,51.6955996,
|
||||||
|
آرامش - کهندژ,1404,yes,32.6649497,51.630077,
|
||||||
|
آرامش جی,1404,no,32.6610717,51.7110184,
|
||||||
|
آرامش - بابک,1405,yes,32.674076,51.6368204,
|
||||||
|
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771,
|
||||||
|
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643,
|
||||||
|
آرامش - وحدت,1405,yes,32.6537564,51.6197007,
|
||||||
|
آرامش پروین,1405,no,32.6618223,51.7098152,
|
||||||
|
خلیفه سلطانی,1405,yes,32.7035398,51.6223296,
|
||||||
|
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975,
|
||||||
|
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342,
|
||||||
|
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436,
|
||||||
|
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تجزیهی یک خط CSV — پشتیبانی از فیلدهای گیومهدار و گیومهی دوگانهی درونی.
|
||||||
|
* جداکننده کاما و نویسههای مخفی ابتدای فایل (BOM) هم حذف میشوند.
|
||||||
|
*/
|
||||||
|
function parseCsvLine(line: string): string[] {
|
||||||
|
const fields: string[] = []
|
||||||
|
let current = ''
|
||||||
|
let inQuotes = false
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i += 1) {
|
||||||
|
const char = line[i]
|
||||||
|
if (inQuotes) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
current += '"'
|
||||||
|
i += 1
|
||||||
|
} else inQuotes = false
|
||||||
|
} else current += char
|
||||||
|
} else if (char === '"') inQuotes = true
|
||||||
|
else if (char === ',') {
|
||||||
|
fields.push(current.trim())
|
||||||
|
current = ''
|
||||||
|
} else current += char
|
||||||
|
}
|
||||||
|
fields.push(current.trim())
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV خام → آرایهی تایپشدهی JSON */
|
||||||
|
export function parseProjectsCsv(raw: string): MapProject[] {
|
||||||
|
const rows: CsvRow[] = []
|
||||||
|
const lines = raw.replace(/^\ufeff/, '').split(/\r?\n/)
|
||||||
|
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
const [name, year = '', isNew = '', lat = '', lng = '', description = ''] = parseCsvLine(line)
|
||||||
|
const parsedLat = Number(lat)
|
||||||
|
const parsedLng = Number(lng)
|
||||||
|
if (!name || !Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) continue
|
||||||
|
rows.push({ name, year, isNew: isNew.toLowerCase() === 'yes', lat: parsedLat, lng: parsedLng, description })
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((row, index) => ({ id: index + 1, ...row }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
|
||||||
|
|
||||||
|
/** پیوند نشان روی نقشه برای سرویس نشان */
|
||||||
|
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string {
|
||||||
|
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پیوند نقشه گوگل */
|
||||||
|
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
|
||||||
|
return `https://www.google.com/maps?q=${project.lat},${project.lng}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نرمالسازی نام برای تطبیق شیت با دادهی بکاند: نویسههای نامرئی،
|
||||||
|
* ی/ک عربی، نشانهها و واژههای عمومی حذف میشوند تا «آرامش - سنایی» و
|
||||||
|
* «پروژه سنایی» یک کلید بگیرند.
|
||||||
|
*/
|
||||||
|
export function normalizeProjectName(name: string): string {
|
||||||
|
let result = (name ?? '')
|
||||||
|
.normalize('NFC')
|
||||||
|
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||||
|
.replace(/[يى]/g, 'ی')
|
||||||
|
.replace(/ك/g, 'ک')
|
||||||
|
.replace(/[ۀة]/g, 'ه')
|
||||||
|
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
for (const noise of NAME_NOISE) result = result.replace(new RegExp(`(^|\\s)${noise}(?=\\s|$)`, 'g'), ' ')
|
||||||
|
return result.replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پروژهی شیت متناظر با یک نام (تطبیق دقیق نرمالشده، سپس دربرگیری) */
|
||||||
|
export function findMapProjectByName(name: string): MapProject | undefined {
|
||||||
|
const key = normalizeProjectName(name)
|
||||||
|
if (!key) return undefined
|
||||||
|
|
||||||
|
const exact = MAP_PROJECTS.find((project) => normalizeProjectName(project.name) === key)
|
||||||
|
if (exact) return exact
|
||||||
|
|
||||||
|
return MAP_PROJECTS.find((project) => {
|
||||||
|
const candidate = normalizeProjectName(project.name)
|
||||||
|
return candidate.length >= 3 && key.length >= 3 && (candidate.includes(key) || key.includes(candidate))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* محل جایگزین وقتی پروژهای در شیت پیدا نشد — مرکز اصفهان با نام پروژهی
|
||||||
|
* بکاند، تا نمای تکپروژه هرگز بدون نقشه نماند.
|
||||||
|
*/
|
||||||
|
export function createPlaceholderProject(name: string, description = ''): MapProject {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
name,
|
||||||
|
year: '',
|
||||||
|
isNew: false,
|
||||||
|
lat: ISFAHAN_CENTER[1],
|
||||||
|
lng: ISFAHAN_CENTER[0],
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کادر محدودهٔ نقشه از مختصات پروژهها — با حاشیه تا لبهٔ نقشه خالی نماند و
|
||||||
|
* کاربر به بیرون از محدودهٔ پروژهها جابهجا نشود.
|
||||||
|
*/
|
||||||
|
export function boundsForProjects(
|
||||||
|
projects: Pick<MapProject, 'lat' | 'lng'>[],
|
||||||
|
padding = 0.02,
|
||||||
|
): [[number, number], [number, number]] | undefined {
|
||||||
|
if (!projects.length) return undefined
|
||||||
|
const lats = projects.map((project) => project.lat)
|
||||||
|
const lngs = projects.map((project) => project.lng)
|
||||||
|
return [
|
||||||
|
[Math.min(...lngs) - padding, Math.min(...lats) - padding],
|
||||||
|
[Math.max(...lngs) + padding, Math.max(...lats) + padding],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ردیف شیت → کارت فهرست: عنوان از شیت، وضعیت از ستون new، نشانی از مختصات */
|
||||||
|
export function csvProjectToCard(project: MapProject, detailHref: string): ProjectCardItem {
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
title: project.name,
|
||||||
|
status: project.isNew ? 'progress' : 'done',
|
||||||
|
detailHref,
|
||||||
|
addressUrl: buildNeshanUrl(project),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** پروژهٔ بکاند → کارت فهرست؛ نشانی نشان از تطبیق نامی با شیت میآید */
|
||||||
|
export function apiProjectToCard(project: Project, detailHref: string): ProjectCardItem {
|
||||||
|
const matched = findMapProjectByName(project.title)
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
status: project.status,
|
||||||
|
detailHref,
|
||||||
|
location: project.location || undefined,
|
||||||
|
image: project.image || undefined,
|
||||||
|
area: project.area || undefined,
|
||||||
|
addressUrl: matched ? buildNeshanUrl(matched) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export type BilingualText = { en: string; fa: string };
|
||||||
|
|
||||||
|
export type StaticProject = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: BilingualText;
|
||||||
|
location: BilingualText;
|
||||||
|
area: BilingualText;
|
||||||
|
status: 'done' | 'progress';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const projectsData: StaticProject[] = [
|
||||||
|
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||||
|
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||||
|
];
|
||||||
@@ -32,6 +32,10 @@ export type Project = {
|
|||||||
units_en?: string;
|
units_en?: string;
|
||||||
client: string;
|
client: string;
|
||||||
client_en?: string;
|
client_en?: string;
|
||||||
|
/** مختصات جغرافیایی پروژه — عرض جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||||
|
lat?: number;
|
||||||
|
/** مختصات جغرافیایی پروژه — طول جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||||
|
lng?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
summary_en?: string;
|
summary_en?: string;
|
||||||
body: string[];
|
body: string[];
|
||||||
@@ -46,7 +50,11 @@ export type ArticleStatus = 'draft' | 'published';
|
|||||||
export type Article = {
|
export type Article = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
slug: string; 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;
|
image: string;
|
||||||
|
read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
||||||
|
/** پرسشوپاسخهای پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
|
||||||
|
faqs?: { question: string; answer: string }[];
|
||||||
|
faqs_en?: { question: string; answer: string }[] | null;
|
||||||
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||||
body: ArticleBodyDoc | string[];
|
body: ArticleBodyDoc | string[];
|
||||||
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
||||||
@@ -66,3 +74,20 @@ export type Article = {
|
|||||||
/** هشتگها/برچسبهای مقاله */
|
/** هشتگها/برچسبهای مقاله */
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شکل پروژه در کارتهای فهرست — از پروژهٔ بکاند یا ردیف شیت پر میشود؛
|
||||||
|
* فیلدهای اختیاری فقط وقتی رندر میشوند که دادهشان موجود باشد.
|
||||||
|
*/
|
||||||
|
export type ProjectCardItem = {
|
||||||
|
id: number | string;
|
||||||
|
title: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
/** مسیر صفحهٔ جزئیات پروژه */
|
||||||
|
detailHref: string;
|
||||||
|
/** نشانی بیرونی پروژه (نشان) — در نبودش دکمهٔ آن رندر نمیشود */
|
||||||
|
addressUrl?: string;
|
||||||
|
location?: string;
|
||||||
|
image?: string;
|
||||||
|
area?: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ export const fa = {
|
|||||||
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران.',
|
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران.',
|
||||||
|
|
||||||
// کارت پروژه
|
// کارت پروژه
|
||||||
yearLabel: 'سال:',
|
|
||||||
sqm: 'متر مربع',
|
sqm: 'متر مربع',
|
||||||
|
|
||||||
// جزئیات پروژه
|
// جزئیات پروژه
|
||||||
projectNotFound: 'این پروژه پیدا نشد',
|
projectNotFound: 'این پروژه پیدا نشد',
|
||||||
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژهها را ببینید.',
|
||||||
@@ -106,6 +104,18 @@ export const fa = {
|
|||||||
nextTestimonial: 'نظر بعدی',
|
nextTestimonial: 'نظر بعدی',
|
||||||
selectTestimonialAria: 'انتخاب نظر',
|
selectTestimonialAria: 'انتخاب نظر',
|
||||||
testimonialByPrefix: 'نظر',
|
testimonialByPrefix: 'نظر',
|
||||||
|
|
||||||
|
// نقشه پروژهها (مپلیبر)
|
||||||
|
mapAria: 'نقشه موقعیت پروژهها',
|
||||||
|
mapLoading: 'در حال بارگذاری نقشه…',
|
||||||
|
mapErrorText: 'بارگذاری نقشه ناموفق بود — اتصال به سرور کاشیها را بررسی کنید.',
|
||||||
|
viewProject: 'مشاهده پروژه',
|
||||||
|
mapFullscreen: 'نمایش تمامصفحه',
|
||||||
|
mapExitFullscreen: 'خروج از تمامصفحه',
|
||||||
|
viewAddress: 'مشاهده آدرس پروژه',
|
||||||
|
mapNewBadge: 'جدید',
|
||||||
|
mapProjectDescription: 'پروژه اجراشده در اصفهان',
|
||||||
|
mapImagePlaceholder: 'تصویر پروژه',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Dict = typeof fa
|
export type Dict = typeof fa
|
||||||
@@ -151,7 +161,6 @@ export const en: Dict = {
|
|||||||
fallbackFaqTitle: 'Frequently Asked Questions',
|
fallbackFaqTitle: 'Frequently Asked Questions',
|
||||||
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
|
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
|
||||||
|
|
||||||
yearLabel: 'Year:',
|
|
||||||
sqm: 'm²',
|
sqm: 'm²',
|
||||||
|
|
||||||
projectNotFound: 'Project not found',
|
projectNotFound: 'Project not found',
|
||||||
@@ -206,6 +215,17 @@ export const en: Dict = {
|
|||||||
nextTestimonial: 'Next testimonial',
|
nextTestimonial: 'Next testimonial',
|
||||||
selectTestimonialAria: 'Select testimonial',
|
selectTestimonialAria: 'Select testimonial',
|
||||||
testimonialByPrefix: 'Testimonial by',
|
testimonialByPrefix: 'Testimonial by',
|
||||||
|
|
||||||
|
mapAria: 'Projects location map',
|
||||||
|
mapLoading: 'Loading map…',
|
||||||
|
mapErrorText: 'The map failed to load — check the tile server connection.',
|
||||||
|
viewProject: 'View project',
|
||||||
|
mapFullscreen: 'Fullscreen',
|
||||||
|
mapExitFullscreen: 'Exit fullscreen',
|
||||||
|
viewAddress: 'View project location',
|
||||||
|
mapNewBadge: 'New',
|
||||||
|
mapProjectDescription: 'Project delivered in Isfahan',
|
||||||
|
mapImagePlaceholder: 'Project image',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dict = { fa, en }
|
export const dict = { fa, en }
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
|
|||||||
read: s(a.read, a.read_en),
|
read: s(a.read, a.read_en),
|
||||||
excerpt: s(a.excerpt, a.excerpt_en),
|
excerpt: s(a.excerpt, a.excerpt_en),
|
||||||
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
body: docHasText(a.body_en) ? a.body_en! : a.body,
|
||||||
|
faqs: list(a.faqs ?? [], a.faqs_en),
|
||||||
})),
|
})),
|
||||||
testimonials: data.testimonials.map((tm) => ({
|
testimonials: data.testimonials.map((tm) => ({
|
||||||
...tm,
|
...tm,
|
||||||
|
|||||||
@@ -388,4 +388,78 @@ html[lang='en'] .article-body h3 {
|
|||||||
}
|
}
|
||||||
.aramesh-datepicker .rmdp-action-button {
|
.aramesh-datepicker .rmdp-action-button {
|
||||||
color: #d5874c;
|
color: #d5874c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- نقشه مپلیبر — کنترلها و پاپآپ همرنگ تم تیره ---------- */
|
||||||
|
.lux-map-popup .maplibregl-popup-content {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* دم پاپآپ — همرنگ کارت شیشهای */
|
||||||
|
.lux-map-popup .maplibregl-popup-tip {
|
||||||
|
border-top-color: #1e1a16;
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-group {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(245, 243, 239, 0.14);
|
||||||
|
border-radius: 0.75rem !important;
|
||||||
|
background: rgba(30, 26, 22, 0.85) !important;
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-group button {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-group button:hover {
|
||||||
|
background-color: rgba(213, 135, 76, 0.16) !important;
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-group button + button {
|
||||||
|
border-top: 1px solid rgba(245, 243, 239, 0.12) !important;
|
||||||
|
}
|
||||||
|
/* آیکونهای پیشفرض کنترلها تیرهاند — با اینورتر روشن میشوند */
|
||||||
|
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
|
||||||
|
filter: invert(0.92);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-attrib.maplibregl-compact {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(30, 26, 22, 0.85);
|
||||||
|
color: rgba(245, 243, 239, 0.55);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-attrib a {
|
||||||
|
color: rgba(213, 135, 76, 0.9);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-attrib-button {
|
||||||
|
filter: invert(0.92);
|
||||||
|
}
|
||||||
|
.maplibregl-ctrl-scale {
|
||||||
|
border-color: rgba(245, 243, 239, 0.35);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(30, 26, 22, 0.7);
|
||||||
|
color: rgba(245, 243, 239, 0.6);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- ورود نرم کارت پاپآپ و حالت تمامصفحه ---------- */
|
||||||
|
@keyframes luxPopupIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes luxFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import type { ProjectsMapAllProps } from './ProjectsMapAll'
|
||||||
|
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بارگیری تنبل نقشهها — بستهی سنگین مپلیبر فقط وقتی دانلود میشود که
|
||||||
|
* واقعاً نقشهای روی صفحه باشد؛ صفحههای بدون نقشه (مثل خانه) جریمهی حجم
|
||||||
|
* نمیپردازند. Import زیر فقط نوع است و باندل را سنگین نمیکند.
|
||||||
|
*/
|
||||||
|
const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({ default: module.ProjectsMapAll })))
|
||||||
|
const ProjectsMapSingle = lazy(() =>
|
||||||
|
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** جاینگهدار هماندازهی نقشه تا چیدمان هنگام بارگیری نلرزد */
|
||||||
|
function MapSkeleton({ className = '' }: { className?: string }) {
|
||||||
|
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||||
|
<ProjectsMapAll {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||||
|
<ProjectsMapSingle {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import * as maplibregl from 'maplibre-gl'
|
||||||
|
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
|
||||||
|
import { Loader2Icon } from 'lucide-react'
|
||||||
|
import { MdClose, MdFullscreen } from 'react-icons/md'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
import type { MapStatus } from './types'
|
||||||
|
|
||||||
|
type MapViewportProps = {
|
||||||
|
containerRef: MutableRefObject<HTMLDivElement | null>
|
||||||
|
/** نمونهی نقشه — فقط برای همگامسازی اندازه؛ مالکیتش با useMapLibre است */
|
||||||
|
map: maplibregl.Map | null
|
||||||
|
status: MapStatus
|
||||||
|
onRetry: () => void
|
||||||
|
/** برچسب دسترسپذیری ناحیهی نقشه */
|
||||||
|
label: string
|
||||||
|
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||||
|
className?: string
|
||||||
|
/** محتوای شناور روی نقشه (پورتال پاپآپ) */
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پوستهی نقشه — قاب شیشهای، حالت تمامصفحه، کنترل بزرگنمایی و لایههای
|
||||||
|
* بارگذاری/خطا.
|
||||||
|
*
|
||||||
|
* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد مینشیند (نه absolute)؛
|
||||||
|
* استایل خود مپلیبر بعد از تیلویند بار میشود و position: relative میدهد،
|
||||||
|
* پس کلاس absolute بیاثر میشد و ظرف صفر میماند.
|
||||||
|
*
|
||||||
|
* در تمامصفحه فقط شفافیت و گوشهها انیمیت میشوند تا بافر بوم هرگز کشیده
|
||||||
|
* و تار نشود؛ تغییر اندازه در همان فریم اعمال میشود.
|
||||||
|
*/
|
||||||
|
export function MapViewport({
|
||||||
|
containerRef,
|
||||||
|
map,
|
||||||
|
status,
|
||||||
|
onRetry,
|
||||||
|
label,
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
}: MapViewportProps) {
|
||||||
|
const { t } = useLang()
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
|
||||||
|
/** تمامصفحه: Esc برای خروج و قفل اسکرول پسزمینه */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullscreen) return
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setIsFullscreen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
const previousOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.body.style.overflow = previousOverflow
|
||||||
|
}
|
||||||
|
}, [isFullscreen])
|
||||||
|
|
||||||
|
/** پس از پایان انتقال، بوم را همگام کن (مپلیبر خودش هم ResizeObserver دارد) */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullscreen) return
|
||||||
|
const timer = window.setTimeout(() => map?.resize(), 340)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [isFullscreen, map])
|
||||||
|
|
||||||
|
const shellClass = isFullscreen
|
||||||
|
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
|
||||||
|
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
aria-label={label}
|
||||||
|
className={`overflow-hidden bg-char font-pinar transition-all duration-300 ease-lux ${shellClass}`}
|
||||||
|
>
|
||||||
|
{/* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد است؛ کلاس relative صریح
|
||||||
|
است تا هم بستر موقعیتیابی فرزندان مپلیبر باشد و هم هشدار
|
||||||
|
«container has a non-static position» و محاسبهی اشتباه آفست اسکرول
|
||||||
|
رخ ندهد — ترتیب بار شدن CSS هیچوقت تعیینکننده نباشد */}
|
||||||
|
<div ref={containerRef} className="relative h-full w-full" />
|
||||||
|
|
||||||
|
{isFullscreen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
aria-label={t('mapExitFullscreen')}
|
||||||
|
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
<MdClose className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t('mapExitFullscreen')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsFullscreen(true)}
|
||||||
|
aria-label={t('mapFullscreen')}
|
||||||
|
title={t('mapFullscreen')}
|
||||||
|
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||||
|
>
|
||||||
|
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status !== 'ready' && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon className="h-7 w-7 animate-spin text-copper" aria-hidden="true" />
|
||||||
|
<p className="text-xs font-light tracking-wide text-ivory/60">{t('mapLoading')}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="max-w-xs text-center text-xs font-light leading-6 text-ivory/60">
|
||||||
|
{t('mapErrorText')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="border border-copper/50 px-6 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
{t('retry')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState, type MouseEvent, type PointerEvent } from 'react'
|
||||||
|
import { MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||||
|
import { ProjectImagePlaceholder } from '../components/ProjectImagePlaceholder'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
import { toPersianDigits } from '../utils/digits'
|
||||||
|
|
||||||
|
type ProjectImageSliderProps = {
|
||||||
|
/** همان آرایهٔ تصاویر صفحهٔ جزئیات پروژه — در نبودش جاینگهدار میآید */
|
||||||
|
images: string[]
|
||||||
|
alt: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اسلایدر تصویر لبهتالبهٔ بالای کارت پاپآپ (سبک Zillow): فلشهای چپ/راست،
|
||||||
|
* شمارندهٔ اسلاید و گوشههای گرد هماندازهٔ کارت. همهٔ رویدادهای اشاره روی
|
||||||
|
* اسلایدر متوقف میشوند تا کلیک/کشیدن آن، نقشه را جابهجا یا پاپآپ را نبندد.
|
||||||
|
*/
|
||||||
|
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
|
||||||
|
const { t } = useLang()
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
|
||||||
|
/** هیچ رویدادی از اسلایدر به نقشهٔ زیرین نمیرسد */
|
||||||
|
const stop = (event: MouseEvent | PointerEvent) => event.stopPropagation()
|
||||||
|
|
||||||
|
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
|
||||||
|
|
||||||
|
if (!images.length) {
|
||||||
|
return (
|
||||||
|
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||||
|
<ProjectImagePlaceholder />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = Math.min(index, images.length - 1)
|
||||||
|
const step = (delta: number) => setIndex((value) => (value + delta + images.length) % images.length)
|
||||||
|
|
||||||
|
const arrow =
|
||||||
|
'absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/85 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||||
|
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('prevImage')}
|
||||||
|
title={t('prevImage')}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
step(-1)
|
||||||
|
}}
|
||||||
|
className={`${arrow} left-2`}
|
||||||
|
>
|
||||||
|
<MdChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('nextImage')}
|
||||||
|
title={t('nextImage')}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
step(1)
|
||||||
|
}}
|
||||||
|
className={`${arrow} right-2`}
|
||||||
|
>
|
||||||
|
<MdChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-ink/75 px-2.5 py-0.5 text-[10px] font-light text-ivory/85 backdrop-blur-md">
|
||||||
|
{toPersianDigits(current + 1)} / {toPersianDigits(images.length)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MdLocationOn } from 'react-icons/md'
|
||||||
|
|
||||||
|
type ProjectPinProps = {
|
||||||
|
label: string
|
||||||
|
/** پروژهی انتخابشده — هالهی مسی پایدار */
|
||||||
|
active?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سنجاق پروژه روی نقشه — آیکون MdLocationOn در رنگ مسی برند با هالهی
|
||||||
|
* نورانی در هاور، کلیک و حالت انتخابشده. این کامپوننت داخل المان نشانگر
|
||||||
|
* مپلیبر رندر میشود، پس خودش هیچ چیدمان بیرونی ندارد.
|
||||||
|
*/
|
||||||
|
export function ProjectPin({ label, active = false, onClick }: ProjectPinProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
}}
|
||||||
|
className="group flex cursor-pointer items-end justify-center bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||||
|
>
|
||||||
|
<MdLocationOn
|
||||||
|
className={`h-9 w-9 text-[#d5874c] transition-all duration-300 ease-lux group-hover:scale-125 group-hover:drop-shadow-[0_0_14px_rgba(213,135,76,0.95)] group-active:scale-110 group-active:drop-shadow-[0_0_18px_rgba(213,135,76,1)] ${
|
||||||
|
active
|
||||||
|
? 'scale-110 drop-shadow-[0_0_16px_rgba(213,135,76,0.95)]'
|
||||||
|
: 'drop-shadow-[0_3px_6px_rgba(0,0,0,0.65)]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { MdClose } from 'react-icons/md'
|
||||||
|
import type { MapProject } from '../data/mapProjects'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
import { ProjectImageSlider } from './ProjectImageSlider'
|
||||||
|
|
||||||
|
/** دکمههای کارت — تمامعرض، همخانوادهٔ دکمههای مسی سایت (در ستون هم درست مینشینند) */
|
||||||
|
export const POPUP_PRIMARY_CLASS =
|
||||||
|
'flex w-full items-center justify-center gap-1.5 rounded-xl bg-copper px-3 py-2.5 text-[12px] font-bold text-char transition-all duration-300 ease-lux hover:scale-[1.03] hover:shadow-[0_8px_22px_rgba(213,135,76,0.35)] active:scale-95'
|
||||||
|
|
||||||
|
export const POPUP_SECONDARY_CLASS =
|
||||||
|
'flex w-full items-center justify-center gap-1.5 rounded-xl border border-ivory/20 px-3 py-2.5 text-[12px] font-light text-ivory/80 transition-colors duration-300 ease-lux hover:border-copper hover:text-copper'
|
||||||
|
|
||||||
|
type ProjectPopupCardProps = {
|
||||||
|
project: MapProject
|
||||||
|
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جاینگهدار میآید */
|
||||||
|
images?: string[]
|
||||||
|
onClose: () => void
|
||||||
|
/** دکمههای پایهٔ کارت — هر نما اکشنهای خودش را میدهد (عمودی روی هم) */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کارت پاپآپ پروژه به سبک Zillow — اسلایدر لبهتالبه در بالاترین بخش، سپس
|
||||||
|
* عنوان و توضیح، و در پایه دکمههای عمودی. راستبهچپ، شیشهای تیره و
|
||||||
|
* هماندازهٔ کارتهای سایت. سال پروژه عمداً نمایش داده نمیشود.
|
||||||
|
*/
|
||||||
|
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
|
||||||
|
const { t } = useLang()
|
||||||
|
const description = project.description.trim() || t('mapProjectDescription')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
dir="rtl"
|
||||||
|
className="w-[17rem] max-w-[calc(100vw-2.5rem)] animate-[luxPopupIn_0.22s_ease-out] overflow-hidden rounded-2xl border border-ivory/10 bg-[#27221c]/95 font-pinar text-ivory shadow-[0_24px_60px_-15px_rgba(0,0,0,0.9)] backdrop-blur-xl sm:w-72"
|
||||||
|
>
|
||||||
|
{/* اسلایدر تصویر: لبهتالبه، بدون حاشیه، با فلشهای چپ/راست */}
|
||||||
|
<div className="relative">
|
||||||
|
<ProjectImageSlider images={images} alt={project.name} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('close')}
|
||||||
|
title={t('close')}
|
||||||
|
className="absolute left-2.5 top-2.5 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
<MdClose className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{project.isNew && (
|
||||||
|
<span className="absolute right-2.5 top-2.5 z-20 rounded-full bg-copper px-2.5 py-1 text-[10px] font-bold text-char shadow-[0_4px_14px_rgba(213,135,76,0.4)]">
|
||||||
|
{t('mapNewBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-1 pt-3.5">
|
||||||
|
<h3 className="min-w-0 truncate text-base font-bold text-ivory">{project.name}</h3>
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-[11px] font-light leading-6 text-ivory/60">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 p-3 pt-2.5">{children}</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
|
||||||
|
import {
|
||||||
|
ISFAHAN_CENTER,
|
||||||
|
MAP_PROJECTS,
|
||||||
|
boundsForProjects,
|
||||||
|
buildNeshanUrl,
|
||||||
|
type MapProject,
|
||||||
|
} from '../data/mapProjects'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
import { MapViewport } from './MapViewport'
|
||||||
|
import { POPUP_PRIMARY_CLASS, POPUP_SECONDARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||||
|
import { useMapLibre } from './useMapLibre'
|
||||||
|
import { useProjectMarkers } from './useProjectMarkers'
|
||||||
|
|
||||||
|
export type ProjectsMapAllProps = {
|
||||||
|
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||||
|
className?: string
|
||||||
|
/** فهرست پروژهها — پیشفرض همهی ردیفهای شیت */
|
||||||
|
projects?: MapProject[]
|
||||||
|
/**
|
||||||
|
* نشانی صفحهی جزئیات هر پروژه. اگر تطبیقی پیدا نشود، دکمهی «مشاهده پروژه»
|
||||||
|
* به فهرست پروژهها میرود تا هیچ لینکی بیمقصد نماند.
|
||||||
|
*/
|
||||||
|
resolveProjectHref?: (project: MapProject) => string | undefined
|
||||||
|
/** آرایهٔ تصاویر هر پروژه (همان آرایهٔ صفحهٔ جزئیات) — در نبودش جاینگهدار */
|
||||||
|
imagesFor?: (project: MapProject) => string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمای «همهی پروژهها» — همهی مختصات شیت روی یک نقشهی تمامعرض با
|
||||||
|
* سنجاقهای مسی، پاپآپ کارتی و دو اکشن عمودی: صفحهی پروژه و آدرس نشان.
|
||||||
|
* پاپآپ فقط با کلیک کاربر باز میشود و جابهجایی نقشه به محدودهٔ پروژهها
|
||||||
|
* بسته است.
|
||||||
|
*/
|
||||||
|
export function ProjectsMapAll({
|
||||||
|
className = '',
|
||||||
|
projects = MAP_PROJECTS,
|
||||||
|
resolveProjectHref,
|
||||||
|
imagesFor,
|
||||||
|
}: ProjectsMapAllProps) {
|
||||||
|
const { t, href } = useLang()
|
||||||
|
const bounds = useMemo(() => boundsForProjects(projects), [projects])
|
||||||
|
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
|
||||||
|
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||||
|
|
||||||
|
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
|
||||||
|
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapViewport
|
||||||
|
containerRef={containerRef}
|
||||||
|
map={map}
|
||||||
|
status={status}
|
||||||
|
onRetry={retry}
|
||||||
|
label={t('mapAria')}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{popupHost &&
|
||||||
|
activeProject &&
|
||||||
|
createPortal(
|
||||||
|
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||||
|
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
|
||||||
|
{t('viewProject')}
|
||||||
|
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
{/* منطق دکمهٔ آدرس دستنخورده — فقط در ستون عمودی نشسته است */}
|
||||||
|
<a
|
||||||
|
href={buildNeshanUrl(activeProject)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={POPUP_SECONDARY_CLASS}
|
||||||
|
>
|
||||||
|
{t('viewAddress')}
|
||||||
|
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</ProjectPopupCard>,
|
||||||
|
popupHost,
|
||||||
|
)}
|
||||||
|
</MapViewport>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { MdOpenInNew } from 'react-icons/md'
|
||||||
|
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
|
||||||
|
import { useLang } from '../i18n/lang'
|
||||||
|
import { MapViewport } from './MapViewport'
|
||||||
|
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||||
|
import { useMapLibre } from './useMapLibre'
|
||||||
|
import { useProjectMarkers } from './useProjectMarkers'
|
||||||
|
|
||||||
|
export type ProjectsMapSingleProps = {
|
||||||
|
/** همان پروژهی شیت — یا محل جایگزین ساختهشده با createPlaceholderProject */
|
||||||
|
location: MapProject
|
||||||
|
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات (تصویر شاخص + گالری) */
|
||||||
|
images?: string[]
|
||||||
|
/** ارتفاع/فاصلهها از بیرون — مثل «h-80» */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نمای «تکپروژه» — یک سنجاق دقیق روی مختصات همان پروژه و پاپآپی که فقط با
|
||||||
|
* کلیک کاربر باز میشود. تنها اکشن کارت، مشاهدهٔ آدرس پروژه در نشان است.
|
||||||
|
* جابهجایی نقشه به محدودهٔ همین پروژه بسته شده تا به بیرون نرود.
|
||||||
|
*/
|
||||||
|
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
|
||||||
|
const { t } = useLang()
|
||||||
|
const projects = useMemo(() => [location], [location])
|
||||||
|
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
|
||||||
|
const { containerRef, map, status, retry } = useMapLibre({
|
||||||
|
center: [location.lng, location.lat],
|
||||||
|
zoom: 15,
|
||||||
|
maxBounds: bounds,
|
||||||
|
})
|
||||||
|
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapViewport
|
||||||
|
containerRef={containerRef}
|
||||||
|
map={map}
|
||||||
|
status={status}
|
||||||
|
onRetry={retry}
|
||||||
|
label={t('mapAria')}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{popupHost &&
|
||||||
|
activeProject &&
|
||||||
|
createPortal(
|
||||||
|
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||||
|
{/* منطق دکمهٔ آدرس دستنخورده است — تنها اکشن این کارت */}
|
||||||
|
<a
|
||||||
|
href={buildNeshanUrl(activeProject)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={POPUP_PRIMARY_CLASS}
|
||||||
|
>
|
||||||
|
{t('viewAddress')}
|
||||||
|
<MdOpenInNew className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</ProjectPopupCard>,
|
||||||
|
popupHost,
|
||||||
|
)}
|
||||||
|
</MapViewport>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as maplibregl from 'maplibre-gl'
|
||||||
|
import { Protocol } from 'pmtiles'
|
||||||
|
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||||
|
|
||||||
|
let setup: Promise<void> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آمادهسازی وبورکر نقشه — یکبار برای همه نقشهها.
|
||||||
|
*
|
||||||
|
* ۱) نشانی وبورکر مپلیبر صریح تعیین میشود: مپلیبر بهصورت پیشفرض
|
||||||
|
* «./maplibre-gl-worker.mjs» را کنار باندل جستوجو میکند که در خروجی ویت
|
||||||
|
* وجود ندارد و ورکر ۴۰۴ میشود؛ نسخهی ساختهشدهی ویت را با setWorkerUrl
|
||||||
|
* میدهیم (نشانی مطلق، چون پایهی ورکر میتواند Blob باشد).
|
||||||
|
* ۲) پروتکل pmtiles در نخ اصلی ثبت میشود.
|
||||||
|
* ۳) همان پروتکل با importScriptInWorkers داخل وبورکر هم ثبت میشود —
|
||||||
|
* کاشیهای برداری آنجا بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||||
|
* جداست. اسکریپت وبورکر، خروجی خودبسندهی public/map/pmtiles-worker.mjs
|
||||||
|
* است (ساختهشده از src/map/pmtiles.worker.ts با npm run build:map-worker).
|
||||||
|
*
|
||||||
|
* تا پایان بارگیری اسکریپت صبر میکنیم تا نخستین درخواستهای کاشی در وبورکر
|
||||||
|
* بیپاسخ نمانند.
|
||||||
|
*/
|
||||||
|
export function ensureMapWorker(): Promise<void> {
|
||||||
|
if (!setup) {
|
||||||
|
maplibregl.setWorkerUrl(new URL(maplibreWorkerUrl, window.location.href).href)
|
||||||
|
maplibregl.addProtocol('pmtiles', new Protocol().tile)
|
||||||
|
|
||||||
|
const protocolScript = new URL(
|
||||||
|
`${import.meta.env.BASE_URL}map/pmtiles-worker.mjs`,
|
||||||
|
window.location.href,
|
||||||
|
).href
|
||||||
|
setup = maplibregl.importScriptInWorkers(protocolScript)
|
||||||
|
}
|
||||||
|
return setup
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Protocol } from 'pmtiles'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اسکریپت سمت وبورکر مپلیبر — با importScriptInWorkers داخل وبورکر اجرا
|
||||||
|
* میشود و مپلیبر تابع addProtocol را روی self وبورکر گذاشته است.
|
||||||
|
*
|
||||||
|
* کاشیهای برداری در وبورکر بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||||
|
* جداست؛ بدون این ثبت، درخواستهای pmtiles در وبورکر بیپاسخ میمانند و
|
||||||
|
* نقشه فقط پسزمینه را نشان میدهد.
|
||||||
|
*/
|
||||||
|
const workerScope = self as unknown as {
|
||||||
|
addProtocol: (name: string, loadFn: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
workerScope.addProtocol('pmtiles', new Protocol().tile)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { SourceSpecification, StyleSpecification } from 'maplibre-gl'
|
||||||
|
import { MAP_BASE_URL } from '../config'
|
||||||
|
|
||||||
|
const PMTILES_SCHEME = 'pmtiles://'
|
||||||
|
/** نشانیهای مطلق — دستنخورده میمانند */
|
||||||
|
const EXTERNAL = /^(https?:|data:|blob:)/i
|
||||||
|
/** تنها آرشیو برداری باکت — منبع برداری سبک همیشه به همین جا میرسد */
|
||||||
|
const PMTILES_ARCHIVE = 'iran.pmtiles'
|
||||||
|
/** نام منبع برداری در سبکهای استاندارد OpenMapTiles */
|
||||||
|
const OPENMAPTILES_KEY = 'openmaptiles'
|
||||||
|
/** پوشه فونتهای گلیف در باکت MinIO — سبک خام فقط «{fontstack}/{range}.pbf» دارد */
|
||||||
|
const GLYPHS_FOLDER = 'v2.0'
|
||||||
|
|
||||||
|
function joinUrl(base: string, path: string): string {
|
||||||
|
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نشانی آرشیو برداری روی MinIO — تنها منبع کاشی این پروژه */
|
||||||
|
function archiveUrl(base: string): string {
|
||||||
|
return `${PMTILES_SCHEME}${joinUrl(base, PMTILES_ARCHIVE)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جایگذاری جانگهدارهای سبک — «{styleJsonFolder}» به آدرس پایه MinIO تبدیل
|
||||||
|
* میشود. جانگهدارهای {fontstack} و {range} قانونی خود مپلیبرند و دستنخورده
|
||||||
|
* میمانند. جانگهدار تازهای در سبکهای بعدی دیده شد، به همین نگاشت اضافه شود.
|
||||||
|
*/
|
||||||
|
function resolvePlaceholders(url: string, base: string): string {
|
||||||
|
return url.replace(/\{styleJsonFolder\}/g, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مطلقسازی نشانیهای عمومی (گلیف، اسپرایت، منابع غیربرداری) */
|
||||||
|
function absolutize(resource: string, base: string): string {
|
||||||
|
const url = resolvePlaceholders(resource.trim(), base)
|
||||||
|
if (EXTERNAL.test(url)) return url
|
||||||
|
|
||||||
|
if (url.startsWith(PMTILES_SCHEME)) {
|
||||||
|
const target = url.slice(PMTILES_SCHEME.length).trim()
|
||||||
|
return EXTERNAL.test(target) ? url : PMTILES_SCHEME + joinUrl(base, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinUrl(base, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نشانی گلیفها — سبک خام فقط الگوی «{fontstack}/{range}.pbf» را دارد و پوشه
|
||||||
|
* فونتها را نمیآورد، اما فونتهای باکت زیر پوشه v2.0 هستند؛ پس الگو همیشه
|
||||||
|
* به همان پوشه میرسد و جانگهدارهای {fontstack} و {range} دستنخورده میمانند.
|
||||||
|
*/
|
||||||
|
function resolveGlyphsUrl(url: string, base: string): string {
|
||||||
|
const resolved = resolvePlaceholders(url.trim(), base)
|
||||||
|
if (EXTERNAL.test(resolved)) return resolved
|
||||||
|
|
||||||
|
if (resolved.includes('{fontstack}')) {
|
||||||
|
const template = resolved.slice(resolved.indexOf('{fontstack}'))
|
||||||
|
return joinUrl(base, joinUrl(GLYPHS_FOLDER, template))
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinUrl(base, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** منبع برداری سبک — با نوع vector یا نام شناختهشده openmaptiles */
|
||||||
|
function isVectorSource(key: string, source: SourceSpecification): boolean {
|
||||||
|
return source.type === 'vector' || key === OPENMAPTILES_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بازنویسی نشانی منبع برداری. سبکهای ساختهشده برای سرور MBTiles (مثل
|
||||||
|
* «mbtiles://{v3}» در tileserver-gl) کاشی برداری را با طرح mbtiles آدرس
|
||||||
|
* میدهند که مپلیبر نمیشناسد؛ هر شکلی از نشانی اینجا به آرشیو iran.pmtiles
|
||||||
|
* روی MinIO هدایت میشود تا با پروتکل pmtiles رندر شود.
|
||||||
|
*/
|
||||||
|
function resolveVectorUrl(url: string, base: string): string {
|
||||||
|
const resolved = resolvePlaceholders(url.trim(), base)
|
||||||
|
|
||||||
|
if (resolved.startsWith(PMTILES_SCHEME)) {
|
||||||
|
const target = resolved.slice(PMTILES_SCHEME.length).trim()
|
||||||
|
return EXTERNAL.test(target) ? resolved : PMTILES_SCHEME + joinUrl(base, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return archiveUrl(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پالایش سبک نقشه پیش از تحویل به مپلیبر — style.json خام MinIO با
|
||||||
|
* جانگهدار ({styleJsonFolder}) و نشانی MBTiles نوشته شده است؛ این تابع با یک
|
||||||
|
* پاس، گلیف/اسپرایت را به آدرس مطلق MinIO و منبع برداری را به آرشیو
|
||||||
|
* iran.pmtiles تبدیل میکند تا مستقیم به «new Map({ style })» داده شود.
|
||||||
|
*/
|
||||||
|
export function rewriteStyleUrls(
|
||||||
|
style: StyleSpecification,
|
||||||
|
base: string = MAP_BASE_URL,
|
||||||
|
): StyleSpecification {
|
||||||
|
const next: StyleSpecification = { ...style }
|
||||||
|
|
||||||
|
if (typeof next.glyphs === 'string') {
|
||||||
|
next.glyphs = resolveGlyphsUrl(next.glyphs, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// اسپرایت در نسخه ۸ مشخصات سبک، رشته یا فهرست {id, url} است
|
||||||
|
if (typeof next.sprite === 'string') {
|
||||||
|
next.sprite = absolutize(next.sprite, base)
|
||||||
|
} else if (Array.isArray(next.sprite)) {
|
||||||
|
next.sprite = next.sprite.map((sprite) => ({ ...sprite, url: absolutize(sprite.url, base) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources: StyleSpecification['sources'] = {}
|
||||||
|
for (const [key, source] of Object.entries(next.sources ?? {})) {
|
||||||
|
const rewritten = { ...source } as SourceSpecification & { url?: unknown; tiles?: unknown }
|
||||||
|
|
||||||
|
if (typeof rewritten.url === 'string') {
|
||||||
|
const raw = rewritten.url.trim()
|
||||||
|
if (isVectorSource(key, source)) {
|
||||||
|
rewritten.url = resolveVectorUrl(raw, base)
|
||||||
|
} else {
|
||||||
|
// رندر سختگیرانهی برداری: منابع غیربرداری (مثل raster) هرگز به آرشیو
|
||||||
|
// برداری هدایت نمیشوند — کاشی MVT زیر منبع raster یعنی خروجی خراب.
|
||||||
|
if (import.meta.env.DEV && source.type === 'raster') {
|
||||||
|
console.warn(`[map] منبع غیربرداری «${key}» در سبک هست؛ افزایش رزولوشن برداری برای آن اعمال نمیشود`)
|
||||||
|
}
|
||||||
|
rewritten.url = absolutize(raw, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(rewritten.tiles)) {
|
||||||
|
rewritten.tiles = rewritten.tiles.map((tile) =>
|
||||||
|
typeof tile === 'string' ? absolutize(tile, base) : tile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sources[key] = rewritten
|
||||||
|
}
|
||||||
|
next.sources = sources
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** بارگیری style.json از MinIO و بازگرداندن سبک پالایششده برای «new Map({ style })» */
|
||||||
|
export async function loadMapStyle(signal?: AbortSignal): Promise<StyleSpecification> {
|
||||||
|
const res = await fetch(joinUrl(MAP_BASE_URL, 'style.json'), { signal })
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return rewriteStyleUrls((await res.json()) as StyleSpecification)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { RequestTransformFunction } from 'maplibre-gl'
|
||||||
|
|
||||||
|
/** دو بخش آخر مسیر گلیف: پوشهی پشتهی فونت و فایل بازه */
|
||||||
|
const GLYPH_PATH = /\/([^/?#]+)\/([^/?#]+\.pbf)$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نشانی گلیف با تنها فونت نخستِ پشته.
|
||||||
|
*
|
||||||
|
* مپلیبر برای هر لایه، تمام `text-font` را با کاما در {fontstack} میگذارد
|
||||||
|
* (مثل «Metropolis Regular,Noto Sans Regular»)؛ سرورهای عمومی فونت، پوشهی
|
||||||
|
* همین پشتهی ترکیبی را دارند اما MinIO این پروژه فونتها را در پوشههای
|
||||||
|
* تکفونتی نگه میدارد. نتیجهی آن درخواستها ۴۰۴ بود، متنها رندر نمیشدند و
|
||||||
|
* سیل خطا اپ را قفل میکرد. با این بازنویسی فقط فونت نخست — همان ترجیح اول
|
||||||
|
* سبک — درخواست میشود و بقیهی مسیر (پوشهی v2.0 و فایل بازه) دستنخورده
|
||||||
|
* میماند. نشانی بدون پشتهی ترکیبی یا ناشناس، بدون تغییر برمیگردد.
|
||||||
|
*/
|
||||||
|
export function singleFontGlyphUrl(url: string): string {
|
||||||
|
const queryIndex = url.search(/[?#]/)
|
||||||
|
const path = queryIndex === -1 ? url : url.slice(0, queryIndex)
|
||||||
|
const suffix = queryIndex === -1 ? '' : url.slice(queryIndex)
|
||||||
|
|
||||||
|
const match = path.match(GLYPH_PATH)
|
||||||
|
if (!match) return url
|
||||||
|
|
||||||
|
let fonts: string[]
|
||||||
|
try {
|
||||||
|
fonts = match[1].split(',').map((font) => decodeURIComponent(font).trim())
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if (fonts.length < 2 || !fonts[0]) return url
|
||||||
|
|
||||||
|
return `${path.slice(0, match.index)}/${encodeURIComponent(fonts[0])}/${match[2]}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قلاب تبدیل درخواستهای نقشه — فقط گلیفها بازنویسی میشوند و هر درخواست
|
||||||
|
* دیگری دستنخورده میماند تا رفتار پیشفرض مپلیبر حفظ شود.
|
||||||
|
*/
|
||||||
|
export const mapTransformRequest: RequestTransformFunction = (url, resourceType) =>
|
||||||
|
resourceType === 'Glyphs' ? { url: singleFontGlyphUrl(url) } : { url }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** وضعیت چرخهی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
|
||||||
|
export type MapStatus = 'loading' | 'ready' | 'error'
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import * as maplibregl from 'maplibre-gl'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ensureMapWorker } from './mapWorker'
|
||||||
|
import { loadMapStyle } from './styleLoader'
|
||||||
|
import { mapTransformRequest } from './transformRequest'
|
||||||
|
import type { MapStatus } from './types'
|
||||||
|
|
||||||
|
type UseMapLibreOptions = {
|
||||||
|
center: [number, number]
|
||||||
|
zoom: number
|
||||||
|
/** بزرگنمایی با چرخ ماوس و پینچ لمسی — پیشفرض فعال */
|
||||||
|
scrollZoom?: boolean
|
||||||
|
/** محدودهٔ مجاز جابهجایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژهها */
|
||||||
|
maxBounds?: maplibregl.LngLatBoundsLike
|
||||||
|
}
|
||||||
|
|
||||||
|
/** سقف انتظار سبک نقشه — سرور کاشی بیپاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بیپایان */
|
||||||
|
const STYLE_TIMEOUT_MS = 10_000
|
||||||
|
/** اگر رویداد style.load دیر برسد، وضعیت آماده با این مهلت نمایش داده میشود */
|
||||||
|
const READY_FALLBACK_MS = 2_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سقف بزرگنمایی = maxzoom آرشیو + همین مقدار سطح overzoom.
|
||||||
|
*
|
||||||
|
* سنجش روی تایلهای واقعی z14 اصفهان (۲۶٫۸۷۴ پارهخط جاده): میانهی وتر ۲۰
|
||||||
|
* متر و صدک ۷۵ برابر ۳۶ متر است. با سه سطح overzoom (زوم ۱۷، ۰٫۵ متر بر
|
||||||
|
* پیکسل) میانه ۴۰ و صدک ۷۵ حدود ۷۲ پیکسل میشود که خمها را هنوز صاف نشان
|
||||||
|
* میدهد؛ از ۱۷٫۵ به بعد وترها به ۱۰۰ پیکسل و بیشتر میرسند و چندضلعیشدن
|
||||||
|
* خمها دیده میشود. متن و آیکون هرگز تار نمیشوند (متن SDF و آیکون اسپرایت
|
||||||
|
* است)؛ افت کیفیت فقط از هندسهی سادهشده میآید. با بازتولید آرشیو در z16
|
||||||
|
* این سقف خودکار به ۱۹ میرسد.
|
||||||
|
*/
|
||||||
|
const OVERZOOM_LEVELS = 3
|
||||||
|
/** سقف مطلق زوم — بالاتر از آن حتی با دادهی دقیقتر هم نمایش مرورگر بیمعناست */
|
||||||
|
const ABSOLUTE_MAX_ZOOM = 22
|
||||||
|
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
|
||||||
|
const FALLBACK_MAX_ZOOM = 17
|
||||||
|
|
||||||
|
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [map, setMap] = useState<maplibregl.Map | null>(null)
|
||||||
|
const [status, setStatus] = useState<MapStatus>('loading')
|
||||||
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
let disposed = false
|
||||||
|
let instance: maplibregl.Map | null = null
|
||||||
|
let readyFallback: number | undefined
|
||||||
|
let dprQuery: MediaQueryList | null = null
|
||||||
|
let handleDprChange: (() => void) | null = null
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
|
||||||
|
|
||||||
|
setStatus('loading')
|
||||||
|
setMap(null)
|
||||||
|
|
||||||
|
// وبورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
|
||||||
|
// وگرنه نخستین درخواستهای کاشی در وبورکر بیپاسخ میمانند
|
||||||
|
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
|
||||||
|
.then(([style]) => {
|
||||||
|
if (disposed) return
|
||||||
|
instance = new maplibregl.Map({
|
||||||
|
container,
|
||||||
|
style,
|
||||||
|
center,
|
||||||
|
zoom,
|
||||||
|
maxBounds,
|
||||||
|
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق میکند
|
||||||
|
maxZoom: FALLBACK_MAX_ZOOM,
|
||||||
|
renderWorldCopies: false,
|
||||||
|
// لبههای نرمتر خطوط و چندضلعیها — حس لوکس رندر برداری
|
||||||
|
canvasContextAttributes: { antialias: true },
|
||||||
|
// کیفیت حداکثری در بزرگنماییهای عمیق: هیچ سطحی با مقیاسکردن سطح
|
||||||
|
// بالاتر رندر نمیشود و همهی سطحها تا maxZoom از تقسیم هندسه
|
||||||
|
// میآیند (پیشفرض ۴ سطح آخر را overscale میکند). مبادله: کار
|
||||||
|
// بیشتر پردازنده در زومهای عمیق.
|
||||||
|
zoomLevelsToOverscale: 0,
|
||||||
|
// چرخ ماوس، پینچ لمسی و دکمههای کنترل — مطابق درخواست کاربر
|
||||||
|
scrollZoom,
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
// MinIO فونتها را در پوشههای تکفونتی دارد، پس پشتهی ترکیبی
|
||||||
|
// text-font («Metropolis Regular,Noto Sans Regular») به فونت نخست
|
||||||
|
// بازنویسی میشود — توضیح کامل در transformRequest.ts
|
||||||
|
transformRequest: mapTransformRequest,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سقف بزرگنمایی را از maxzoom واقعی آرشیو میسازد؛ با دادهی دقیقتر
|
||||||
|
* (بازتولید آرشیو در z بالاتر) خودکار بالاتر میرود و کیفیت روی صفحه
|
||||||
|
* ثابت میماند. زوم جاری هم اگر از سقف بیشتر باشد به آن محدود میشود.
|
||||||
|
*/
|
||||||
|
const applyZoomLimit = () => {
|
||||||
|
if (!instance) return
|
||||||
|
const source = instance.getSource('openmaptiles') as { maxzoom?: number } | undefined
|
||||||
|
const sourceMaxZoom = typeof source?.maxzoom === 'number' ? source.maxzoom : null
|
||||||
|
instance.setMaxZoom(
|
||||||
|
sourceMaxZoom === null
|
||||||
|
? FALLBACK_MAX_ZOOM
|
||||||
|
: Math.min(sourceMaxZoom + OVERZOOM_LEVELS, ABSOLUTE_MAX_ZOOM),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.on('style.load', () => {
|
||||||
|
if (disposed) return
|
||||||
|
setStatus('ready')
|
||||||
|
window.setTimeout(() => instance?.resize(), 150)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کنترل بزرگنمایی همراه خود نقشه ساخته میشود و با map.remove() پایین
|
||||||
|
* میآید — map.remove بهتنهایی onRemove همهی کنترلها را صدا میزند،
|
||||||
|
* پس removeControl دستی پس از آن، onRemove را دوباره اجرا میکند و با
|
||||||
|
* «reading 'off' of undefined» کرش میکند. مالکیت کنترل همینجا، کنار
|
||||||
|
* مالکیت نقشه میماند تا ترتیب پاکسازی هیچوقت به React وابسته نباشد.
|
||||||
|
*/
|
||||||
|
instance.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right')
|
||||||
|
|
||||||
|
// maxzoom منبع همراه متادیتای TileJSON میرسد — همان لحظه سقف را قطعی کن
|
||||||
|
instance.on('sourcedata', (e) => {
|
||||||
|
if (e.sourceDataType === 'metadata' && e.sourceId === 'openmaptiles') applyZoomLimit()
|
||||||
|
})
|
||||||
|
instance.on('load', applyZoomLimit)
|
||||||
|
applyZoomLimit()
|
||||||
|
|
||||||
|
// تایمر فالبک امنیتی
|
||||||
|
readyFallback = window.setTimeout(() => {
|
||||||
|
if (disposed) return
|
||||||
|
setStatus('ready')
|
||||||
|
instance?.resize()
|
||||||
|
}, READY_FALLBACK_MS)
|
||||||
|
|
||||||
|
instance.on('error', (e) => {
|
||||||
|
console.warn('MapLibre minor error:', e)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نسبت پیکسل دستگاه منبع حقیقت بافر بوم است؛ با زوم مرورگر
|
||||||
|
* (Ctrl+zoom) یا جابهجایی پنجره روی مانیتور با چگالی دیگر عوض
|
||||||
|
* میشود. اگر بافر با نسبت تازه همگام نشود، نقشه تا رفرش بعدی تار
|
||||||
|
* میماند. matchMedia هر تغییر را دقیقاً همان لحظه گزارش میدهد و
|
||||||
|
* پس از هر تغییر دوباره مسلح میشود.
|
||||||
|
*/
|
||||||
|
function syncPixelRatio() {
|
||||||
|
if (!instance) return
|
||||||
|
if (instance.getPixelRatio() !== window.devicePixelRatio) {
|
||||||
|
instance.setPixelRatio(window.devicePixelRatio)
|
||||||
|
}
|
||||||
|
instance.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchPixelRatio() {
|
||||||
|
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||||
|
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
|
||||||
|
handleDprChange = () => {
|
||||||
|
syncPixelRatio()
|
||||||
|
watchPixelRatio()
|
||||||
|
}
|
||||||
|
dprQuery.addEventListener('change', handleDprChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
watchPixelRatio()
|
||||||
|
|
||||||
|
// دسترسی اشکالزدایی در محیط توسعه — در بیلد تولیدی وجود ندارد
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
(window as unknown as { __map?: maplibregl.Map }).__map = instance
|
||||||
|
}
|
||||||
|
|
||||||
|
setMap(instance)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!disposed) setStatus('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
|
||||||
|
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||||
|
controller.abort()
|
||||||
|
instance?.remove()
|
||||||
|
}
|
||||||
|
// مرکز و بزرگنمایی فقط موقعیت اولیه نقشهاند؛ فهرست وابستگی عمداً فقط reloadKey است
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [reloadKey])
|
||||||
|
|
||||||
|
const retry = useCallback(() => setReloadKey((key) => key + 1), [])
|
||||||
|
|
||||||
|
return { containerRef, map, status, retry }
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import * as maplibregl from 'maplibre-gl'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { createRoot, type Root } from 'react-dom/client'
|
||||||
|
import type { MapProject } from '../data/mapProjects'
|
||||||
|
import { ProjectPin } from './ProjectPin'
|
||||||
|
|
||||||
|
type PinHandle = { project: MapProject; marker: maplibregl.Marker; root: Root }
|
||||||
|
|
||||||
|
/** حاشیهٔ امن پاپآپ از لبههای نقشه — بالای صفحه بازتر، برای نوار برچسب و کنترلها */
|
||||||
|
const AUTO_PAN_PADDING = { top: 150, bottom: 20, left: 20, right: 20 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نگهداشتن پاپآپ داخل کادر دید — معادل رفتار autoPan که مپلیبر (برخلاف
|
||||||
|
* مپباکس) گزینهاش را ندارد: اگر کارت از حاشیهٔ امن بیرون بزند، نقشه نرم
|
||||||
|
* جابهجا میشود تا کارت کامل دیده شود.
|
||||||
|
*/
|
||||||
|
function ensurePopupVisible(instance: maplibregl.Map, popup: maplibregl.Popup): void {
|
||||||
|
const element = popup.getElement()
|
||||||
|
const container = instance.getContainer()
|
||||||
|
if (!element || !container) return
|
||||||
|
|
||||||
|
// چیدمان پاپآپ در همان فریم addTo کامل میشود، پس اندازهگیری به فریم بعد موکول است
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const popupRect = element.getBoundingClientRect()
|
||||||
|
const viewRect = container.getBoundingClientRect()
|
||||||
|
|
||||||
|
const excessTop = viewRect.top + AUTO_PAN_PADDING.top - popupRect.top
|
||||||
|
const excessBottom = popupRect.bottom - (viewRect.bottom - AUTO_PAN_PADDING.bottom)
|
||||||
|
const excessLeft = viewRect.left + AUTO_PAN_PADDING.left - popupRect.left
|
||||||
|
const excessRight = popupRect.right - (viewRect.right - AUTO_PAN_PADDING.right)
|
||||||
|
|
||||||
|
// کمبود افقی و عمودی؛ دوربین به اندازهٔ بیرونزدگی جابهجا میشود
|
||||||
|
const dx = Math.max(0, excessLeft) - Math.max(0, excessRight)
|
||||||
|
const dy = Math.max(0, excessTop) - Math.max(0, excessBottom)
|
||||||
|
if (!dx && !dy) return
|
||||||
|
|
||||||
|
instance.panBy([-dx, -dy], { duration: 320 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لایهی نشانگرهای پروژه — ساخت نشانگرها با React (هر نشانگر یک ریشهی
|
||||||
|
* کوچک)، یک پاپآپ مشترک برای همه، و وضعیت پروژهی فعال.
|
||||||
|
*
|
||||||
|
* پاپآپ هیچوقت خودکار باز نمیشود؛ فقط با کلیک کاربر روی سنجاق. پاپآپ یک
|
||||||
|
* المان میزبان دارد که بیرون از React ساخته میشود؛ مصرفکننده محتوای React را
|
||||||
|
* با createPortal داخل همان میزبان میریزد تا Link روتر، حالت هاور و
|
||||||
|
* انیمیشنها همه در درخت React بمانند.
|
||||||
|
*/
|
||||||
|
export function useProjectMarkers(map: maplibregl.Map | null, projects: MapProject[]) {
|
||||||
|
const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null)
|
||||||
|
const [activeProject, setActiveProject] = useState<MapProject | null>(null)
|
||||||
|
|
||||||
|
const popupRef = useRef<maplibregl.Popup | null>(null)
|
||||||
|
const pinsRef = useRef<PinHandle[]>([])
|
||||||
|
const activeRef = useRef<MapProject | null>(null)
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
mapRef.current = map
|
||||||
|
|
||||||
|
const openProject = useCallback((project: MapProject) => {
|
||||||
|
const popup = popupRef.current
|
||||||
|
const instance = mapRef.current
|
||||||
|
if (!popup || !instance) return
|
||||||
|
activeRef.current = project
|
||||||
|
popup.setLngLat([project.lng, project.lat]).addTo(instance)
|
||||||
|
ensurePopupVisible(instance, popup)
|
||||||
|
setActiveProject(project)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closePopup = useCallback(() => {
|
||||||
|
activeRef.current = null
|
||||||
|
const popup = popupRef.current
|
||||||
|
// remove() فقط روی پاپآپ باز صدا زده میشود؛ پاپآپ بسته مالک مرورگر است
|
||||||
|
if (popup?.isOpen()) popup.remove()
|
||||||
|
setActiveProject(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** پاپآپ مشترک + المان میزبان محتوا — یکبار برای هر نمونه نقشه */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return
|
||||||
|
const host = document.createElement('div')
|
||||||
|
const popup = new maplibregl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
offset: 46,
|
||||||
|
maxWidth: 'none',
|
||||||
|
className: 'lux-map-popup',
|
||||||
|
}).setDOMContent(host)
|
||||||
|
|
||||||
|
// بستن با کلیک روی نقشه یا دکمهی کارت، وضعیت فعال را هم پاک میکند
|
||||||
|
popup.on('close', () => {
|
||||||
|
activeRef.current = null
|
||||||
|
setActiveProject(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
popupRef.current = popup
|
||||||
|
setPopupHost(host)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// فقط پاپآپِ باز پایین آورده میشود؛ popup.remove خودش هم به رویداد
|
||||||
|
// remove نقشه گوش میدهد، پس این پاکسازی تکراری و بیخطر است
|
||||||
|
if (popup.isOpen()) popup.remove()
|
||||||
|
popupRef.current = null
|
||||||
|
setPopupHost(null)
|
||||||
|
}
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
/** نشانگرها — با تغییر فهرست پروژهها یا نمونهی نقشه بازساخته میشوند */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return
|
||||||
|
const pins: PinHandle[] = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const element = document.createElement('div')
|
||||||
|
const marker = new maplibregl.Marker({ element, anchor: 'bottom' })
|
||||||
|
.setLngLat([project.lng, project.lat])
|
||||||
|
.addTo(map)
|
||||||
|
const root = createRoot(element)
|
||||||
|
root.render(<ProjectPin label={project.name} active={false} onClick={() => openProject(project)} />)
|
||||||
|
pins.push({ project, marker, root })
|
||||||
|
}
|
||||||
|
|
||||||
|
pinsRef.current = pins
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const pin of pins) {
|
||||||
|
// نشانگر خودش پاکسازی امن دارد (حذف شنوندهها + برداشتن المان)
|
||||||
|
pin.marker.remove()
|
||||||
|
/**
|
||||||
|
* unmount ریشهی React هنگام جدا شدن والد، وسط چرخهی رندر رخ میدهد و
|
||||||
|
* React 18 برای unmount همگام ریشهی تودرتو هشدار میدهد
|
||||||
|
* («Attempted to synchronously unmount a root while React was already
|
||||||
|
* rendering»). پس به میکروتسک بعد از پایان کامیت موکول میشود؛ المان
|
||||||
|
* نشانگر تا آن لحظه از DOM جدا شده و unmount روی ظرف جدا امن است.
|
||||||
|
*/
|
||||||
|
queueMicrotask(() => pin.root.unmount())
|
||||||
|
}
|
||||||
|
pinsRef.current = []
|
||||||
|
}
|
||||||
|
}, [map, projects, openProject])
|
||||||
|
|
||||||
|
/** با تغییر پروژهی فعال، فقط هالهی سنجاقها بهروز میشود (بازسازی نمیشوند) */
|
||||||
|
useEffect(() => {
|
||||||
|
for (const pin of pinsRef.current) {
|
||||||
|
pin.root.render(
|
||||||
|
<ProjectPin
|
||||||
|
label={pin.project.name}
|
||||||
|
active={activeProject?.id === pin.project.id}
|
||||||
|
onClick={() => openProject(pin.project)}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [activeProject, openProject])
|
||||||
|
|
||||||
|
return { popupHost, activeProject, openProject, closePopup }
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { renderToHTMLString } from '@tiptap/static-renderer';
|
import { renderToHTMLString } from '@tiptap/static-renderer';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { ButtonLink } from '../components/Button';
|
import { PremiumButton } from '../components/PremiumButton';
|
||||||
import { ArticleCard } from '../components/ArticleCard';
|
import { ArticleCard } from '../components/ArticleCard';
|
||||||
import { ArticleMeta } from '../components/ArticleMeta';
|
import { ArticleMeta } from '../components/ArticleMeta';
|
||||||
|
import { ArticleFaq } from '../components/ArticleFaq';
|
||||||
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
|
||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
@@ -47,6 +48,35 @@ export function ArticleDetail() {
|
|||||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
|
let jsonLd: Record<string, unknown> | Record<string, unknown>[] = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: article.title,
|
||||||
|
description: article.meta_description || article.excerpt,
|
||||||
|
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
||||||
|
inLanguage: lang,
|
||||||
|
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
|
||||||
|
publisher: { '@type': 'Organization', name: brand.name },
|
||||||
|
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
|
||||||
|
};
|
||||||
|
// پرسشوپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایهای از دو اسکیما)
|
||||||
|
const articleFaqs = article.faqs ?? [];
|
||||||
|
if (articleFaqs.length) {
|
||||||
|
jsonLd = [
|
||||||
|
jsonLd,
|
||||||
|
{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: articleFaqs
|
||||||
|
.filter((f) => f.question.trim())
|
||||||
|
.map((f) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: f.question,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: f.answer },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${article.title} | ${brand.name}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||||
@@ -55,17 +85,7 @@ export function ArticleDetail() {
|
|||||||
image: article.image,
|
image: article.image,
|
||||||
type: 'article',
|
type: 'article',
|
||||||
lang,
|
lang,
|
||||||
jsonLd: {
|
jsonLd,
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'Article',
|
|
||||||
headline: article.title,
|
|
||||||
description: article.meta_description || article.excerpt,
|
|
||||||
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
|
|
||||||
inLanguage: lang,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, [article?.id, lang, href]);
|
}, [article?.id, lang, href]);
|
||||||
|
|
||||||
@@ -74,10 +94,9 @@ export function ArticleDetail() {
|
|||||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
||||||
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
|
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
|
||||||
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
|
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
|
||||||
<ButtonLink to={href('/blog')} variant="copper" className="mt-8">
|
<PremiumButton to={href('/blog')} className="mt-8">
|
||||||
{t('backToBlog')}
|
{t('backToBlog')}
|
||||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
</PremiumButton>
|
||||||
</ButtonLink>
|
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -171,6 +190,14 @@ export function ArticleDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{/* پرسشوپاسخهای پایان مقاله — همان ظاهر آکاردئونی FAQ سایت */}
|
||||||
|
<div className="bg-ink pb-4">
|
||||||
|
<div className="mx-auto max-w-5xl px-6 lg:px-10">
|
||||||
|
<h2 className="text-section font-bold text-ivory">{t('fallbackFaqTitle')}</h2>
|
||||||
|
<ArticleFaq faqs={article.faqs ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
|
||||||
|
|||||||
@@ -1,39 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { PageHero } from '../components/PageHero'
|
import { PageHero } from '../components/PageHero'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Reveal } from '../components/Reveal'
|
|
||||||
import { useSite } from '../api'
|
|
||||||
import { useLang } from '../i18n/lang'
|
import { useLang } from '../i18n/lang'
|
||||||
import { AddItemButton, EditableItem } from '../admin/Editable'
|
|
||||||
|
|
||||||
export function ContactPage() {
|
export function ContactPage() {
|
||||||
const { offices } = useSite()
|
|
||||||
const { t } = useLang()
|
const { t } = useLang()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero pageKey="contact" />
|
<PageHero pageKey="contact" />
|
||||||
|
|
||||||
<section className="bg-ink py-24" aria-label={t('officesAria')}>
|
<section className="bg-ink py-2" aria-label={t('officesAria')} />
|
||||||
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
|
|
||||||
{offices.map((office, i) => (
|
|
||||||
<Reveal key={office.id} delay={0.06 * i} className="h-full">
|
|
||||||
<EditableItem resource="offices" item={office} className="h-full">
|
|
||||||
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
|
||||||
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
|
|
||||||
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
|
|
||||||
<div className="mt-auto pt-10">
|
|
||||||
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
|
|
||||||
{office.hours}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</EditableItem>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
<AddItemButton resource="offices" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function Home() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
<Story />
|
|
||||||
<Stats />
|
<Stats />
|
||||||
|
<Story />
|
||||||
<Services />
|
<Services />
|
||||||
<BeforeAfter />
|
<BeforeAfter />
|
||||||
<FeaturedProjects />
|
<FeaturedProjects />
|
||||||
|
|||||||
@@ -1,74 +1,98 @@
|
|||||||
import React, { useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
|
import { ChevronLeftIcon } from 'lucide-react'
|
||||||
import { Reveal } from '../components/Reveal'
|
import { Reveal } from '../components/Reveal'
|
||||||
import { ButtonLink } from '../components/Button'
|
import { PremiumButton } from '../components/PremiumButton'
|
||||||
import { ProjectCard } from '../components/ProjectCard'
|
import { ProjectCard } from '../components/ProjectCard'
|
||||||
import { ContactSection } from '../components/ContactSection'
|
import { ContactSection } from '../components/ContactSection'
|
||||||
import { Lightbox } from '../components/Lightbox'
|
import { Lightbox } from '../components/Lightbox'
|
||||||
import { useEffect } from 'react';
|
import { LazyProjectsMapSingle } from '../map/LazyMaps';
|
||||||
|
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
|
||||||
import { useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
|
import { projectsData } from '../data/projectsData';
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>()
|
const { slug } = useParams<{ slug: string }>()
|
||||||
const { projects, brand } = useSite()
|
const { projects, brand } = useSite()
|
||||||
const { t, href, lang } = useLang()
|
const { t, href, lang } = useLang()
|
||||||
const project = projects.find((p) => p.slug === slug)
|
|
||||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
const currentProject = projectsData.find((p) => p.slug === slug)
|
||||||
|
|
||||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
|
||||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
if (!currentProject) {
|
||||||
useEffect(() => {
|
return <div className="text-center py-20 text-ivory">Project not found</div>
|
||||||
if (!project) return;
|
|
||||||
applySeo({
|
|
||||||
title: `${project.title} | ${brand.name}`,
|
|
||||||
description: project.summary,
|
|
||||||
path: href(`/projects/${project.slug}`),
|
|
||||||
image: project.image,
|
|
||||||
lang,
|
|
||||||
});
|
|
||||||
}, [project?.id, lang, href]);
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return (
|
|
||||||
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
|
|
||||||
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
|
|
||||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
|
||||||
{t('projectNotFoundText')}
|
|
||||||
</p>
|
|
||||||
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
|
|
||||||
{t('backToProjects')}
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
|
|
||||||
</ButtonLink>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
|
const backendProject = projects.find((p) => p.slug === slug)
|
||||||
|
|
||||||
|
// Dynamic bilingual text from projectsData
|
||||||
|
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en;
|
||||||
|
const displayLocation = currentProject.location[lang as 'en' | 'fa'] || currentProject.location.en;
|
||||||
|
const displayArea = currentProject.area[lang as 'en' | 'fa'] || currentProject.area.en;
|
||||||
|
const displayStatus = currentProject.status;
|
||||||
|
|
||||||
|
const statusLabel = useStatusLabel(displayStatus)
|
||||||
|
|
||||||
|
// Fallbacks for layout structure fields missing in projectsData
|
||||||
|
const summary = backendProject?.summary || (lang === 'en' ? 'A modern and luxurious architectural project built with high-quality materials and smart design.' : 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ.');
|
||||||
|
const body = backendProject?.body || (lang === 'en'
|
||||||
|
? ['Located in the heart of the city, this project offers unparalleled access to major urban hubs.', 'It features state-of-the-art facilities, green spaces, and a sustainable approach to modern living.']
|
||||||
|
: ['لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است.', 'چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.']);
|
||||||
|
const gallery = backendProject?.gallery?.length ? backendProject.gallery : [
|
||||||
|
'/seed-media/2026/09/1.png',
|
||||||
|
'/seed-media/2026/09/2.png',
|
||||||
|
'/seed-media/2026/09/3.png',
|
||||||
|
'/seed-media/2026/09/4.png'
|
||||||
|
];
|
||||||
|
const image = backendProject?.image || '/seed-media/2026/09/1.png';
|
||||||
|
const year = backendProject?.year || (lang === 'en' ? '2024' : '1402');
|
||||||
|
const floors = backendProject?.floors || '5';
|
||||||
|
const units = backendProject?.units || '20';
|
||||||
|
const client = backendProject?.client || (lang === 'en' ? 'Aramesh Group' : 'گروه آرامش');
|
||||||
|
|
||||||
|
// تصاویر پاپآپ نقشه
|
||||||
|
const mapImages = useMemo(
|
||||||
|
() => [image, ...gallery].filter(Boolean),
|
||||||
|
[image, gallery]
|
||||||
|
)
|
||||||
|
|
||||||
|
// مختصات همین پروژه
|
||||||
|
const mapLocation = useMemo(
|
||||||
|
() => findMapProjectByName(displayTitle) ?? createPlaceholderProject(displayTitle, displayLocation),
|
||||||
|
[displayTitle, displayLocation]
|
||||||
|
)
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
useEffect(() => {
|
||||||
|
applySeo({
|
||||||
|
title: `${displayTitle} | ${brand.name}`,
|
||||||
|
description: summary,
|
||||||
|
path: href(`/projects/${currentProject.slug}`),
|
||||||
|
image: image,
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]);
|
||||||
|
|
||||||
|
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3)
|
||||||
|
|
||||||
const facts = [
|
const facts = [
|
||||||
{ label: t('specLocation'), value: project.location },
|
{ label: t('specLocation'), value: displayLocation },
|
||||||
{ label: t('specYear'), value: project.year },
|
{ label: t('specYear'), value: year },
|
||||||
{ label: t('specArea'), value: project.area },
|
{ label: t('specArea'), value: displayArea },
|
||||||
{ label: t('specFloors'), value: project.floors },
|
{ label: t('specFloors'), value: floors },
|
||||||
{ label: t('specUnits'), value: project.units },
|
{ label: t('specUnits'), value: units },
|
||||||
{ label: t('specClient'), value: project.client },
|
{ label: t('specClient'), value: client },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}>
|
||||||
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
|
||||||
<div
|
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||||
className="absolute inset-0"
|
|
||||||
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
|
||||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
|
||||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||||
{t('home')}
|
{t('home')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -77,75 +101,80 @@ export function ProjectDetail() {
|
|||||||
{t('projects')}
|
{t('projects')}
|
||||||
</Link>
|
</Link>
|
||||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||||
<span className="text-copper">{project.title}</span>
|
<span className="text-copper">{displayTitle}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
|
|
||||||
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
|
<div className="mb-8">
|
||||||
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
|
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1>
|
||||||
|
</div>
|
||||||
<Reveal className="order-1">
|
|
||||||
<span className="flex items-center gap-3 text-sm font-light text-copper">
|
{/* Gallery Desktop: Grid. Mobile: Grid */}
|
||||||
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]">
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
|
|
||||||
|
|
||||||
{project.body.map((para) => (
|
{/* Main large image */}
|
||||||
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
|
<button
|
||||||
{para}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
|
|
||||||
{facts.map((f) => (
|
|
||||||
<div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
|
|
||||||
<dt className="text-xs font-light text-ivory/50">{f.label}</dt>
|
|
||||||
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="mt-10">
|
|
||||||
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
|
|
||||||
{t('similarProjectCta')}
|
|
||||||
</ButtonLink>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<Reveal delay={0.1} className="order-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className="col-span-2 lg:col-span-1 h-[250px] md:h-[400px] lg:h-full relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block"
|
||||||
onClick={() => setLightbox(0)}
|
onClick={() => setLightbox(0)}
|
||||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
|
||||||
>
|
>
|
||||||
<img
|
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
||||||
src={project.gallery[0] || project.image}
|
|
||||||
alt=""
|
|
||||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
|
||||||
{project.gallery.slice(1).map((img, i) => (
|
{/* 2x2 Small images */}
|
||||||
<button
|
<div className="col-span-2 lg:col-span-1 grid grid-cols-2 gap-2 md:gap-3 lg:grid-rows-2 lg:h-full">
|
||||||
key={img}
|
{mapImages.slice(1, 5).map((img, i) => (
|
||||||
|
<button
|
||||||
|
key={img}
|
||||||
type="button"
|
type="button"
|
||||||
|
className={`relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block h-[120px] md:h-[200px] lg:h-full ${i >= 2 ? 'hidden md:block' : ''}`}
|
||||||
onClick={() => setLightbox(i + 1)}
|
onClick={() => setLightbox(i + 1)}
|
||||||
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
|
||||||
>
|
>
|
||||||
<img
|
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
|
||||||
src={img}
|
|
||||||
alt=""
|
|
||||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Smart Content Layout */}
|
||||||
|
<div className="max-w-4xl mx-auto mt-16 lg:mt-24 flex flex-col gap-16 lg:gap-20">
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Reveal>
|
||||||
|
<span className="flex items-center gap-3 text-sm font-light text-copper mb-6">
|
||||||
|
<span aria-hidden="true" className="h-px w-8 bg-copper" />
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
|
||||||
|
|
||||||
|
<p className="mt-8 text-xl font-medium leading-9 text-ivory/90">{summary}</p>
|
||||||
|
{body.map((para) => (
|
||||||
|
<p key={para} className="mt-6 text-base font-light leading-9 text-ivory/70">
|
||||||
|
{para}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Specifications Table - Perfectly center-aligned */}
|
||||||
|
<Reveal>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-px border border-ivory/10 bg-ivory/10 rounded-xl overflow-hidden">
|
||||||
|
{facts.map((f) => (
|
||||||
|
<div key={f.label} className="bg-ink p-6 md:p-8 flex flex-col items-center justify-center text-center">
|
||||||
|
<dt className="text-xs font-light text-ivory/50 mb-2">{f.label}</dt>
|
||||||
|
<dd className="text-base font-bold text-ivory">{f.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Map (بدون Reveal) */}
|
||||||
|
{mapLocation && (
|
||||||
|
<div className="h-80 md:h-[400px] rounded-xl overflow-hidden border border-white/10 shadow-2xl">
|
||||||
|
<LazyProjectsMapSingle location={mapLocation} images={mapImages} className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -154,17 +183,24 @@ export function ProjectDetail() {
|
|||||||
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
|
||||||
<div className="mt-6 h-px w-24 bg-copper/70" />
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{related.map((item, i) => (
|
{related.map((item, i) => {
|
||||||
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||||
<ProjectCard project={item} />
|
return (
|
||||||
</Reveal>
|
<Reveal key={item.slug} delay={0.06 * i} className="h-full">
|
||||||
))}
|
<ProjectCard
|
||||||
|
project={item}
|
||||||
|
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
|
||||||
|
detailHref={href(`/projects/${item.slug}`)}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Lightbox
|
<Lightbox
|
||||||
images={project.gallery}
|
images={gallery}
|
||||||
index={lightbox}
|
index={lightbox}
|
||||||
onClose={() => setLightbox(null)}
|
onClose={() => setLightbox(null)}
|
||||||
onChange={setLightbox}
|
onChange={setLightbox}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { PageHero } from '../components/PageHero';
|
import { PageHero } from '../components/PageHero';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { ProjectCard } from '../components/ProjectCard';
|
import { ProjectCard } from '../components/ProjectCard';
|
||||||
|
import { PremiumButton } from '../components/PremiumButton';
|
||||||
import { Gallery } from '../components/home/Gallery';
|
import { Gallery } from '../components/home/Gallery';
|
||||||
import { ContactSection } from '../components/ContactSection';
|
import { ContactSection } from '../components/ContactSection';
|
||||||
import type { ProjectStatus } from '../data/site';
|
|
||||||
import { useSite, useStatusLabel } from '../api';
|
import { useSite, useStatusLabel } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
import { useFieldSaver } from '../admin/AdminContext';
|
import { useFieldSaver } from '../admin/AdminContext';
|
||||||
import { InlineEdit } from '../components/admin/InlineEdit';
|
import { InlineEdit } from '../components/admin/InlineEdit';
|
||||||
import { AddItemButton, EditableItem } from '../admin/Editable';
|
import { projectsData } from '../data/projectsData';
|
||||||
|
import { LazyProjectsMapAll } from '../map/LazyMaps';
|
||||||
|
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const { projects, status_labels } = useSite();
|
const { status_labels } = useSite();
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
const save = useFieldSaver();
|
const save = useFieldSaver();
|
||||||
|
|
||||||
@@ -27,27 +29,79 @@ export function Projects() {
|
|||||||
|
|
||||||
const doneLabel = useStatusLabel('done');
|
const doneLabel = useStatusLabel('done');
|
||||||
const progressLabel = useStatusLabel('progress');
|
const progressLabel = useStatusLabel('progress');
|
||||||
const FILTERS: {key: ProjectStatus;label: string;}[] = [
|
const FILTERS: { key: 'done' | 'progress'; label: string; }[] = [
|
||||||
{ key: 'done', label: doneLabel },
|
{ key: 'done', label: doneLabel },
|
||||||
{ key: 'progress', label: progressLabel }
|
{ key: 'progress', label: progressLabel }
|
||||||
];
|
];
|
||||||
const [status, setStatus] = useState<ProjectStatus>('done');
|
const [status, setStatus] = useState<'done' | 'progress'>('done');
|
||||||
const list = projects.filter((p) => p.status === status);
|
|
||||||
|
// Filtering and Pagination State
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const itemsPerPage = 12;
|
||||||
|
|
||||||
|
// Filter projects by status
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
return projectsData.filter((p) => p.status === status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// Currently displayed projects
|
||||||
|
const displayedProjects = useMemo(() => {
|
||||||
|
return filteredProjects.slice(0, page * itemsPerPage);
|
||||||
|
}, [filteredProjects, page]);
|
||||||
|
|
||||||
|
// Handle Tab Switch
|
||||||
|
const handleTabSwitch = (newStatus: 'done' | 'progress') => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
setPage(1); // Reset pagination on tab switch
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Load More
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
// Brief loading state
|
||||||
|
setTimeout(() => {
|
||||||
|
setPage((prev) => prev + 1);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||||
|
|
||||||
|
// Resolve a project href from a MapProject entry by matching against projectsData slugs
|
||||||
|
const resolveProjectHref = (mapProject: MapProject): string | undefined => {
|
||||||
|
const match = projectsData.find(
|
||||||
|
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name
|
||||||
|
);
|
||||||
|
return match ? `/projects/${match.slug}` : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero pageKey="projects" />
|
<PageHero pageKey="projects" />
|
||||||
|
|
||||||
|
{/* نقشهٔ محدودشده — همهٔ پروژهها با سنجاق مسی */}
|
||||||
|
<section className="bg-char py-10" aria-label={t('mapAria')}>
|
||||||
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
|
<div className="w-full max-w-full overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
<LazyProjectsMapAll
|
||||||
|
projects={MAP_PROJECTS}
|
||||||
|
resolveProjectHref={resolveProjectHref}
|
||||||
|
className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
|
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
|
||||||
<div className="mx-auto max-w-site px-6 lg:px-10">
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
||||||
|
|
||||||
{/* دکمههای فیلتر مینیمال و شیشهای (وسطچین و بدون کادر) */}
|
{/* دکمههای فیلتر */}
|
||||||
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||||
{FILTERS.map((filter, index) => {
|
{FILTERS.map((filter, index) => {
|
||||||
const isSelected = status === filter.key;
|
const isSelected = status === filter.key;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={filter.key}>
|
<React.Fragment key={filter.key}>
|
||||||
{/* خط جداکننده بین دکمهها */}
|
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
@@ -56,7 +110,7 @@ export function Projects() {
|
|||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
onClick={() => setStatus(filter.key)}
|
onClick={() => handleTabSwitch(filter.key)}
|
||||||
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
|
||||||
@@ -76,15 +130,34 @@ export function Projects() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
<div className="mt-12 grid gap-6 lg:grid-cols-3">
|
||||||
{list.map((project, i) => (
|
{displayedProjects.map((project, index) => {
|
||||||
<Reveal key={project.id} delay={0.05 * i} className="h-full">
|
const dummyImageName = `${(index % 6) + 1}.png`;
|
||||||
<EditableItem resource="projects" item={project} className="h-full">
|
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||||
<ProjectCard project={project} imageClass="h-80" />
|
|
||||||
</EditableItem>
|
return (
|
||||||
</Reveal>
|
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
|
||||||
))}
|
<ProjectCard
|
||||||
<AddItemButton resource="projects" />
|
project={project}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
detailHref={`/projects/${project.slug}`}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Load More Button */}
|
||||||
|
{displayedProjects.length < filteredProjects.length && (
|
||||||
|
<div className="mt-16 flex justify-center">
|
||||||
|
<PremiumButton
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-10 py-4 w-48"
|
||||||
|
>
|
||||||
|
{isLoading ? 'در حال بارگذاری...' : 'مشاهده بیشتر'}
|
||||||
|
</PremiumButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -92,4 +165,4 @@ export function Projects() {
|
|||||||
<ContactSection />
|
<ContactSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ function upsertAlternates(path: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setJsonLd(data: Record<string, unknown> | null): void {
|
function setJsonLd(data: Record<string, unknown> | Record<string, unknown>[] | null): void {
|
||||||
const id = 'seo-jsonld'
|
const id = 'seo-jsonld'
|
||||||
let el = document.getElementById(id)
|
let el = document.getElementById(id)
|
||||||
if (!data) {
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
||||||
el?.remove()
|
el?.remove()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,8 @@ export type SeoInput = {
|
|||||||
type?: 'website' | 'article'
|
type?: 'website' | 'article'
|
||||||
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
||||||
lang?: Lang
|
lang?: Lang
|
||||||
jsonLd?: Record<string, unknown> | null
|
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + FAQPage) */
|
||||||
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const file = fs.readFileSync('src/data/projectsData.ts', 'utf8');
|
||||||
|
const lines = file.split('\n');
|
||||||
|
const newLines = [];
|
||||||
|
let insideArray = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.includes('export const projectsData')) {
|
||||||
|
insideArray = true;
|
||||||
|
newLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (insideArray && line.includes('{ id:')) {
|
||||||
|
const match = line.match(/{ id: '(\d+)', name: '(.*?)', status: '(.*?)' }/);
|
||||||
|
if (match) {
|
||||||
|
const id = match[1];
|
||||||
|
const nameFa = match[2];
|
||||||
|
const status = match[3];
|
||||||
|
const nameEn = 'Project ' + id;
|
||||||
|
const slug = 'project-' + id;
|
||||||
|
newLines.push(` { id: '${id}', slug: '${slug}', name: { en: '${nameEn}', fa: '${nameFa}' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: '${status}' },`);
|
||||||
|
} else {
|
||||||
|
newLines.push(line);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (line.includes('export type StaticProject') || line.includes('id: string;') || line.includes('name: string;') || line.includes('status:') || line === '};') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = `export type BilingualText = { en: string; fa: string };
|
||||||
|
|
||||||
|
export type StaticProject = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: BilingualText;
|
||||||
|
location: BilingualText;
|
||||||
|
area: BilingualText;
|
||||||
|
status: 'done' | 'progress';
|
||||||
|
};
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync('src/data/projectsData.ts', header + newLines.join('\n'));
|
||||||
@@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// وبورکر مپلیبر از node_modules ساخته و بهعنوان دارایی خروجی منتشر میشود؛
|
||||||
|
// در src/map/mapWorker.ts با setWorkerUrl به آن اشاره میکنیم (نسخهی
|
||||||
|
// پیشفرض مپلیبر کنار باندل است و در خروجی ویت وجود ندارد).
|
||||||
|
worker: {
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
port: 5174, // سرور توسعه فرانتاند روی این پورت اجرا میشود تا تداخل نکند
|
||||||
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
strictPort: true, // جلوگیری از تغییر خودکار پورت
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# پیشنمایش خصوصی و انتشار عمومی سایت.
|
||||||
|
#
|
||||||
|
# bash scripts/preview-mode.sh status # وضعیت فعلی
|
||||||
|
# bash scripts/preview-mode.sh on # سایت آنلاین ولی خصوصی (گیت ورود + noindex)
|
||||||
|
# bash scripts/preview-mode.sh off # انتشار عمومی (حذف گیت + اجازه ایندکس)
|
||||||
|
#
|
||||||
|
# در حالت «on» کل سایت پشت Basic Auth است: تیم با نام کاربری/رمز مشترک وارد میشود،
|
||||||
|
# محتوا را در همان سایت آنلاین ویرایش میکند (ویرایش درجای ایمیل/تلفن و بقیه محتوا)،
|
||||||
|
# اما نه عموم به سایت دسترسی دارند و نه گوگل چیزی ایندکس میکند.
|
||||||
|
#
|
||||||
|
# پس از هر تغییر، استک را دوباره بالا بیاورید:
|
||||||
|
# bash scripts/stack.sh deploy-prod
|
||||||
|
#
|
||||||
|
# با «فعال شدن» اولین بار، نام کاربری/رمز ساخته و در .env ذخیره میشود
|
||||||
|
# (پیشفرض: preview / یک رمز تصادفی — در خروجی چاپ میشود).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ENV_FILE=.env
|
||||||
|
[ -f "$ENV_FILE" ] || { echo "فایل .env پیدا نشد — اول bash scripts/gen-env.sh" >&2; exit 1; }
|
||||||
|
|
||||||
|
# مقدار فعلی یک کلید در .env (یا خالی)
|
||||||
|
env_get() {
|
||||||
|
local key="$1"
|
||||||
|
sed -n "s/^${key}=//p" "$ENV_FILE" | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# تنظیم/جایگزینی یک کلید در .env (بدون آسیب به بقیه خطوط)
|
||||||
|
env_set() {
|
||||||
|
local key="$1" value="$2"
|
||||||
|
if grep -q "^${key}=" "$ENV_FILE"; then
|
||||||
|
# جداکننده | تا کاراکترهای ویژه مقدار (مثل / و $) مشکلی نسازند
|
||||||
|
python - "$ENV_FILE" "$key" "$value" <<'PY'
|
||||||
|
import io, sys
|
||||||
|
path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
lines = io.open(path, encoding="utf-8").read().split("\n")
|
||||||
|
out = [f"{key}={value}" if line.startswith(f"{key}=") else line for line in lines]
|
||||||
|
io.open(path, "w", encoding="utf-8", newline="\n").write("\n".join(out))
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
printf '\n%s=%s\n' "$key" "$value" >> "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ساخت هش htpasswd — اول با openssl، در نبودش با ایمیج httpd (Docker لازم است)
|
||||||
|
make_htpasswd() {
|
||||||
|
local user="$1" pass="$2" hash
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
hash=$(openssl passwd -apr1 "$pass")
|
||||||
|
else
|
||||||
|
hash=$(docker run --rm httpd:2.4-alpine htpasswd -nbB "$user" "$pass" | cut -d: -f2)
|
||||||
|
fi
|
||||||
|
printf '%s:%s' "$user" "$hash"
|
||||||
|
}
|
||||||
|
|
||||||
|
# رمز تصادفی خوانا (بدون نویسههای گیجکننده)
|
||||||
|
random_password() {
|
||||||
|
python - <<'PY'
|
||||||
|
import secrets, string
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
print("".join(secrets.choice(alphabet) for _ in range(16)))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
PREVIEW_MIDDLEWARES_VALUE="preview-auth@docker,preview-noindex@docker"
|
||||||
|
|
||||||
|
case "${1:-status}" in
|
||||||
|
on)
|
||||||
|
users="$(env_get PREVIEW_AUTH_USERS)"
|
||||||
|
if [ -z "$users" ]; then
|
||||||
|
password="$(random_password)"
|
||||||
|
users="$(make_htpasswd preview "$password")"
|
||||||
|
env_set PREVIEW_AUTH_USERS "$users"
|
||||||
|
echo "نام کاربری/رمز پیشنمایش ساخته شد:"
|
||||||
|
echo " کاربر: preview"
|
||||||
|
echo " رمز: $password"
|
||||||
|
echo " (این رمز فقط همین یکبار چاپ میشود — در .env ذخیره شده است)"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
env_set PREVIEW_MODE on
|
||||||
|
env_set PREVIEW_MIDDLEWARES "$PREVIEW_MIDDLEWARES_VALUE"
|
||||||
|
# در پیشنمایش، دامنه دوم به اصلی ریدایرکت نمیشود تا هر دو قابل بررسی باشند
|
||||||
|
env_set ALIAS_REDIRECT_MIDDLEWARES ""
|
||||||
|
echo "پیشنمایش خصوصی فعال شد — سایت آنلاین است، ورود با نام کاربری/رمز، بدون ایندکس."
|
||||||
|
echo "برای اعمال: bash scripts/stack.sh deploy-prod"
|
||||||
|
;;
|
||||||
|
|
||||||
|
off)
|
||||||
|
env_set PREVIEW_MODE off
|
||||||
|
env_set PREVIEW_MIDDLEWARES ""
|
||||||
|
# در انتشار، دامنه دوم به اصلی ریدایرکت میشود تا محتوای تکراری ایجاد نشود
|
||||||
|
env_set ALIAS_REDIRECT_MIDDLEWARES "to-primary@docker"
|
||||||
|
echo "حالت انتشار عمومی فعال شد — گیت ورود حذف و ایندکس مجاز میشود."
|
||||||
|
echo "پس از deploy، نقشه سایت را در Search Console ثبت کنید:"
|
||||||
|
echo " https://\$(grep '^DOMAIN=' .env | cut -d= -f2)/sitemap.xml"
|
||||||
|
echo "برای اعمال: bash scripts/stack.sh deploy-prod"
|
||||||
|
;;
|
||||||
|
|
||||||
|
status)
|
||||||
|
mode="$(env_get PREVIEW_MODE)"
|
||||||
|
mws="$(env_get PREVIEW_MIDDLEWARES)"
|
||||||
|
alias_mws="$(env_get ALIAS_REDIRECT_MIDDLEWARES)"
|
||||||
|
if [ "$mode" = "on" ]; then
|
||||||
|
echo "وضعیت: پیشنمایش خصوصی (آنلاین، غیرعمومی، noindex)"
|
||||||
|
elif [ "$mode" = "off" ]; then
|
||||||
|
echo "وضعیت: انتشار عمومی"
|
||||||
|
else
|
||||||
|
echo "وضعیت: تعییننشده (PREVIEW_MODE=${mode:-خالی}) — پیشفرض compose: off"
|
||||||
|
fi
|
||||||
|
echo " PREVIEW_MIDDLEWARES=${mws:-(خالی — بدون گیت)}"
|
||||||
|
echo " ALIAS_REDIRECT_MIDDLEWARES=${alias_mws:-(خالی — بدون ریدایرکت دامنه دوم)}"
|
||||||
|
if [ -n "$(env_get PREVIEW_AUTH_USERS)" ]; then
|
||||||
|
echo " کاربر پیشنمایش: $(env_get PREVIEW_AUTH_USERS | cut -d: -f1)"
|
||||||
|
else
|
||||||
|
echo " کاربر پیشنمایش: ساخته نشده (اولین «on» میسازد)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "استفاده: bash scripts/preview-mode.sh [on|off|status]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1009 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 781 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |