7 Commits
Author SHA1 Message Date
Behrad 575e4868be feat(mobile): complete responsive design and layout optimizations across landing page 2026-10-04 23:48:19 +03:30
MMD 846682ac4a صفحه تماس: حذف بخش دفاتر مطابق تصمیم تیم UI
CI / tests (push) Canceled after 0s
CI / images (push) Canceled after 0s
بازگرداندن من اشتباه بود — تیم UI عمداً این بخش را برداشته/جابه‌جا کرده است.
جای‌نگهدار خودشان (section خالی با aria-label) حفظ شد و فقط importهای
بی‌استفاده حذف شدند تا تایپ‌چک تمیز بماند.
2026-09-26 23:30:18 +03:30
MMD f7105a62cc بازگرداندن بخش دفاتر در صفحه تماس
شاخه UI این بخش را به یک section خالی تبدیل کرده بود (کار ناتمام)؛
ادغام خودکار آن را بی‌صدا حذف می‌کرد. محتوای دفاتر (شهر/نشانی/ساعات)
با همان چیدمان و ویرایش‌پذیری مدیریتی برگردانده شد.
2026-09-26 23:09:13 +03:30
MMD 0628344dcf Merge remote-tracking branch 'origin/feature/ui-updates-v1' into new-one 2026-09-26 23:04:15 +03:30
MMD d25db9367c پیش‌نمایش خصوصی: سایت آنلاین اما غیرعمومی تا زمان انتشار
- گیت ورود Traefik (Basic Auth) روی همه مسیرها: سایت، API، تصاویر و چت — بدون رمز فقط ۴۰۱
- لایه دوم noindex: هدر X-Robots-Tag روی همه پاسخ‌ها + robots.txt مسدودکننده + نقشه سایت خالی
- اسکریپت scripts/preview-mode.sh on|off|status — ساخت خودکار نام کاربری/رمز و هماهنگ‌کردن .env
- دو دامنه (DOMAIN + DOMAIN_ALIAS) با گواهی TLS و ریدایرکت ۳۰۱ دامنه دوم به اصلی در زمان انتشار
- اسکیمای FAQPage در پوسته سئوی سمت سرور (فارسی و انگلیسی) برای خزنده‌های بدون جاوااسکریپت
- مستندسازی جریان پیش‌نمایش و چک‌لیست انتشار در README و .env.example
2026-09-26 22:47:04 +03:30
MMD 1d29e50852 وبلاگ: نام‌گذاری و متادیتای تصاویر، پرسش‌وپاسخ مقاله، میزبانی تصاویر چسبانده‌شده
- Alt/Title تصویر شاخص اکنون ذخیره می‌شود (ستون‌های جدید image_alt/image_title — قبلاً بی‌صدا دور ریخته می‌شد)
- Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایش‌اند (کنار کپشن)
- نام فایل ذخیره‌سازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (به‌جای UUID خالی)
- پنل پرسش‌وپاسخ پایان مقاله (ستون‌های faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage
- چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی می‌شوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
2026-09-26 22:46:56 +03:30
fatemeh sane 853ad67db5 feat(ui): update UI v1 2026-09-25 22:49:30 +03:30
106 changed files with 12036 additions and 790 deletions
+14
View File
@@ -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
+37 -2
View File
@@ -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
+121 -17
View File
@@ -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,
+4
View File
@@ -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()
+11
View File
@@ -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=["احراز هویت"])
+11
View File
@@ -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":
+7
View File
@@ -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")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
+13
View File
@@ -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)
+45 -2
View File
@@ -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}",
+216
View File
@@ -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", "")
+33 -5
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
+254
View File
@@ -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",
+4
View File
@@ -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",
+836
View File
@@ -0,0 +1,836 @@
// node_modules/fflate/esm/browser.js
var u8 = Uint8Array;
var u16 = Uint16Array;
var i32 = Int32Array;
var fleb = new u8([
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
2,
2,
2,
2,
3,
3,
3,
3,
4,
4,
4,
4,
5,
5,
5,
5,
0,
/* unused */
0,
0,
/* impossible */
0
]);
var fdeb = new u8([
0,
0,
0,
0,
1,
1,
2,
2,
3,
3,
4,
4,
5,
5,
6,
6,
7,
7,
8,
8,
9,
9,
10,
10,
11,
11,
12,
12,
13,
13,
/* unused */
0,
0
]);
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
var freb = function(eb, start) {
var b2 = new u16(31);
for (var i = 0; i < 31; ++i) {
b2[i] = start += 1 << eb[i - 1];
}
var r = new i32(b2[30]);
for (var i = 1; i < 30; ++i) {
for (var j2 = b2[i]; j2 < b2[i + 1]; ++j2) {
r[j2] = j2 - b2[i] << 5 | i;
}
}
return { b: b2, r };
};
var _a = freb(fleb, 2);
var fl = _a.b;
var revfl = _a.r;
fl[28] = 258, revfl[258] = 28;
var _b = freb(fdeb, 0);
var fd = _b.b;
var revfd = _b.r;
var rev = new u16(32768);
for (i = 0; i < 32768; ++i) {
x2 = (i & 43690) >> 1 | (i & 21845) << 1;
x2 = (x2 & 52428) >> 2 | (x2 & 13107) << 2;
x2 = (x2 & 61680) >> 4 | (x2 & 3855) << 4;
rev[i] = ((x2 & 65280) >> 8 | (x2 & 255) << 8) >> 1;
}
var x2;
var i;
var hMap = function(cd, mb, r) {
var s = cd.length;
var i = 0;
var l = new u16(mb);
for (; i < s; ++i) {
if (cd[i])
++l[cd[i] - 1];
}
var le = new u16(mb);
for (i = 1; i < mb; ++i) {
le[i] = le[i - 1] + l[i - 1] << 1;
}
var co;
if (r) {
co = new u16(1 << mb);
var rvb = 15 - mb;
for (i = 0; i < s; ++i) {
if (cd[i]) {
var sv = i << 4 | cd[i];
var r_1 = mb - cd[i];
var v2 = le[cd[i] - 1]++ << r_1;
for (var m = v2 | (1 << r_1) - 1; v2 <= m; ++v2) {
co[rev[v2] >> rvb] = sv;
}
}
}
} else {
co = new u16(s);
for (i = 0; i < s; ++i) {
if (cd[i]) {
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
}
}
}
return co;
};
var flt = new u8(288);
for (i = 0; i < 144; ++i)
flt[i] = 8;
var i;
for (i = 144; i < 256; ++i)
flt[i] = 9;
var i;
for (i = 256; i < 280; ++i)
flt[i] = 7;
var i;
for (i = 280; i < 288; ++i)
flt[i] = 8;
var i;
var fdt = new u8(32);
for (i = 0; i < 32; ++i)
fdt[i] = 5;
var i;
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
var max = function(a) {
var m = a[0];
for (var i = 1; i < a.length; ++i) {
if (a[i] > m)
m = a[i];
}
return m;
};
var bits = function(d2, p, m) {
var o = p / 8 | 0;
return (d2[o] | d2[o + 1] << 8) >> (p & 7) & m;
};
var bits16 = function(d2, p) {
var o = p / 8 | 0;
return (d2[o] | d2[o + 1] << 8 | d2[o + 2] << 16) >> (p & 7);
};
var shft = function(p) {
return (p + 7) / 8 | 0;
};
var slc = function(v2, s, e) {
if (s == null || s < 0)
s = 0;
if (e == null || e > v2.length)
e = v2.length;
return new u8(v2.subarray(s, e));
};
var ec = [
"unexpected EOF",
"invalid block type",
"invalid length/literal",
"invalid distance",
"stream finished",
"no stream handler",
,
// determined by compression function
"no callback",
"invalid UTF-8 data",
"extra field too long",
"date not in range 1980-2099",
"filename too long",
"stream finishing",
"invalid zip data"
// determined by unknown compression method
];
var err = function(ind, msg, nt) {
var e = new Error(msg || ec[ind]);
e.code = ind;
if (Error.captureStackTrace)
Error.captureStackTrace(e, err);
if (!nt)
throw e;
return e;
};
var inflt = function(dat, st, buf, dict) {
var sl = dat.length, dl = dict ? dict.length : 0;
if (!sl || st.f && !st.l)
return buf || new u8(0);
var noBuf = !buf;
var resize = noBuf || st.i != 2;
var noSt = st.i;
if (noBuf)
buf = new u8(sl * 3);
var cbuf = function(l2) {
var bl = buf.length;
if (l2 > bl) {
var nbuf = new u8(Math.max(bl * 2, l2));
nbuf.set(buf);
buf = nbuf;
}
};
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
var tbts = sl * 8;
do {
if (!lm) {
final = bits(dat, pos, 1);
var type = bits(dat, pos + 1, 3);
pos += 3;
if (!type) {
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
if (t > sl) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + l);
buf.set(dat.subarray(s, t), bt);
st.b = bt += l, st.p = pos = t * 8, st.f = final;
continue;
} else if (type == 1)
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
else if (type == 2) {
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
var tl = hLit + bits(dat, pos + 5, 31) + 1;
pos += 14;
var ldt = new u8(tl);
var clt = new u8(19);
for (var i = 0; i < hcLen; ++i) {
clt[clim[i]] = bits(dat, pos + i * 3, 7);
}
pos += hcLen * 3;
var clb = max(clt), clbmsk = (1 << clb) - 1;
var clm = hMap(clt, clb, 1);
for (var i = 0; i < tl; ) {
var r = clm[bits(dat, pos, clbmsk)];
pos += r & 15;
var s = r >> 4;
if (s < 16) {
ldt[i++] = s;
} else {
var c = 0, n = 0;
if (s == 16)
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
else if (s == 17)
n = 3 + bits(dat, pos, 7), pos += 3;
else if (s == 18)
n = 11 + bits(dat, pos, 127), pos += 7;
while (n--)
ldt[i++] = c;
}
}
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
lbt = max(lt);
dbt = max(dt);
lm = hMap(lt, lbt, 1);
dm = hMap(dt, dbt, 1);
} else
err(1);
if (pos > tbts) {
if (noSt)
err(0);
break;
}
}
if (resize)
cbuf(bt + 131072);
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
var lpos = pos;
for (; ; lpos = pos) {
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
pos += c & 15;
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (!c)
err(2);
if (sym < 256)
buf[bt++] = sym;
else if (sym == 256) {
lpos = pos, lm = null;
break;
} else {
var add = sym - 254;
if (sym > 264) {
var i = sym - 257, b2 = fleb[i];
add = bits(dat, pos, (1 << b2) - 1) + fl[i];
pos += b2;
}
var d2 = dm[bits16(dat, pos) & dms], dsym = d2 >> 4;
if (!d2)
err(3);
pos += d2 & 15;
var dt = fd[dsym];
if (dsym > 3) {
var b2 = fdeb[dsym];
dt += bits16(dat, pos) & (1 << b2) - 1, pos += b2;
}
if (pos > tbts) {
if (noSt)
err(0);
break;
}
if (resize)
cbuf(bt + 131072);
var end = bt + add;
if (bt < dt) {
var shift = dl - dt, dend = Math.min(dt, end);
if (shift + bt < 0)
err(3);
for (; bt < dend; ++bt)
buf[bt] = dict[shift + bt];
}
for (; bt < end; ++bt)
buf[bt] = buf[bt - dt];
}
}
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
if (lm)
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
} while (!final);
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
};
var et = /* @__PURE__ */ new u8(0);
var gzs = function(d2) {
if (d2[0] != 31 || d2[1] != 139 || d2[2] != 8)
err(6, "invalid gzip data");
var flg = d2[3];
var st = 10;
if (flg & 4)
st += (d2[10] | d2[11] << 8) + 2;
for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d2[st++])
;
return st + (flg & 2);
};
var gzl = function(d2) {
var l = d2.length;
return (d2[l - 4] | d2[l - 3] << 8 | d2[l - 2] << 16 | d2[l - 1] << 24) >>> 0;
};
var zls = function(d2, dict) {
if ((d2[0] & 15) != 8 || d2[0] >> 4 > 7 || (d2[0] << 8 | d2[1]) % 31)
err(6, "invalid zlib data");
if ((d2[1] >> 5 & 1) == +!dict)
err(6, "invalid zlib data: " + (d2[1] & 32 ? "need" : "unexpected") + " dictionary");
return (d2[1] >> 3 & 4) + 2;
};
function inflateSync(data, opts) {
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
}
function gunzipSync(data, opts) {
var st = gzs(data);
if (st + 8 > data.length)
err(6, "invalid gzip data");
return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
}
function unzlibSync(data, opts) {
return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
}
function decompressSync(data, opts) {
return data[0] == 31 && data[1] == 139 && data[2] == 8 ? gunzipSync(data, opts) : (data[0] & 15) != 8 || data[0] >> 4 > 7 || (data[0] << 8 | data[1]) % 31 ? inflateSync(data, opts) : unzlibSync(data, opts);
}
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
var tds = 0;
try {
td.decode(et, { stream: true });
tds = 1;
} catch (e) {
}
// node_modules/pmtiles/dist/esm/index.js
var j = Object.defineProperty;
var S = Math.pow;
var d = (o, t) => j(o, "name", { value: t, configurable: true });
var h = (o, t, e) => new Promise((r, n) => {
var s = (c) => {
try {
a(e.next(c));
} catch (l) {
n(l);
}
}, i = (c) => {
try {
a(e.throw(c));
} catch (l) {
n(l);
}
}, a = (c) => c.done ? r(c.value) : Promise.resolve(c.value).then(s, i);
a((e = e.apply(o, t)).next());
});
var re = d((o, t) => {
let e = false, r = "", n = L.GridLayer.extend({ createTile: d((s, i) => {
let a = document.createElement("img"), c = new AbortController(), l = c.signal;
return a.cancel = () => {
c.abort();
}, e || (o.getHeader().then((u) => {
u.tileType === 1 || u.tileType === 6 ? console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details.") : u.tileType === 2 ? r = "image/png" : u.tileType === 3 ? r = "image/jpeg" : u.tileType === 4 ? r = "image/webp" : u.tileType === 5 && (r = "image/avif");
}), e = true), o.getZxy(s.z, s.x, s.y, l).then((u) => {
if (u) {
let m = new Blob([u.data], { type: r }), g = window.URL.createObjectURL(m);
a.src = g;
} else a.style.display = "none";
a.cancel = void 0, i(void 0, a);
}).catch((u) => {
if (u.name !== "AbortError") throw u;
}), a;
}, "createTile"), _removeTile: d(function(s) {
let i = this._tiles[s];
i && (i.el.cancel && i.el.cancel(), i.el.src && window.URL.revokeObjectURL(i.el.src), i.el.width = 0, i.el.height = 0, i.el.deleted = true, L.DomUtil.remove(i.el), delete this._tiles[s], this.fire("tileunload", { tile: i.el, coords: this._keyToTileCoords(s) }));
}, "_removeTile") });
return new n(t);
}, "leafletRasterLayer");
var z = d((o) => (t, e) => {
if (e instanceof AbortController) return o(t, e);
let r = new AbortController();
return o(t, r).then((n) => e(void 0, n.data, n.cacheControl || "", n.expires || ""), (n) => e(n)).catch((n) => e(n)), { cancel: d(() => r.abort(), "cancel") };
}, "v3compat");
var A = class A2 {
constructor(t) {
this.tilev4 = d((t2, e) => h(this, null, function* () {
if (t2.type === "json") {
let g = t2.url.substr(10), p = this.tiles.get(g);
if (p || (p = new w(g), this.tiles.set(g, p)), this.metadata) {
let K = yield p.getTileJson(t2.url);
return e.signal.throwIfAborted(), { data: K };
}
let f = yield p.getHeader();
return e.signal.throwIfAborted(), (f.minLon >= f.maxLon || f.minLat >= f.maxLat) && console.error(`Bounds of PMTiles archive ${f.minLon},${f.minLat},${f.maxLon},${f.maxLat} are not valid.`), { data: { tiles: [`${t2.url}/{z}/{x}/{y}`], minzoom: f.minZoom, maxzoom: f.maxZoom, bounds: [f.minLon, f.minLat, f.maxLon, f.maxLat] } };
}
let r = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/), n = t2.url.match(r);
if (!n) throw new Error("Invalid PMTiles protocol URL");
let s = n[1], i = this.tiles.get(s);
i || (i = new w(s), this.tiles.set(s, i));
let a = n[2], c = n[3], l = n[4], u = yield i == null ? void 0 : i.getZxy(+a, +c, +l, e.signal);
if (e.signal.throwIfAborted(), u) return { data: new Uint8Array(u.data), cacheControl: u.cacheControl, expires: u.expires };
let m = yield i.getHeader();
if (m.tileType === 1 || m.tileType === 6) {
if (this.errorOnMissingTile) throw new Error("Tile not found.");
return { data: new Uint8Array() };
}
return { data: null };
}), "tilev4");
this.tile = z(this.tilev4);
this.tiles = /* @__PURE__ */ new Map(), this.metadata = (t == null ? void 0 : t.metadata) || false, this.errorOnMissingTile = (t == null ? void 0 : t.errorOnMissingTile) || false;
}
add(t) {
this.tiles.set(t.source.getKey(), t);
}
get(t) {
return this.tiles.get(t);
}
};
d(A, "Protocol");
var B = A;
function b(o, t) {
return (t >>> 0) * 4294967296 + (o >>> 0);
}
d(b, "toNum");
function N(o, t) {
let e = t.buf, r = e[t.pos++], n = (r & 112) >> 4;
if (r < 128 || (r = e[t.pos++], n |= (r & 127) << 3, r < 128) || (r = e[t.pos++], n |= (r & 127) << 10, r < 128) || (r = e[t.pos++], n |= (r & 127) << 17, r < 128) || (r = e[t.pos++], n |= (r & 127) << 24, r < 128) || (r = e[t.pos++], n |= (r & 1) << 31, r < 128)) return b(o, n);
throw new Error("Expected varint not more than 10 bytes");
}
d(N, "readVarintRemainder");
function x(o) {
let t = o.buf, e = t[o.pos++], r = e & 127;
return e < 128 || (e = t[o.pos++], r |= (e & 127) << 7, e < 128) || (e = t[o.pos++], r |= (e & 127) << 14, e < 128) || (e = t[o.pos++], r |= (e & 127) << 21, e < 128) ? r : (e = t[o.pos], r |= (e & 15) << 28, N(r, o));
}
d(x, "readVarint");
function I(o, t, e, r, n) {
return n === 0 ? r !== 0 ? [o - 1 - e, o - 1 - t] : [e, t] : [t, e];
}
d(I, "rotate");
function q(o, t, e) {
if (o > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
if (t >= 1 << o || e >= 1 << o) throw new Error("tile x/y outside zoom level bounds");
let r = ((1 << o) * (1 << o) - 1) / 3, n = o - 1, [s, i] = [t, e];
for (let a = 1 << n; a > 0; a >>= 1) {
let c = s & a, l = i & a;
r += (3 * c ^ l) * (1 << n), [s, i] = I(a, s, i, c, l), n--;
}
return r;
}
d(q, "zxyToTileId");
function G(o) {
let t = 3 * o + 1;
return t < 4294967296 ? 31 - Math.clz32(t) : 63 - Math.clz32(t / 4294967296);
}
d(G, "tileIdToZ");
function ie(o) {
let t = G(o) >> 1;
if (t > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
let e = ((1 << t) * (1 << t) - 1) / 3, r = o - e, n = 0, s = 0, i = 1 << t;
for (let a = 1; a < i; a <<= 1) {
let c = a & r / 2, l = a & (r ^ c);
[n, s] = I(a, n, s, c, l), r = r / 2, n += c, s += l;
}
return [t, n, s];
}
d(ie, "tileIdToZxy");
var J = ((s) => (s[s.Unknown = 0] = "Unknown", s[s.None = 1] = "None", s[s.Gzip = 2] = "Gzip", s[s.Brotli = 3] = "Brotli", s[s.Zstd = 4] = "Zstd", s))(J || {});
function P(o, t) {
return h(this, null, function* () {
if (t === 1 || t === 0) return o;
if (t === 2) {
if (typeof globalThis.DecompressionStream == "undefined") return decompressSync(new Uint8Array(o));
let e = new Response(o).body;
if (!e) throw new Error("Failed to read response stream");
let r = e.pipeThrough(new globalThis.DecompressionStream("gzip"));
return new Response(r).arrayBuffer();
}
throw new Error("Compression method not supported");
});
}
d(P, "defaultDecompress");
var O = ((a) => (a[a.Unknown = 0] = "Unknown", a[a.Mvt = 1] = "Mvt", a[a.Png = 2] = "Png", a[a.Jpeg = 3] = "Jpeg", a[a.Webp = 4] = "Webp", a[a.Avif = 5] = "Avif", a[a.Mlt = 6] = "Mlt", a))(O || {});
function _(o) {
return o === 1 ? ".mvt" : o === 2 ? ".png" : o === 3 ? ".jpg" : o === 4 ? ".webp" : o === 5 ? ".avif" : o === 6 ? ".mlt" : "";
}
d(_, "tileTypeExt");
var Y = 127;
function Q(o, t) {
let e = 0, r = o.length - 1;
for (; e <= r; ) {
let n = r + e >> 1, s = t - o[n].tileId;
if (s > 0) e = n + 1;
else if (s < 0) r = n - 1;
else return o[n];
}
return r >= 0 && (o[r].runLength === 0 || t - o[r].tileId < o[r].runLength) ? o[r] : null;
}
d(Q, "findTile");
var T = class T2 {
constructor(t) {
this.file = t;
}
getKey() {
return this.file.name;
}
getBytes(t, e) {
return h(this, null, function* () {
return { data: yield this.file.slice(t, t + e).arrayBuffer() };
});
}
};
d(T, "FileSource");
var D = class D2 {
constructor(t, e = new Headers(), r = void 0) {
var a, c;
this.url = t, this.customHeaders = e, this.credentials = r, this.mustReload = false;
let n = "";
"navigator" in globalThis && (n = (c = (a = globalThis.navigator) == null ? void 0 : a.userAgent) != null ? c : "");
let s = n.indexOf("Windows") > -1, i = /Chrome|Chromium|Edg|OPR|Brave/.test(n);
this.chromeWindowsNoCache = false, s && i && (this.chromeWindowsNoCache = true);
}
getKey() {
return this.url;
}
setHeaders(t) {
this.customHeaders = t;
}
getBytes(t, e, r, n) {
return h(this, null, function* () {
let s, i;
r ? i = r : (s = new AbortController(), i = s.signal);
let a = new Headers(this.customHeaders);
a.set("range", `bytes=${t}-${t + e - 1}`);
let c;
this.mustReload ? c = "reload" : this.chromeWindowsNoCache && (c = "no-store");
let l = yield fetch(this.url, { signal: i, cache: c, headers: a, credentials: this.credentials });
if (t === 0 && l.status === 416) {
let p = l.headers.get("Content-Range");
if (!p || !p.startsWith("bytes */")) throw new Error("Missing content-length on 416 response");
let f = +p.substr(8);
a.set("range", `bytes=0-${f - 1}`), l = yield fetch(this.url, { signal: i, cache: "reload", headers: a, credentials: this.credentials });
}
let u = l.headers.get("Etag");
if (u != null && u.startsWith("W/") && (u = null), l.status === 416 || n && u && u !== n) throw this.mustReload = true, new v(`Server returned non-matching ETag ${n} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);
if (l.status >= 300) throw new Error(`Bad response code: ${l.status}`);
let m = l.headers.get("Content-Length");
if (l.status === 200 && (!m || +m > e)) throw s && s.abort(), new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");
return { data: yield l.arrayBuffer(), etag: u || void 0, cacheControl: l.headers.get("Cache-Control") || void 0, expires: l.headers.get("Expires") || void 0 };
});
}
};
d(D, "FetchSource");
var E = D;
function y(o, t) {
let e = o.getUint32(t + 4, true), r = o.getUint32(t + 0, true);
return e * S(2, 32) + r;
}
d(y, "getUint64");
function X(o, t) {
let e = new DataView(o), r = e.getUint8(7);
if (r > 3) throw new Error(`Archive is spec version ${r} but this library supports up to spec version 3`);
return { specVersion: r, rootDirectoryOffset: y(e, 8), rootDirectoryLength: y(e, 16), jsonMetadataOffset: y(e, 24), jsonMetadataLength: y(e, 32), leafDirectoryOffset: y(e, 40), leafDirectoryLength: y(e, 48), tileDataOffset: y(e, 56), tileDataLength: y(e, 64), numAddressedTiles: y(e, 72), numTileEntries: y(e, 80), numTileContents: y(e, 88), clustered: e.getUint8(96) === 1, internalCompression: e.getUint8(97), tileCompression: e.getUint8(98), tileType: e.getUint8(99), minZoom: e.getUint8(100), maxZoom: e.getUint8(101), minLon: e.getInt32(102, true) / 1e7, minLat: e.getInt32(106, true) / 1e7, maxLon: e.getInt32(110, true) / 1e7, maxLat: e.getInt32(114, true) / 1e7, centerZoom: e.getUint8(118), centerLon: e.getInt32(119, true) / 1e7, centerLat: e.getInt32(123, true) / 1e7, etag: t };
}
d(X, "bytesToHeader");
function Z(o) {
let t = { buf: new Uint8Array(o), pos: 0 }, e = x(t), r = [], n = 0;
for (let s = 0; s < e; s++) {
let i = x(t);
r.push({ tileId: n + i, offset: 0, length: 0, runLength: 1 }), n += i;
}
for (let s = 0; s < e; s++) r[s].runLength = x(t);
for (let s = 0; s < e; s++) r[s].length = x(t);
for (let s = 0; s < e; s++) {
let i = x(t);
i === 0 && s > 0 ? r[s].offset = r[s - 1].offset + r[s - 1].length : r[s].offset = i - 1;
}
return r;
}
d(Z, "deserializeIndex");
var R = class R2 extends Error {
};
d(R, "EtagMismatch");
var v = R;
function V(o, t) {
return h(this, null, function* () {
let e = yield o.getBytes(0, 16384);
if (new DataView(e.data).getUint16(0, true) !== 19792) throw new Error("Wrong magic number for PMTiles archive");
let n = e.data.slice(0, Y), s = X(n, e.etag), i = e.data.slice(s.rootDirectoryOffset, s.rootDirectoryOffset + s.rootDirectoryLength), a = `${o.getKey()}|${s.etag || ""}|${s.rootDirectoryOffset}|${s.rootDirectoryLength}`, c = Z(yield t(i, s.internalCompression));
return [s, [a, c.length, c]];
});
}
d(V, "getHeaderAndRoot");
function F(o, t, e, r, n, s) {
return h(this, null, function* () {
let i = yield o.getBytes(e, r, s, n.etag), a = yield t(i.data, n.internalCompression), c = Z(a);
if (c.length === 0) throw new Error("Empty directory is invalid");
return c;
});
}
d(F, "getDirectory");
var U = class U2 {
constructor(t = 100, e = true, r = P) {
this.cache = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
}
getHeader(t) {
return h(this, null, function* () {
let e = t.getKey(), r = this.cache.get(e);
if (r) return r.lastUsed = this.counter++, r.data;
let n = yield V(t, this.decompress);
return n[1] && this.cache.set(n[1][0], { lastUsed: this.counter++, data: n[1][2] }), this.cache.set(e, { lastUsed: this.counter++, data: n[0] }), this.prune(), n[0];
});
}
getDirectory(t, e, r, n, s) {
return h(this, null, function* () {
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
if (a) return a.lastUsed = this.counter++, a.data;
let c = yield F(t, this.decompress, e, r, n, s);
return this.cache.set(i, { lastUsed: this.counter++, data: c }), this.prune(), c;
});
}
prune() {
if (this.cache.size > this.maxCacheEntries) {
let t = 1 / 0, e;
this.cache.forEach((r, n) => {
r.lastUsed < t && (t = r.lastUsed, e = n);
}), e && this.cache.delete(e);
}
}
invalidate(t) {
return h(this, null, function* () {
this.cache.delete(t.getKey());
});
}
};
d(U, "ResolvedValueCache");
var M = class M2 {
constructor(t = 100, e = true, r = P) {
this.cache = /* @__PURE__ */ new Map(), this.invalidations = /* @__PURE__ */ new Map(), this.pendingFetches = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
}
getHeader(t) {
return h(this, null, function* () {
let e = t.getKey(), r = this.cache.get(e);
if (r) return r.lastUsed = this.counter++, yield r.data;
let n = new Promise((s, i) => {
V(t, this.decompress).then((a) => {
a[1] && this.cache.set(a[1][0], { lastUsed: this.counter++, data: Promise.resolve(a[1][2]) }), s(a[0]), this.prune();
}).catch((a) => {
i(a);
});
});
return this.cache.set(e, { lastUsed: this.counter++, data: n }), n;
});
}
trackSignal(t, e, r) {
e.refs++, r.addEventListener("abort", () => {
--e.refs <= 0 && this.pendingFetches.get(t) === e && (e.controller.abort(), this.cache.delete(t), this.pendingFetches.delete(t));
}, { once: true });
}
getDirectory(t, e, r, n, s) {
return h(this, null, function* () {
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
if (a) {
a.lastUsed = this.counter++;
let m = this.pendingFetches.get(i);
return m && this.trackSignal(i, m, s != null ? s : new AbortController().signal), yield a.data;
}
let c = new AbortController(), l = { controller: c, refs: 0 };
this.trackSignal(i, l, s != null ? s : new AbortController().signal), this.pendingFetches.set(i, l);
let u = new Promise((m, g) => {
F(t, this.decompress, e, r, n, c.signal).then((p) => {
this.pendingFetches.delete(i), m(p), this.prune();
}).catch((p) => {
g(p);
});
});
return this.cache.set(i, { lastUsed: this.counter++, data: u }), u;
});
}
prune() {
if (this.cache.size >= this.maxCacheEntries) {
let t = 1 / 0, e;
this.cache.forEach((r, n) => {
r.lastUsed < t && (t = r.lastUsed, e = n);
}), e && this.cache.delete(e);
}
}
invalidate(t) {
return h(this, null, function* () {
let e = t.getKey();
if (this.invalidations.get(e)) return yield this.invalidations.get(e);
this.cache.delete(t.getKey());
let r = new Promise((n, s) => {
this.getHeader(t).then((i) => {
n(), this.invalidations.delete(e);
}).catch((i) => {
s(i);
});
});
this.invalidations.set(e, r);
});
}
};
d(M, "SharedPromiseCache");
var C = M;
var H = class H2 {
constructor(t, e, r) {
typeof t == "string" ? this.source = new E(t) : this.source = t, r ? this.decompress = r : this.decompress = P, e ? this.cache = e : this.cache = new C();
}
getHeader() {
return h(this, null, function* () {
return yield this.cache.getHeader(this.source);
});
}
getZxyAttempt(t, e, r, n) {
return h(this, null, function* () {
let s = q(t, e, r), i = yield this.cache.getHeader(this.source);
if (n == null || n.throwIfAborted(), t < i.minZoom || t > i.maxZoom) return;
let a = i.rootDirectoryOffset, c = i.rootDirectoryLength;
for (let l = 0; l <= 3; l++) {
let u = yield this.cache.getDirectory(this.source, a, c, i, n);
n == null || n.throwIfAborted();
let m = Q(u, s);
if (m) {
if (m.runLength > 0) {
let g = yield this.source.getBytes(i.tileDataOffset + m.offset, m.length, n, i.etag);
return { data: yield this.decompress(g.data, i.tileCompression), cacheControl: g.cacheControl, expires: g.expires };
}
a = i.leafDirectoryOffset + m.offset, c = m.length;
} else return;
}
throw new Error("Maximum directory depth exceeded");
});
}
getZxy(t, e, r, n) {
return h(this, null, function* () {
try {
return yield this.getZxyAttempt(t, e, r, n);
} catch (s) {
if (s instanceof v) return this.cache.invalidate(this.source), yield this.getZxyAttempt(t, e, r, n);
throw s;
}
});
}
getMetadataAttempt() {
return h(this, null, function* () {
let t = yield this.cache.getHeader(this.source), e = yield this.source.getBytes(t.jsonMetadataOffset, t.jsonMetadataLength, void 0, t.etag), r = yield this.decompress(e.data, t.internalCompression), n = new TextDecoder("utf-8");
return JSON.parse(n.decode(r));
});
}
getMetadata() {
return h(this, null, function* () {
try {
return yield this.getMetadataAttempt();
} catch (t) {
if (t instanceof v) return this.cache.invalidate(this.source), yield this.getMetadataAttempt();
throw t;
}
});
}
getTileJson(t) {
return h(this, null, function* () {
let e = yield this.getHeader(), r = yield this.getMetadata(), n = _(e.tileType);
return { tilejson: "3.0.0", scheme: "xyz", tiles: [`${t}/{z}/{x}/{y}${n}`], vector_layers: r.vector_layers, attribution: r.attribution, description: r.description, name: r.name, version: r.version, bounds: [e.minLon, e.minLat, e.maxLon, e.maxLat], center: [e.centerLon, e.centerLat, e.centerZoom], minzoom: e.minZoom, maxzoom: e.maxZoom };
});
}
};
d(H, "PMTiles");
var w = H;
// src/map/pmtiles.worker.ts
var workerScope = self;
workerScope.addProtocol("pmtiles", new B().tile);
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 283 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+584
View File
@@ -0,0 +1,584 @@
#!/usr/bin/env node
/**
* به‌روزرسانی یک‌بارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
*
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» می‌شود — هم روی دیسک
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
* موفقیت پشتیبان، پالت تازه نوشته و آپلود می‌شود.
*
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
* می‌شوند، نه JSON خام؛ پس تنها راه درست به‌روزرسانی، PUT روی همان API است.
*
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیش‌فرض هیچ تغییری نمی‌دهد:
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
* node --experimental-strip-types scripts/update-map-palette.ts --palette
* node --experimental-strip-types scripts/update-map-palette.ts --restore
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
*
* در همهٔ حالت‌ها (جز restore) ترتیب امن رعایت می‌شود: خواندن و اعتبارسنجی →
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
*
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده می‌شوند (MINIO_ROOT_USER /
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نام‌ها.
*/
import { createHash, createHmac } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
/* ------------------------------------------------------------------ */
/* پالت تازه */
/* ------------------------------------------------------------------ */
/** نگاشت دسته‌ها به رنگ‌های تازهٔ برند — تنها منبع حقیقت این اسکریپت */
const PALETTE = {
background: '#181512',
urban: '#27221c',
highway: '#6a5544',
street: '#4a3a2d',
alley: '#352c25',
water: '#172c32',
parks: '#20312b',
nature: '#314238',
mainText: '#ead9c6',
subText: '#a99584',
borders: '#3e362e',
} as const
type Category = keyof typeof PALETTE
/* ------------------------------------------------------------------ */
/* قواعد دسته‌بندی لایه‌ها */
/* ------------------------------------------------------------------ */
type Layer = {
id: string
type: string
'source-layer'?: string
paint?: Record<string, unknown>
layout?: Record<string, unknown>
}
type StyleDocument = {
version?: number
layers?: Layer[]
sources?: Record<string, unknown>
/** الگوی نشانی گلیف‌ها — باید به پوشهٔ v2.0 باکت اشاره کند */
glyphs?: string
}
/** پوشهٔ فونت‌های آفلاین در باکت MinIO */
const GLYPHS_FOLDER = 'v2.0'
/** الگوی استاندارد گلیف — جانگهدارها دست‌نخورده می‌مانند */
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
/**
* اصلاح مسیر گلیف‌ها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
* «v2.0/{fontstack}/{range}.pbf» تبدیل می‌شود تا با ساختار پوشه‌های باکت
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمی‌کند.
*/
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
const current = (style.glyphs ?? '').trim()
const marker = current.indexOf('{fontstack}')
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
if (next === current) return null
style.glyphs = next
return { from: current || '(خالی)', to: next }
}
/** آیا مقدار text-field به یک فیلد «نام» اشاره می‌کند؟ (name، name_en، name:latin و…) */
const NAME_REFERENCE = /name(:[a-z-]+)?/i
/**
* فونت‌های محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
* تنها پوشه‌های Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
* فقط لاتین‌اند.
*/
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
/**
* بازگردانی متن برچسب‌ها به قالب دوزبانهٔ اصلی، به‌تفکیک لایه.
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راه‌ها)
*/
const TEXT_FIELD_RESTORE: Record<string, string> = {
water_name: '{name:latin}\n{name:nonlatin}',
place_other: '{name:latin}\n{name:nonlatin}',
place_suburb: '{name:latin}\n{name:nonlatin}',
place_village: '{name:latin}\n{name:nonlatin}',
place_town: '{name:latin}\n{name:nonlatin}',
place_city: '{name:latin}\n{name:nonlatin}',
place_city_large: '{name:latin}\n{name:nonlatin}',
place_state: '{name:latin}\n{name:nonlatin}',
highway_name_other: '{name:latin} {name:nonlatin}',
place_country_other: '{name:latin}',
place_country_minor: '{name:latin}',
place_country_major: '{name:latin}',
}
/** قالب پیش‌فرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
function referencesName(value: unknown): boolean {
if (typeof value === 'string') return NAME_REFERENCE.test(value)
if (Array.isArray(value)) return value.some(referencesName)
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
return false
}
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
function persianFontFor(stack: string[]): string {
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
if (local) return local
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
}
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
/**
* آماده‌سازی برچسب‌ها برای فارسی:
* ۱) متن به قالب دوزبانهٔ اصلی برمی‌گردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
* ۲) پشتهٔ فونت بازچینی می‌شود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
* چون مپ‌لیبر برای هر پشته فقط یک فایل گلیف می‌گیرد، همان فونت اول باید هر دو خط
* را پوشش دهد و Noto Sans لاتین را هم دارد.
*/
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
const changed: LabelChange[] = []
const skipped: string[] = []
for (const layer of style.layers ?? []) {
const layout = layer.layout
if (layer.type !== 'symbol' || !layout) continue
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
if (!stack.length) continue
const persianFont = persianFontFor(stack)
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
if (!('text-field' in layout)) {
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح می‌شود
layout['text-font'] = restacked
changed.push({ id: layer.id, font: fontChange })
continue
}
if (!referencesName(layout['text-field'])) {
layout['text-font'] = restacked
skipped.push(layer.id)
continue
}
const from = JSON.stringify(layout['text-field']).slice(0, 48)
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
layout['text-field'] = to
layout['text-font'] = restacked
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
}
return { changed, skipped }
}
/**
* بررسی فونت‌ها: هر فونتی که در text-font لایه‌های برچسب استفاده شده باید در
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
*/
async function verifyFonts(style: StyleDocument): Promise<void> {
const fonts = new Set<string>()
for (const layer of style.layers ?? []) {
if (layer.type !== 'symbol') continue
const stack = layer.layout?.['text-font']
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
}
if (!fonts.size) {
console.log('• فونتی در لایه‌های برچسب پیدا نشد')
return
}
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
const missing: string[] = []
for (const font of [...fonts].sort()) {
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
const ok = (await fetch(url, { method: 'HEAD' })).ok
if (!ok) missing.push(font)
console.log(` ${ok ? '✓' : '✗'} ${font}`)
}
console.log(
missing.length
? `✗ فونت‌های ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
: `✓ همهٔ فونت‌ها در ${GLYPHS_FOLDER} باکت موجودند`,
)
}
/* ------------------------------------------------------------------ */
/* بررسی پوشش فونت‌های محلی (کدام فونت فارسی را رندر می‌کند؟) */
/* ------------------------------------------------------------------ */
/** بازه‌های کلیدی گلیف: لاتین پایه، عربی/فارسی، فرم‌های نمایشی عربی */
const COVERAGE_RANGES = [
{ label: 'لاتین', range: '0-255' },
{ label: 'فارسی', range: '1536-1791' },
{ label: 'فرم فارسی', range: '65024-65279' },
] as const
/** فهرست پوشه‌های فونت داخل v2.0 باکت */
async function listFontFolders(): Promise<string[]> {
const response = await fetch(
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
)
const xml = await response.text()
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
return [...xml.matchAll(pattern)].map((match) => match[1])
}
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
async function rangeCoverage(font: string, range: string): Promise<string> {
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) return 'نیست'
const bytes = Number(response.headers.get('content-length') ?? 0)
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
}
/** گزارش پوشش خط‌ها برای همهٔ فونت‌های باکت — ورودی تصمیم انتخاب فونت برچسب‌ها */
async function auditFonts(): Promise<void> {
const fonts = await listFontFolders()
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
for (const font of fonts) {
const cells: string[] = []
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
}
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر می‌کند.')
}
/**
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا می‌شود تا مطمئن شویم سند
* سالم است و اسکیمای سبک (نسخه، لایه‌ها، منابع) نشکسته است.
*/
function validateStyle(style: StyleDocument, label: string): void {
const problems: string[] = []
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
else {
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
}
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
problems.push('sources باید یک شیء باشد')
}
console.log(
problems.length
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
)
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
}
type Rule = {
category: Category
/** با این الگو روی شناسهٔ لایه تطبیق داده می‌شود */
test: RegExp
/** کدام ویژگی‌های paint رنگ‌آمیزی شوند */
properties: string[]
}
/**
* جدول قواعد — به ترتیب بررسی می‌شود و نخستین تطبیق برنده است.
* رنگ‌های داخل عبارت‌ها (interpolate/match) هم بازنویسی می‌شوند تا پالت
* یکدست بماند.
*/
const RULES: Rule[] = [
{ category: 'background', test: /^background$/, properties: ['background-color'] },
// آب: خود لایه‌های آب و راه‌های آبی (برچسب‌ها قاعدهٔ جدا دارند)
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
// پارک‌ها پیش از طبیعت بررسی می‌شوند تا چمن/پارک زیر «طبیعت» نرود
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
// طبیعت: پوشش زمینِ جنگلی/علف‌زارِ بیرون از شهر
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
// کاربری شهری و ساختمان‌ها
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
// برچسب‌های اصلی: شهر/شهرک و نام بزرگراه‌ها
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
// بقیهٔ برچسب‌ها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
]
/** ویژگی‌های فرعی که همیشه با رنگ پس‌زمینه هماهنگ می‌شوند تا خوانایی بماند */
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
/** ویژگی‌های آیکون برچسب‌ها که با متن هم‌رنگ می‌شوند */
const ICON_PROPERTIES = ['icon-color']
const COLOR_PATTERN = /^(#|rgb|hsl)/i
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
/* ------------------------------------------------------------------ */
/* بازنویسی رنگ‌ها (توابع خالص) */
/* ------------------------------------------------------------------ */
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارت‌ها — را با رنگ تازه عوض می‌کند */
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
if (isColor(value)) {
stats.seen.add(value.trim())
stats.replaced += 1
return color
}
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {}
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
return result
}
return value
}
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
/** یک لایه را بر پایهٔ قواعد رنگ‌آمیزی می‌کند و گزارش تغییرها را برمی‌گرداند */
function recolorLayer(layer: Layer): LayerReport {
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
const stats = { replaced: 0, seen: new Set<string>() }
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
if (rule) {
for (const property of rule.properties) {
if (!(property in layer.paint)) continue
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
}
}
// لایه‌های برچسب: هاله و آیکون‌ها با پالت هماهنگ می‌شوند
if (layer.type === 'symbol') {
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
? PALETTE[rule.category]
: PALETTE.subText
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
}
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
if (!(property in layer.paint)) continue
const target = property === 'icon-color' ? textColor : PALETTE.background
layer.paint[property] = paintColor(layer.paint[property], target, stats)
}
}
// لبهٔ ساختمان‌ها با رنگ مرزها هم‌خانواده می‌شود
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
}
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
}
/** کل سند سبک را بازنویسی می‌کند (بدون دست‌زدن به ساختار و ویژگی‌های غیررنگی) */
function recolorStyle(style: StyleDocument): LayerReport[] {
const layers = style.layers ?? []
return layers.map(recolorLayer)
}
/** گزارش فشردهٔ تغییرهای پالت — به‌تفکیک دسته و لایه‌های دست‌نخورده */
function reportPalette(reports: LayerReport[]): void {
const byCategory = new Map<string, number>()
for (const report of reports) {
if (!report.replaced) continue
const category = report.category ?? 'بدون‌دسته'
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
}
console.log('\nگزارش تغییرها:')
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
}
const untouched = reports.filter((report) => !report.replaced)
if (untouched.length) {
console.log(`\nلایه‌های بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
}
}
/* ------------------------------------------------------------------ */
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
/* ------------------------------------------------------------------ */
type Credentials = { user: string; secret: string }
async function readCredentials(): Promise<Credentials> {
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
const fromFile: Record<string, string> = {}
try {
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
if (match) fromFile[match[1]] = match[2].trim()
}
} catch {
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین می‌شوند */
}
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
return { user, secret }
}
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام می‌دهند */
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
const url = new URL(objectPath, endpoint)
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
const dateStamp = amzDate.slice(0, 8)
const payloadHash = sha256(body)
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
const authorization =
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: authorization,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
'Content-Type': 'application/json',
},
body,
})
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
}
/* ------------------------------------------------------------------ */
/* اجرا */
/* ------------------------------------------------------------------ */
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
const STYLE_PATH = '/maps/style.json'
const BACKUP_PATH = '/maps/style.backup.json'
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
/** بازگردانی: پشتیبان دقیقاً به‌جای style.json نوشته می‌شود */
async function restore(): Promise<void> {
const backup = await readFile(LOCAL_BACKUP, 'utf8')
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
console.log(
restored === backup
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
: '✗ محتوای بازگردانده‌شده با پشتیبان یکسان نیست — دستی بررسی کنید',
)
}
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
async function main(): Promise<void> {
const dryRun = process.argv.includes('--dry-run')
const action = ACTIONS.find((flag) => process.argv.includes(flag))
// پیش‌فرض امن: بدون پرچم هیچ نوشتنی انجام نمی‌شود
if (!action) {
console.log(
'کاری انجام نشد — یکی از پرچم‌ها را بدهید:\n' +
' --audit-fonts گزارش پوشش خط‌های هر فونت در v2.0 (فقط خواندن)\n' +
' --fix-labels برچسب‌های دوزبانه + پشتهٔ فونت محلی\n' +
' --fix-glyphs اصلاح مسیر گلیف‌ها به v2.0\n' +
' --palette اعمال پالت تازه\n' +
' --restore بازگردانی از پشتیبان\n' +
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
)
return
}
if (action === '--audit-fonts') {
await auditFonts()
return
}
if (action === '--restore') {
await restore()
return
}
// ۱) خواندن سبک فعلی
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
const original = await response.text()
const style = JSON.parse(original) as StyleDocument
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
validateStyle(style, 'نسخهٔ فعلی')
// ۲) پشتیبان‌گیری، پیش از هر تغییر
await mkdir(OUTPUT_DIR, { recursive: true })
await writeFile(LOCAL_BACKUP, original, 'utf8')
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
if (!dryRun) {
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
}
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
if (action === '--fix-labels') {
const { changed, skipped } = fixLabels(style)
console.log(`\nلایه‌های برچسب اصلاح‌شده (${changed.length}):`)
for (const layer of changed) {
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
}
if (skipped.length) {
console.log(`\nلایه‌های بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
}
await verifyFonts(style)
} else if (action === '--fix-glyphs') {
const change = fixGlyphsPath(style)
console.log(
change ? `\nمسیر گلیف‌ها: ${change.from} → ${change.to}` : '\nمسیر گلیف‌ها از قبل به v2.0 اشاره می‌کند',
)
await verifyFonts(style)
} else {
reportPalette(recolorStyle(style))
}
validateStyle(style, 'نسخهٔ تازه')
// ۴) نوشتن و آپلود نسخهٔ تازه
const updated = JSON.stringify(style, null, 2)
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
if (dryRun) {
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
return
}
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
}
main().catch((error: unknown) => {
console.error('\nخطا:', error instanceof Error ? error.message : error)
process.exitCode = 1
})
+13 -1
View File
@@ -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
+84 -1
View File
@@ -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={{
+10 -1
View File
@@ -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',
+16 -4
View File
@@ -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)
} }
}, []) }, [])
+13 -14
View File
@@ -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>
+65
View File
@@ -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>
)
}
-110
View File
@@ -1,110 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
>
{children}
</span>
</>
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+138 -22
View File
@@ -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>
) )
+226 -88
View File
@@ -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>
); );
+67 -5
View File
@@ -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>
) )
} }
+38 -26
View File
@@ -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>
)} )}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
type Props = {
to?: string
href?: string
children: React.ReactNode
className?: string
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}
/**
* Premium Button replicating the cinematic, high-end documentary aesthetic.
* Features an exact cubic-bezier background fill from the bottom, transforming
* text color smoothly to maintain contrast.
*/
export function PremiumButton({ to, href, children, className = '', onClick, disabled, type = 'button' }: Props) {
const navigate = useNavigate()
// Primary: ink, Secondary: copper
const baseClasses = `
group/btn relative inline-flex items-center justify-center overflow-hidden
rounded-full px-10 py-4 text-sm font-bold tracking-widest text-copper
border border-copper bg-ink
transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg
cursor-pointer select-none
${className}
`.trim()
const innerEffect = (
<>
<span className="absolute inset-0 z-0 bg-copper scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
{children}
</span>
</>
)
const handleClick = (e: React.MouseEvent) => {
if (onClick) onClick();
if (to) {
e.preventDefault();
navigate(to);
}
}
if (href) {
return (
<a href={href} className={baseClasses} onClick={handleClick}>
{innerEffect}
</a>
)
}
return (
<button type={type} disabled={disabled} className={baseClasses} onClick={handleClick}>
{innerEffect}
</button>
)
}
+62 -33
View File
@@ -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>
)
}
+132 -3
View File
@@ -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
+24 -2
View File
@@ -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
+93 -18
View File
@@ -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>
);
} }
+8 -6
View File
@@ -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>
)} )}
+57 -35
View File
@@ -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>
+10 -35
View File
@@ -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>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */} {/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
+8 -9
View File
@@ -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>
+16 -14
View File
@@ -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>
+61 -24
View File
@@ -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) => {
+4 -5
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button' import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
@@ -89,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && ( {showLink && (
<Reveal delay={0.22}> <Reveal delay={0.22}>
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light"> <PremiumButton to={href(story.link_href)} className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} /> <InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> </PremiumButton>
</ButtonLink>
</Reveal> </Reveal>
)} )}
+9 -10
View File
@@ -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>
) )
} }
+46 -28
View File
@@ -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>
+56 -18
View File
@@ -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>
) )
+80 -33
View File
@@ -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>
);
} }
+5
View File
@@ -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'
+247
View File
@@ -0,0 +1,247 @@
/**
* مختصات پروژه‌های گروه ساختمانی آرامش — برگرفته از شیت «Sheet1.csv».
*
* دو نکته‌ی داده‌ای که در همان‌جا حل می‌شوند:
* ۱) ستون‌های neshan_url و google_maps_url در شیت فقط برچسب بودند
* («Open in Neshan») و نشانی واقعی نداشتند؛ پس پیوندها از روی مختصات
* ساخته می‌شوند که الگوی تثبیت‌شده‌ی هر دو سرویس است.
* ۲) ستون توضیحات خالی است؛ متن پیش‌فرض در لایه‌ی رابط کاربری جایگزین می‌شود.
*/
import type { Project, ProjectCardItem } from './site'
export type MapProject = {
id: number
name: string
/** سال شمسی — ممکن است خالی باشد */
year: string
/** پروژه‌ی در جریان (ستون new شیت) */
isNew: boolean
lat: number
lng: number
description: string
}
/** مرکز پیش‌فرض نقشه — اصفهان */
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
/**
* ستون‌های برچسبی شیت حذف شده‌اند (نشانی واقعی نداشتند) و ستون توضیحات به
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
*/
const RAW_CSV = `name,year,new,lat,lng,description
پروژه سروش,1381,no,32.6662842,51.701735,
چهرازی-زیتون,1383,no,32.6700428,51.69985,
جعفریان,1384,no,32.6669259,51.701085,
بهار,1385,yes,32.6468963,51.6899119,
حسن مرادی,1385,no,32.6704278,51.6985567,
کوثر,1386,no,32.6522044,51.6856474,
صبا,1387,no,32.6541363,51.6847828,
پورمعالی - بهشت,1387,no,32.6500402,51.6919823,
2 پروژه- فردوس,1388,no,32.6614576,51.706429,
ارم,1388,no,32.6563712,51.710694,
سینا,1388,no,32.6469299,51.6855403,
مسجد سید,1389,yes,32.6669953,51.6557085,
هفده شهریور,1389,yes,32.7249998,51.6543819,
آرامش رفیع زاده,1390,no,32.6467107,51.6873398,
بن بست شریعت,1390,yes,32.6459939,51.6897556,
قصر - یاسین,1390,no,32.6663549,51.7065512,
پردیس,1390,no,32.6518961,51.6973621,
آرامش حکیم,1392,no,32.6426783,51.6813477,
آرامش دخانیات,1393,no,32.6411924,51.6797555,
بهشتی فرشادی,1393,no,32.6531989,51.6790984,
کاکتوس,1394,no,32.6486747,51.6859003,
آرامش - صیاد,1395,no,32.6486798,51.712659,
کوثر,1395,yes,32.6506204,51.6344053,
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424,
میرزا نصیر,1396,no,32.668195,51.7173079,
نیکان,1396,yes,32.6504663,51.6355982,
آرامش-رسا,1397,no,32.6522126,51.6857297,
نگین,1397,yes,32.6507084,51.6313395,
آرامش - بوعلی 2,1398,no,32.645391,51.6917828,
آرامش استاد همایی,1398,no,32.6563505,51.691988,
آرامش صدر,1398,no,32.6469234,51.6826176,
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268,
آرامش- صدر,1399,no,32.6469205,51.6829887,
آرامش - سنایی,1400,yes,32.6973339,51.6194128,
مغازه لباس فروشی,1400,no,32.6516965,51.7020497,
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244,
آرامش - بزرگمهر,1401,no,32.651381,51.6993575,
آرامش - سعدی,1401,yes,32.6970115,51.6196909,
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248,
آرامش 2,1401,yes,32.6505704,51.6337863,
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827,
آرامش احمدآباد,1401,no,32.6626101,51.6969828,
تجاری آتشگاه,1401,yes,32.6515087,51.6033278,
آرامش - شهیدان,1402,yes,32.6854968,51.6171964,
آرامش خواجو,1402,no,32.6419187,51.6831873,
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519,
آرامش - انقلاب,1403,yes,32.719769,51.645921,
آرامش - شریف,1404,no,32.6473319,51.6955996,
آرامش - کهندژ,1404,yes,32.6649497,51.630077,
آرامش جی,1404,no,32.6610717,51.7110184,
آرامش - بابک,1405,yes,32.674076,51.6368204,
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771,
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643,
آرامش - وحدت,1405,yes,32.6537564,51.6197007,
آرامش پروین,1405,no,32.6618223,51.7098152,
خلیفه سلطانی,1405,yes,32.7035398,51.6223296,
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975,
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342,
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436,
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,`
/**
* تجزیه‌ی یک خط CSV — پشتیبانی از فیلدهای گیومه‌دار و گیومه‌ی دوگانه‌ی درونی.
* جداکننده کاما و نویسه‌های مخفی ابتدای فایل (BOM) هم حذف می‌شوند.
*/
function parseCsvLine(line: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i += 1) {
const char = line[i]
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
current += '"'
i += 1
} else inQuotes = false
} else current += char
} else if (char === '"') inQuotes = true
else if (char === ',') {
fields.push(current.trim())
current = ''
} else current += char
}
fields.push(current.trim())
return fields
}
/** CSV خام → آرایه‌ی تایپ‌شده‌ی JSON */
export function parseProjectsCsv(raw: string): MapProject[] {
const rows: CsvRow[] = []
const lines = raw.replace(/^\ufeff/, '').split(/\r?\n/)
for (const line of lines.slice(1)) {
if (!line.trim()) continue
const [name, year = '', isNew = '', lat = '', lng = '', description = ''] = parseCsvLine(line)
const parsedLat = Number(lat)
const parsedLng = Number(lng)
if (!name || !Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) continue
rows.push({ name, year, isNew: isNew.toLowerCase() === 'yes', lat: parsedLat, lng: parsedLng, description })
}
return rows.map((row, index) => ({ id: index + 1, ...row }))
}
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
/** پیوند نشان روی نقشه برای سرویس نشان */
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string {
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z`
}
/** پیوند نقشه گوگل */
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
return `https://www.google.com/maps?q=${project.lat},${project.lng}`
}
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
/**
* نرمال‌سازی نام برای تطبیق شیت با داده‌ی بک‌اند: نویسه‌های نامرئی،
* ی/ک عربی، نشانه‌ها و واژه‌های عمومی حذف می‌شوند تا «آرامش - سنایی» و
* «پروژه سنایی» یک کلید بگیرند.
*/
export function normalizeProjectName(name: string): string {
let result = (name ?? '')
.normalize('NFC')
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim()
for (const noise of NAME_NOISE) result = result.replace(new RegExp(`(^|\\s)${noise}(?=\\s|$)`, 'g'), ' ')
return result.replace(/\s+/g, ' ').trim()
}
/** پروژه‌ی شیت متناظر با یک نام (تطبیق دقیق نرمال‌شده، سپس دربرگیری) */
export function findMapProjectByName(name: string): MapProject | undefined {
const key = normalizeProjectName(name)
if (!key) return undefined
const exact = MAP_PROJECTS.find((project) => normalizeProjectName(project.name) === key)
if (exact) return exact
return MAP_PROJECTS.find((project) => {
const candidate = normalizeProjectName(project.name)
return candidate.length >= 3 && key.length >= 3 && (candidate.includes(key) || key.includes(candidate))
})
}
/**
* محل جایگزین وقتی پروژه‌ای در شیت پیدا نشد — مرکز اصفهان با نام پروژه‌ی
* بک‌اند، تا نمای تک‌پروژه هرگز بدون نقشه نماند.
*/
export function createPlaceholderProject(name: string, description = ''): MapProject {
return {
id: 0,
name,
year: '',
isNew: false,
lat: ISFAHAN_CENTER[1],
lng: ISFAHAN_CENTER[0],
description,
}
}
/**
* کادر محدودهٔ نقشه از مختصات پروژه‌ها — با حاشیه تا لبهٔ نقشه خالی نماند و
* کاربر به بیرون از محدودهٔ پروژه‌ها جابه‌جا نشود.
*/
export function boundsForProjects(
projects: Pick<MapProject, 'lat' | 'lng'>[],
padding = 0.02,
): [[number, number], [number, number]] | undefined {
if (!projects.length) return undefined
const lats = projects.map((project) => project.lat)
const lngs = projects.map((project) => project.lng)
return [
[Math.min(...lngs) - padding, Math.min(...lats) - padding],
[Math.max(...lngs) + padding, Math.max(...lats) + padding],
]
}
/** ردیف شیت → کارت فهرست: عنوان از شیت، وضعیت از ستون new، نشانی از مختصات */
export function csvProjectToCard(project: MapProject, detailHref: string): ProjectCardItem {
return {
id: project.id,
title: project.name,
status: project.isNew ? 'progress' : 'done',
detailHref,
addressUrl: buildNeshanUrl(project),
}
}
/** پروژهٔ بک‌اند → کارت فهرست؛ نشانی نشان از تطبیق نامی با شیت می‌آید */
export function apiProjectToCard(project: Project, detailHref: string): ProjectCardItem {
const matched = findMapProjectByName(project.title)
return {
id: project.id,
title: project.title,
status: project.status,
detailHref,
location: project.location || undefined,
image: project.image || undefined,
area: project.area || undefined,
addressUrl: matched ? buildNeshanUrl(matched) : undefined,
}
}
+74
View File
@@ -0,0 +1,74 @@
export type BilingualText = { en: string; fa: string };
export type StaticProject = {
id: string;
slug: string;
name: BilingualText;
location: BilingualText;
area: BilingualText;
status: 'done' | 'progress';
};
export const projectsData: StaticProject[] = [
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
];
+26 -1
View File
@@ -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;
};
+23 -3
View File
@@ -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 }
+1
View File
@@ -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,
+74
View File
@@ -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;
}
} }
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
import { lazy, Suspense } from 'react'
import type { ProjectsMapAllProps } from './ProjectsMapAll'
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
/**
* بارگیری تنبل نقشه‌ها — بسته‌ی سنگین مپ‌لیبر فقط وقتی دانلود می‌شود که
* واقعاً نقشه‌ای روی صفحه باشد؛ صفحه‌های بدون نقشه (مثل خانه) جریمه‌ی حجم
* نمی‌پردازند. Import زیر فقط نوع است و باندل را سنگین نمی‌کند.
*/
const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({ default: module.ProjectsMapAll })))
const ProjectsMapSingle = lazy(() =>
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
)
/** جای‌نگهدار هم‌اندازه‌ی نقشه تا چیدمان هنگام بارگیری نلرزد */
function MapSkeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
}
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
return (
<Suspense fallback={<MapSkeleton className={props.className} />}>
<ProjectsMapAll {...props} />
</Suspense>
)
}
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
return (
<Suspense fallback={<MapSkeleton className={props.className} />}>
<ProjectsMapSingle {...props} />
</Suspense>
)
}
+133
View File
@@ -0,0 +1,133 @@
import * as maplibregl from 'maplibre-gl'
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
import { Loader2Icon } from 'lucide-react'
import { MdClose, MdFullscreen } from 'react-icons/md'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useLang } from '../i18n/lang'
import type { MapStatus } from './types'
type MapViewportProps = {
containerRef: MutableRefObject<HTMLDivElement | null>
/** نمونه‌ی نقشه — فقط برای هم‌گام‌سازی اندازه؛ مالکیتش با useMapLibre است */
map: maplibregl.Map | null
status: MapStatus
onRetry: () => void
/** برچسب دسترس‌پذیری ناحیه‌ی نقشه */
label: string
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string
/** محتوای شناور روی نقشه (پورتال پاپ‌آپ) */
children?: ReactNode
}
/**
* پوسته‌ی نقشه — قاب شیشه‌ای، حالت تمام‌صفحه، کنترل بزرگ‌نمایی و لایه‌های
* بارگذاری/خطا.
*
* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد می‌نشیند (نه absolute)؛
* استایل خود مپ‌لیبر بعد از تیلویند بار می‌شود و position: relative می‌دهد،
* پس کلاس absolute بی‌اثر می‌شد و ظرف صفر می‌ماند.
*
* در تمام‌صفحه فقط شفافیت و گوشه‌ها انیمیت می‌شوند تا بافر بوم هرگز کشیده
* و تار نشود؛ تغییر اندازه در همان فریم اعمال می‌شود.
*/
export function MapViewport({
containerRef,
map,
status,
onRetry,
label,
className = '',
children,
}: MapViewportProps) {
const { t } = useLang()
const [isFullscreen, setIsFullscreen] = useState(false)
/** تمام‌صفحه: Esc برای خروج و قفل اسکرول پس‌زمینه */
useEffect(() => {
if (!isFullscreen) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false)
}
window.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
}
}, [isFullscreen])
/** پس از پایان انتقال، بوم را هم‌گام کن (مپ‌لیبر خودش هم ResizeObserver دارد) */
useEffect(() => {
if (!isFullscreen) return
const timer = window.setTimeout(() => map?.resize(), 340)
return () => window.clearTimeout(timer)
}, [isFullscreen, map])
const shellClass = isFullscreen
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
return (
<div
dir="rtl"
aria-label={label}
className={`overflow-hidden bg-char font-pinar transition-all duration-300 ease-lux ${shellClass}`}
>
{/* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد است؛ کلاس relative صریح
است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار
«container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول
رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */}
<div ref={containerRef} className="relative h-full w-full" />
{isFullscreen ? (
<button
type="button"
onClick={() => setIsFullscreen(false)}
aria-label={t('mapExitFullscreen')}
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
>
<MdClose className="h-4 w-4" aria-hidden="true" />
{t('mapExitFullscreen')}
</button>
) : (
<button
type="button"
onClick={() => setIsFullscreen(true)}
aria-label={t('mapFullscreen')}
title={t('mapFullscreen')}
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
</button>
)}
{status !== 'ready' && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
{status === 'loading' ? (
<>
<Loader2Icon className="h-7 w-7 animate-spin text-copper" aria-hidden="true" />
<p className="text-xs font-light tracking-wide text-ivory/60">{t('mapLoading')}</p>
</>
) : (
<>
<p className="max-w-xs text-center text-xs font-light leading-6 text-ivory/60">
{t('mapErrorText')}
</p>
<button
type="button"
onClick={onRetry}
className="border border-copper/50 px-6 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
{t('retry')}
</button>
</>
)}
</div>
)}
{children}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useState, type MouseEvent, type PointerEvent } from 'react'
import { MdChevronLeft, MdChevronRight } from 'react-icons/md'
import { ProjectImagePlaceholder } from '../components/ProjectImagePlaceholder'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
type ProjectImageSliderProps = {
/** همان آرایهٔ تصاویر صفحهٔ جزئیات پروژه — در نبودش جای‌نگهدار می‌آید */
images: string[]
alt: string
className?: string
}
/**
* اسلایدر تصویر لبه‌تا‌لبهٔ بالای کارت پاپ‌آپ (سبک Zillow): فلش‌های چپ/راست،
* شمارندهٔ اسلاید و گوشه‌های گرد هم‌اندازهٔ کارت. همهٔ رویدادهای اشاره روی
* اسلایدر متوقف می‌شوند تا کلیک/کشیدن آن، نقشه را جابه‌جا یا پاپ‌آپ را نبندد.
*/
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
const { t } = useLang()
const [index, setIndex] = useState(0)
/** هیچ رویدادی از اسلایدر به نقشهٔ زیرین نمی‌رسد */
const stop = (event: MouseEvent | PointerEvent) => event.stopPropagation()
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
if (!images.length) {
return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<ProjectImagePlaceholder />
</div>
)
}
const current = Math.min(index, images.length - 1)
const step = (delta: number) => setIndex((value) => (value + delta + images.length) % images.length)
const arrow =
'absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/85 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char'
return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
{images.length > 1 && (
<>
<button
type="button"
aria-label={t('prevImage')}
title={t('prevImage')}
onClick={(event) => {
event.stopPropagation()
step(-1)
}}
className={`${arrow} left-2`}
>
<MdChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label={t('nextImage')}
title={t('nextImage')}
onClick={(event) => {
event.stopPropagation()
step(1)
}}
className={`${arrow} right-2`}
>
<MdChevronRight className="h-5 w-5" />
</button>
<span className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-ink/75 px-2.5 py-0.5 text-[10px] font-light text-ivory/85 backdrop-blur-md">
{toPersianDigits(current + 1)} / {toPersianDigits(images.length)}
</span>
</>
)}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { MdLocationOn } from 'react-icons/md'
type ProjectPinProps = {
label: string
/** پروژه‌ی انتخاب‌شده — هاله‌ی مسی پایدار */
active?: boolean
onClick: () => void
}
/**
* سنجاق پروژه روی نقشه — آیکون MdLocationOn در رنگ مسی برند با هاله‌ی
* نورانی در هاور، کلیک و حالت انتخاب‌شده. این کامپوننت داخل المان نشانگر
* مپ‌لیبر رندر می‌شود، پس خودش هیچ چیدمان بیرونی ندارد.
*/
export function ProjectPin({ label, active = false, onClick }: ProjectPinProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
className="group flex cursor-pointer items-end justify-center bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<MdLocationOn
className={`h-9 w-9 text-[#d5874c] transition-all duration-300 ease-lux group-hover:scale-125 group-hover:drop-shadow-[0_0_14px_rgba(213,135,76,0.95)] group-active:scale-110 group-active:drop-shadow-[0_0_18px_rgba(213,135,76,1)] ${
active
? 'scale-110 drop-shadow-[0_0_16px_rgba(213,135,76,0.95)]'
: 'drop-shadow-[0_3px_6px_rgba(0,0,0,0.65)]'
}`}
/>
</button>
)
}
+66
View File
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { MdClose } from 'react-icons/md'
import type { MapProject } from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { ProjectImageSlider } from './ProjectImageSlider'
/** دکمه‌های کارت — تمام‌عرض، هم‌خانوادهٔ دکمه‌های مسی سایت (در ستون هم درست می‌نشینند) */
export const POPUP_PRIMARY_CLASS =
'flex w-full items-center justify-center gap-1.5 rounded-xl bg-copper px-3 py-2.5 text-[12px] font-bold text-char transition-all duration-300 ease-lux hover:scale-[1.03] hover:shadow-[0_8px_22px_rgba(213,135,76,0.35)] active:scale-95'
export const POPUP_SECONDARY_CLASS =
'flex w-full items-center justify-center gap-1.5 rounded-xl border border-ivory/20 px-3 py-2.5 text-[12px] font-light text-ivory/80 transition-colors duration-300 ease-lux hover:border-copper hover:text-copper'
type ProjectPopupCardProps = {
project: MapProject
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جای‌نگهدار می‌آید */
images?: string[]
onClose: () => void
/** دکمه‌های پایهٔ کارت — هر نما اکشن‌های خودش را می‌دهد (عمودی روی هم) */
children: ReactNode
}
/**
* کارت پاپ‌آپ پروژه به سبک Zillow — اسلایدر لبه‌تا‌لبه در بالاترین بخش، سپس
* عنوان و توضیح، و در پایه دکمه‌های عمودی. راست‌به‌چپ، شیشه‌ای تیره و
* هم‌اندازهٔ کارت‌های سایت. سال پروژه عمداً نمایش داده نمی‌شود.
*/
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
const { t } = useLang()
const description = project.description.trim() || t('mapProjectDescription')
return (
<article
dir="rtl"
className="w-[17rem] max-w-[calc(100vw-2.5rem)] animate-[luxPopupIn_0.22s_ease-out] overflow-hidden rounded-2xl border border-ivory/10 bg-[#27221c]/95 font-pinar text-ivory shadow-[0_24px_60px_-15px_rgba(0,0,0,0.9)] backdrop-blur-xl sm:w-72"
>
{/* اسلایدر تصویر: لبه‌تا‌لبه، بدون حاشیه، با فلش‌های چپ/راست */}
<div className="relative">
<ProjectImageSlider images={images} alt={project.name} />
<button
type="button"
onClick={onClose}
aria-label={t('close')}
title={t('close')}
className="absolute left-2.5 top-2.5 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char"
>
<MdClose className="h-4 w-4" />
</button>
{project.isNew && (
<span className="absolute right-2.5 top-2.5 z-20 rounded-full bg-copper px-2.5 py-1 text-[10px] font-bold text-char shadow-[0_4px_14px_rgba(213,135,76,0.4)]">
{t('mapNewBadge')}
</span>
)}
</div>
<div className="px-4 pb-1 pt-3.5">
<h3 className="min-w-0 truncate text-base font-bold text-ivory">{project.name}</h3>
<p className="mt-1.5 line-clamp-2 text-[11px] font-light leading-6 text-ivory/60">{description}</p>
</div>
<div className="flex flex-col gap-2 p-3 pt-2.5">{children}</div>
</article>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { createPortal } from 'react-dom'
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
import {
ISFAHAN_CENTER,
MAP_PROJECTS,
boundsForProjects,
buildNeshanUrl,
type MapProject,
} from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { MapViewport } from './MapViewport'
import { POPUP_PRIMARY_CLASS, POPUP_SECONDARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
import { useMapLibre } from './useMapLibre'
import { useProjectMarkers } from './useProjectMarkers'
export type ProjectsMapAllProps = {
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string
/** فهرست پروژه‌ها — پیش‌فرض همه‌ی ردیف‌های شیت */
projects?: MapProject[]
/**
* نشانی صفحه‌ی جزئیات هر پروژه. اگر تطبیقی پیدا نشود، دکمه‌ی «مشاهده پروژه»
* به فهرست پروژه‌ها می‌رود تا هیچ لینکی بی‌مقصد نماند.
*/
resolveProjectHref?: (project: MapProject) => string | undefined
/** آرایهٔ تصاویر هر پروژه (همان آرایهٔ صفحهٔ جزئیات) — در نبودش جای‌نگهدار */
imagesFor?: (project: MapProject) => string[]
}
/**
* نمای «همه‌ی پروژه‌ها» — همه‌ی مختصات شیت روی یک نقشه‌ی تمام‌عرض با
* سنجاق‌های مسی، پاپ‌آپ کارتی و دو اکشن عمودی: صفحه‌ی پروژه و آدرس نشان.
* پاپ‌آپ فقط با کلیک کاربر باز می‌شود و جابه‌جایی نقشه به محدودهٔ پروژه‌ها
* بسته است.
*/
export function ProjectsMapAll({
className = '',
projects = MAP_PROJECTS,
resolveProjectHref,
imagesFor,
}: ProjectsMapAllProps) {
const { t, href } = useLang()
const bounds = useMemo(() => boundsForProjects(projects), [projects])
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
return (
<MapViewport
containerRef={containerRef}
map={map}
status={status}
onRetry={retry}
label={t('mapAria')}
className={className}
>
{popupHost &&
activeProject &&
createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
{t('viewProject')}
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
</Link>
{/* منطق دکمهٔ آدرس دست‌نخورده — فقط در ستون عمودی نشسته است */}
<a
href={buildNeshanUrl(activeProject)}
target="_blank"
rel="noopener noreferrer"
className={POPUP_SECONDARY_CLASS}
>
{t('viewAddress')}
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
</a>
</ProjectPopupCard>,
popupHost,
)}
</MapViewport>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { createPortal } from 'react-dom'
import { MdOpenInNew } from 'react-icons/md'
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { MapViewport } from './MapViewport'
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
import { useMapLibre } from './useMapLibre'
import { useProjectMarkers } from './useProjectMarkers'
export type ProjectsMapSingleProps = {
/** همان پروژه‌ی شیت — یا محل جایگزین ساخته‌شده با createPlaceholderProject */
location: MapProject
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات (تصویر شاخص + گالری) */
images?: string[]
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-80» */
className?: string
}
/**
* نمای «تک‌پروژه» — یک سنجاق دقیق روی مختصات همان پروژه و پاپ‌آپی که فقط با
* کلیک کاربر باز می‌شود. تنها اکشن کارت، مشاهدهٔ آدرس پروژه در نشان است.
* جابه‌جایی نقشه به محدودهٔ همین پروژه بسته شده تا به بیرون نرود.
*/
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
const { t } = useLang()
const projects = useMemo(() => [location], [location])
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
const { containerRef, map, status, retry } = useMapLibre({
center: [location.lng, location.lat],
zoom: 15,
maxBounds: bounds,
})
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
return (
<MapViewport
containerRef={containerRef}
map={map}
status={status}
onRetry={retry}
label={t('mapAria')}
className={className}
>
{popupHost &&
activeProject &&
createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
{/* منطق دکمهٔ آدرس دست‌نخورده است — تنها اکشن این کارت */}
<a
href={buildNeshanUrl(activeProject)}
target="_blank"
rel="noopener noreferrer"
className={POPUP_PRIMARY_CLASS}
>
{t('viewAddress')}
<MdOpenInNew className="h-4 w-4" aria-hidden="true" />
</a>
</ProjectPopupCard>,
popupHost,
)}
</MapViewport>
)
}
+35
View File
@@ -0,0 +1,35 @@
import * as maplibregl from 'maplibre-gl'
import { Protocol } from 'pmtiles'
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
let setup: Promise<void> | null = null
/**
* آماده‌سازی وب‌ورکر نقشه — یک‌بار برای همه نقشه‌ها.
*
* ۱) نشانی وب‌ورکر مپ‌لیبر صریح تعیین می‌شود: مپ‌لیبر به‌صورت پیش‌فرض
* «./maplibre-gl-worker.mjs» را کنار باندل جست‌وجو می‌کند که در خروجی ویت
* وجود ندارد و ورکر ۴۰۴ می‌شود؛ نسخه‌ی ساخته‌شده‌ی ویت را با setWorkerUrl
* می‌دهیم (نشانی مطلق، چون پایه‌ی ورکر می‌تواند Blob باشد).
* ۲) پروتکل pmtiles در نخ اصلی ثبت می‌شود.
* ۳) همان پروتکل با importScriptInWorkers داخل وب‌ورکر هم ثبت می‌شود —
* کاشی‌های برداری آنجا بارگیری می‌شوند و رجیستری پروتکل در هر کانتکست
* جداست. اسکریپت وب‌ورکر، خروجی خودبسنده‌ی public/map/pmtiles-worker.mjs
* است (ساخته‌شده از src/map/pmtiles.worker.ts با npm run build:map-worker).
*
* تا پایان بارگیری اسکریپت صبر می‌کنیم تا نخستین درخواست‌های کاشی در وب‌ورکر
* بی‌پاسخ نمانند.
*/
export function ensureMapWorker(): Promise<void> {
if (!setup) {
maplibregl.setWorkerUrl(new URL(maplibreWorkerUrl, window.location.href).href)
maplibregl.addProtocol('pmtiles', new Protocol().tile)
const protocolScript = new URL(
`${import.meta.env.BASE_URL}map/pmtiles-worker.mjs`,
window.location.href,
).href
setup = maplibregl.importScriptInWorkers(protocolScript)
}
return setup
}
+15
View File
@@ -0,0 +1,15 @@
import { Protocol } from 'pmtiles'
/**
* اسکریپت سمت وبورکر مپ‌لیبر — با importScriptInWorkers داخل وبورکر اجرا
* می‌شود و مپ‌لیبر تابع addProtocol را روی self وبورکر گذاشته است.
*
* کاشی‌های برداری در وبورکر بارگیری می‌شوند و رجیستری پروتکل در هر کانتکست
* جداست؛ بدون این ثبت، درخواست‌های pmtiles در وبورکر بی‌پاسخ می‌مانند و
* نقشه فقط پس‌زمینه را نشان می‌دهد.
*/
const workerScope = self as unknown as {
addProtocol: (name: string, loadFn: unknown) => void
}
workerScope.addProtocol('pmtiles', new Protocol().tile)
+141
View File
@@ -0,0 +1,141 @@
import type { SourceSpecification, StyleSpecification } from 'maplibre-gl'
import { MAP_BASE_URL } from '../config'
const PMTILES_SCHEME = 'pmtiles://'
/** نشانی‌های مطلق — دست‌نخورده می‌مانند */
const EXTERNAL = /^(https?:|data:|blob:)/i
/** تنها آرشیو برداری باکت — منبع برداری سبک همیشه به همین جا می‌رسد */
const PMTILES_ARCHIVE = 'iran.pmtiles'
/** نام منبع برداری در سبک‌های استاندارد OpenMapTiles */
const OPENMAPTILES_KEY = 'openmaptiles'
/** پوشه فونت‌های گلیف در باکت MinIO — سبک خام فقط «{fontstack}/{range}.pbf» دارد */
const GLYPHS_FOLDER = 'v2.0'
function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
}
/** نشانی آرشیو برداری روی MinIO — تنها منبع کاشی این پروژه */
function archiveUrl(base: string): string {
return `${PMTILES_SCHEME}${joinUrl(base, PMTILES_ARCHIVE)}`
}
/**
* جای‌گذاری جانگهدارهای سبک — «{styleJsonFolder}» به آدرس پایه MinIO تبدیل
* می‌شود. جانگهدارهای {fontstack} و {range} قانونی خود مپ‌لیبرند و دست‌نخورده
* می‌مانند. جانگهدار تازه‌ای در سبک‌های بعدی دیده شد، به همین نگاشت اضافه شود.
*/
function resolvePlaceholders(url: string, base: string): string {
return url.replace(/\{styleJsonFolder\}/g, base)
}
/** مطلق‌سازی نشانی‌های عمومی (گلیف، اسپرایت، منابع غیربرداری) */
function absolutize(resource: string, base: string): string {
const url = resolvePlaceholders(resource.trim(), base)
if (EXTERNAL.test(url)) return url
if (url.startsWith(PMTILES_SCHEME)) {
const target = url.slice(PMTILES_SCHEME.length).trim()
return EXTERNAL.test(target) ? url : PMTILES_SCHEME + joinUrl(base, target)
}
return joinUrl(base, url)
}
/**
* نشانی گلیف‌ها — سبک خام فقط الگوی «{fontstack}/{range}.pbf» را دارد و پوشه
* فونت‌ها را نمی‌آورد، اما فونت‌های باکت زیر پوشه v2.0 هستند؛ پس الگو همیشه
* به همان پوشه می‌رسد و جانگهدارهای {fontstack} و {range} دست‌نخورده می‌مانند.
*/
function resolveGlyphsUrl(url: string, base: string): string {
const resolved = resolvePlaceholders(url.trim(), base)
if (EXTERNAL.test(resolved)) return resolved
if (resolved.includes('{fontstack}')) {
const template = resolved.slice(resolved.indexOf('{fontstack}'))
return joinUrl(base, joinUrl(GLYPHS_FOLDER, template))
}
return joinUrl(base, resolved)
}
/** منبع برداری سبک — با نوع vector یا نام شناخته‌شده openmaptiles */
function isVectorSource(key: string, source: SourceSpecification): boolean {
return source.type === 'vector' || key === OPENMAPTILES_KEY
}
/**
* بازنویسی نشانی منبع برداری. سبک‌های ساخته‌شده برای سرور MBTiles (مثل
* «mbtiles://{v3}» در tileserver-gl) کاشی برداری را با طرح mbtiles آدرس
* می‌دهند که مپ‌لیبر نمی‌شناسد؛ هر شکلی از نشانی اینجا به آرشیو iran.pmtiles
* روی MinIO هدایت می‌شود تا با پروتکل pmtiles رندر شود.
*/
function resolveVectorUrl(url: string, base: string): string {
const resolved = resolvePlaceholders(url.trim(), base)
if (resolved.startsWith(PMTILES_SCHEME)) {
const target = resolved.slice(PMTILES_SCHEME.length).trim()
return EXTERNAL.test(target) ? resolved : PMTILES_SCHEME + joinUrl(base, target)
}
return archiveUrl(base)
}
/**
* پالایش سبک نقشه پیش از تحویل به مپ‌لیبر — style.json خام MinIO با
* جانگهدار ({styleJsonFolder}) و نشانی MBTiles نوشته شده است؛ این تابع با یک
* پاس، گلیف/اسپرایت را به آدرس مطلق MinIO و منبع برداری را به آرشیو
* iran.pmtiles تبدیل می‌کند تا مستقیم به «new Map({ style })» داده شود.
*/
export function rewriteStyleUrls(
style: StyleSpecification,
base: string = MAP_BASE_URL,
): StyleSpecification {
const next: StyleSpecification = { ...style }
if (typeof next.glyphs === 'string') {
next.glyphs = resolveGlyphsUrl(next.glyphs, base)
}
// اسپرایت در نسخه ۸ مشخصات سبک، رشته یا فهرست {id, url} است
if (typeof next.sprite === 'string') {
next.sprite = absolutize(next.sprite, base)
} else if (Array.isArray(next.sprite)) {
next.sprite = next.sprite.map((sprite) => ({ ...sprite, url: absolutize(sprite.url, base) }))
}
const sources: StyleSpecification['sources'] = {}
for (const [key, source] of Object.entries(next.sources ?? {})) {
const rewritten = { ...source } as SourceSpecification & { url?: unknown; tiles?: unknown }
if (typeof rewritten.url === 'string') {
const raw = rewritten.url.trim()
if (isVectorSource(key, source)) {
rewritten.url = resolveVectorUrl(raw, base)
} else {
// رندر سخت‌گیرانه‌ی برداری: منابع غیربرداری (مثل raster) هرگز به آرشیو
// برداری هدایت نمی‌شوند — کاشی MVT زیر منبع raster یعنی خروجی خراب.
if (import.meta.env.DEV && source.type === 'raster') {
console.warn(`[map] منبع غیربرداری «${key}» در سبک هست؛ افزایش رزولوشن برداری برای آن اعمال نمی‌شود`)
}
rewritten.url = absolutize(raw, base)
}
}
if (Array.isArray(rewritten.tiles)) {
rewritten.tiles = rewritten.tiles.map((tile) =>
typeof tile === 'string' ? absolutize(tile, base) : tile,
)
}
sources[key] = rewritten
}
next.sources = sources
return next
}
/** بارگیری style.json از MinIO و بازگرداندن سبک پالایش‌شده برای «new Map({ style })» */
export async function loadMapStyle(signal?: AbortSignal): Promise<StyleSpecification> {
const res = await fetch(joinUrl(MAP_BASE_URL, 'style.json'), { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return rewriteStyleUrls((await res.json()) as StyleSpecification)
}
+41
View File
@@ -0,0 +1,41 @@
import type { RequestTransformFunction } from 'maplibre-gl'
/** دو بخش آخر مسیر گلیف: پوشه‌ی پشته‌ی فونت و فایل بازه */
const GLYPH_PATH = /\/([^/?#]+)\/([^/?#]+\.pbf)$/
/**
* نشانی گلیف با تنها فونت نخستِ پشته.
*
* مپ‌لیبر برای هر لایه، تمام `text-font` را با کاما در {fontstack} می‌گذارد
* (مثل «Metropolis Regular,Noto Sans Regular»)؛ سرورهای عمومی فونت، پوشه‌ی
* همین پشته‌ی ترکیبی را دارند اما MinIO این پروژه فونت‌ها را در پوشه‌های
* تک‌فونتی نگه می‌دارد. نتیجه‌ی آن درخواست‌ها ۴۰۴ بود، متن‌ها رندر نمی‌شدند و
* سیل خطا اپ را قفل می‌کرد. با این بازنویسی فقط فونت نخست — همان ترجیح اول
* سبک — درخواست می‌شود و بقیه‌ی مسیر (پوشه‌ی v2.0 و فایل بازه) دست‌نخورده
* می‌ماند. نشانی بدون پشته‌ی ترکیبی یا ناشناس، بدون تغییر برمی‌گردد.
*/
export function singleFontGlyphUrl(url: string): string {
const queryIndex = url.search(/[?#]/)
const path = queryIndex === -1 ? url : url.slice(0, queryIndex)
const suffix = queryIndex === -1 ? '' : url.slice(queryIndex)
const match = path.match(GLYPH_PATH)
if (!match) return url
let fonts: string[]
try {
fonts = match[1].split(',').map((font) => decodeURIComponent(font).trim())
} catch {
return url
}
if (fonts.length < 2 || !fonts[0]) return url
return `${path.slice(0, match.index)}/${encodeURIComponent(fonts[0])}/${match[2]}${suffix}`
}
/**
* قلاب تبدیل درخواست‌های نقشه — فقط گلیف‌ها بازنویسی می‌شوند و هر درخواست
* دیگری دست‌نخورده می‌ماند تا رفتار پیش‌فرض مپ‌لیبر حفظ شود.
*/
export const mapTransformRequest: RequestTransformFunction = (url, resourceType) =>
resourceType === 'Glyphs' ? { url: singleFontGlyphUrl(url) } : { url }
+2
View File
@@ -0,0 +1,2 @@
/** وضعیت چرخه‌ی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
export type MapStatus = 'loading' | 'ready' | 'error'
+192
View File
@@ -0,0 +1,192 @@
import * as maplibregl from 'maplibre-gl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ensureMapWorker } from './mapWorker'
import { loadMapStyle } from './styleLoader'
import { mapTransformRequest } from './transformRequest'
import type { MapStatus } from './types'
type UseMapLibreOptions = {
center: [number, number]
zoom: number
/** بزرگ‌نمایی با چرخ ماوس و پینچ لمسی — پیش‌فرض فعال */
scrollZoom?: boolean
/** محدودهٔ مجاز جابه‌جایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژه‌ها */
maxBounds?: maplibregl.LngLatBoundsLike
}
/** سقف انتظار سبک نقشه — سرور کاشی بی‌پاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بی‌پایان */
const STYLE_TIMEOUT_MS = 10_000
/** اگر رویداد style.load دیر برسد، وضعیت آماده با این مهلت نمایش داده می‌شود */
const READY_FALLBACK_MS = 2_000
/**
* سقف بزرگ‌نمایی = maxzoom آرشیو + همین مقدار سطح overzoom.
*
* سنجش روی تایل‌های واقعی z14 اصفهان (۲۶٫۸۷۴ پاره‌خط جاده): میانه‌ی وتر ۲۰
* متر و صدک ۷۵ برابر ۳۶ متر است. با سه سطح overzoom (زوم ۱۷، ۰٫۵ متر بر
* پیکسل) میانه ۴۰ و صدک ۷۵ حدود ۷۲ پیکسل می‌شود که خم‌ها را هنوز صاف نشان
* می‌دهد؛ از ۱۷٫۵ به بعد وترها به ۱۰۰ پیکسل و بیشتر می‌رسند و چندضلعی‌شدن
* خم‌ها دیده می‌شود. متن و آیکون هرگز تار نمی‌شوند (متن SDF و آیکون اسپرایت
* است)؛ افت کیفیت فقط از هندسه‌ی ساده‌شده می‌آید. با بازتولید آرشیو در z16
* این سقف خودکار به ۱۹ می‌رسد.
*/
const OVERZOOM_LEVELS = 3
/** سقف مطلق زوم — بالاتر از آن حتی با داده‌ی دقیق‌تر هم نمایش مرورگر بی‌معناست */
const ABSOLUTE_MAX_ZOOM = 22
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
const FALLBACK_MAX_ZOOM = 17
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<maplibregl.Map | null>(null)
const [status, setStatus] = useState<MapStatus>('loading')
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
const container = containerRef.current
if (!container) return
let disposed = false
let instance: maplibregl.Map | null = null
let readyFallback: number | undefined
let dprQuery: MediaQueryList | null = null
let handleDprChange: (() => void) | null = null
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
setStatus('loading')
setMap(null)
// وب‌ورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
// وگرنه نخستین درخواست‌های کاشی در وب‌ورکر بی‌پاسخ می‌مانند
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
.then(([style]) => {
if (disposed) return
instance = new maplibregl.Map({
container,
style,
center,
zoom,
maxBounds,
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق می‌کند
maxZoom: FALLBACK_MAX_ZOOM,
renderWorldCopies: false,
// لبه‌های نرم‌تر خطوط و چندضلعی‌ها — حس لوکس رندر برداری
canvasContextAttributes: { antialias: true },
// کیفیت حداکثری در بزرگ‌نمایی‌های عمیق: هیچ سطحی با مقیاس‌کردن سطح
// بالاتر رندر نمی‌شود و همه‌ی سطح‌ها تا maxZoom از تقسیم هندسه
// می‌آیند (پیش‌فرض ۴ سطح آخر را overscale می‌کند). مبادله: کار
// بیشتر پردازنده در زوم‌های عمیق.
zoomLevelsToOverscale: 0,
// چرخ ماوس، پینچ لمسی و دکمه‌های کنترل — مطابق درخواست کاربر
scrollZoom,
attributionControl: { compact: true },
// MinIO فونت‌ها را در پوشه‌های تک‌فونتی دارد، پس پشته‌ی ترکیبی
// text-font («Metropolis Regular,Noto Sans Regular») به فونت نخست
// بازنویسی می‌شود — توضیح کامل در transformRequest.ts
transformRequest: mapTransformRequest,
})
/**
* سقف بزرگ‌نمایی را از maxzoom واقعی آرشیو می‌سازد؛ با داده‌ی دقیق‌تر
* (بازتولید آرشیو در z بالاتر) خودکار بالاتر می‌رود و کیفیت روی صفحه
* ثابت می‌ماند. زوم جاری هم اگر از سقف بیشتر باشد به آن محدود می‌شود.
*/
const applyZoomLimit = () => {
if (!instance) return
const source = instance.getSource('openmaptiles') as { maxzoom?: number } | undefined
const sourceMaxZoom = typeof source?.maxzoom === 'number' ? source.maxzoom : null
instance.setMaxZoom(
sourceMaxZoom === null
? FALLBACK_MAX_ZOOM
: Math.min(sourceMaxZoom + OVERZOOM_LEVELS, ABSOLUTE_MAX_ZOOM),
)
}
instance.on('style.load', () => {
if (disposed) return
setStatus('ready')
window.setTimeout(() => instance?.resize(), 150)
})
/**
* کنترل بزرگ‌نمایی همراه خود نقشه ساخته می‌شود و با map.remove() پایین
* می‌آید — map.remove به‌تنهایی onRemove همه‌ی کنترل‌ها را صدا می‌زند،
* پس removeControl دستی پس از آن، onRemove را دوباره اجرا می‌کند و با
* «reading 'off' of undefined» کرش می‌کند. مالکیت کنترل همین‌جا، کنار
* مالکیت نقشه می‌ماند تا ترتیب پاک‌سازی هیچ‌وقت به React وابسته نباشد.
*/
instance.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right')
// maxzoom منبع همراه متادیتای TileJSON می‌رسد — همان لحظه سقف را قطعی کن
instance.on('sourcedata', (e) => {
if (e.sourceDataType === 'metadata' && e.sourceId === 'openmaptiles') applyZoomLimit()
})
instance.on('load', applyZoomLimit)
applyZoomLimit()
// تایمر فالبک امنیتی
readyFallback = window.setTimeout(() => {
if (disposed) return
setStatus('ready')
instance?.resize()
}, READY_FALLBACK_MS)
instance.on('error', (e) => {
console.warn('MapLibre minor error:', e)
})
/**
* نسبت پیکسل دستگاه منبع حقیقت بافر بوم است؛ با زوم مرورگر
* (Ctrl+zoom) یا جابه‌جایی پنجره روی مانیتور با چگالی دیگر عوض
* می‌شود. اگر بافر با نسبت تازه هم‌گام نشود، نقشه تا رفرش بعدی تار
* می‌ماند. matchMedia هر تغییر را دقیقاً همان لحظه گزارش می‌دهد و
* پس از هر تغییر دوباره مسلح می‌شود.
*/
function syncPixelRatio() {
if (!instance) return
if (instance.getPixelRatio() !== window.devicePixelRatio) {
instance.setPixelRatio(window.devicePixelRatio)
}
instance.resize()
}
function watchPixelRatio() {
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
handleDprChange = () => {
syncPixelRatio()
watchPixelRatio()
}
dprQuery.addEventListener('change', handleDprChange)
}
watchPixelRatio()
// دسترسی اشکال‌زدایی در محیط توسعه — در بیلد تولیدی وجود ندارد
if (import.meta.env.DEV) {
(window as unknown as { __map?: maplibregl.Map }).__map = instance
}
setMap(instance)
})
.catch(() => {
if (!disposed) setStatus('error')
})
return () => {
disposed = true
window.clearTimeout(timeoutId)
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
controller.abort()
instance?.remove()
}
// مرکز و بزرگ‌نمایی فقط موقعیت اولیه نقشه‌اند؛ فهرست وابستگی عمداً فقط reloadKey است
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey])
const retry = useCallback(() => setReloadKey((key) => key + 1), [])
return { containerRef, map, status, retry }
}
+156
View File
@@ -0,0 +1,156 @@
import * as maplibregl from 'maplibre-gl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { MapProject } from '../data/mapProjects'
import { ProjectPin } from './ProjectPin'
type PinHandle = { project: MapProject; marker: maplibregl.Marker; root: Root }
/** حاشیهٔ امن پاپ‌آپ از لبه‌های نقشه — بالای صفحه بازتر، برای نوار برچسب و کنترل‌ها */
const AUTO_PAN_PADDING = { top: 150, bottom: 20, left: 20, right: 20 }
/**
* نگه‌داشتن پاپ‌آپ داخل کادر دید — معادل رفتار autoPan که مپ‌لیبر (برخلاف
* مپ‌باکس) گزینه‌اش را ندارد: اگر کارت از حاشیهٔ امن بیرون بزند، نقشه نرم
* جابه‌جا می‌شود تا کارت کامل دیده شود.
*/
function ensurePopupVisible(instance: maplibregl.Map, popup: maplibregl.Popup): void {
const element = popup.getElement()
const container = instance.getContainer()
if (!element || !container) return
// چیدمان پاپ‌آپ در همان فریم addTo کامل می‌شود، پس اندازه‌گیری به فریم بعد موکول است
window.requestAnimationFrame(() => {
const popupRect = element.getBoundingClientRect()
const viewRect = container.getBoundingClientRect()
const excessTop = viewRect.top + AUTO_PAN_PADDING.top - popupRect.top
const excessBottom = popupRect.bottom - (viewRect.bottom - AUTO_PAN_PADDING.bottom)
const excessLeft = viewRect.left + AUTO_PAN_PADDING.left - popupRect.left
const excessRight = popupRect.right - (viewRect.right - AUTO_PAN_PADDING.right)
// کمبود افقی و عمودی؛ دوربین به اندازهٔ بیرون‌زدگی جابه‌جا می‌شود
const dx = Math.max(0, excessLeft) - Math.max(0, excessRight)
const dy = Math.max(0, excessTop) - Math.max(0, excessBottom)
if (!dx && !dy) return
instance.panBy([-dx, -dy], { duration: 320 })
})
}
/**
* لایه‌ی نشانگرهای پروژه — ساخت نشانگرها با React (هر نشانگر یک ریشه‌ی
* کوچک)، یک پاپ‌آپ مشترک برای همه، و وضعیت پروژه‌ی فعال.
*
* پاپ‌آپ هیچ‌وقت خودکار باز نمی‌شود؛ فقط با کلیک کاربر روی سنجاق. پاپ‌آپ یک
* المان میزبان دارد که بیرون از React ساخته می‌شود؛ مصرف‌کننده محتوای React را
* با createPortal داخل همان میزبان می‌ریزد تا Link روتر، حالت هاور و
* انیمیشن‌ها همه در درخت React بمانند.
*/
export function useProjectMarkers(map: maplibregl.Map | null, projects: MapProject[]) {
const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null)
const [activeProject, setActiveProject] = useState<MapProject | null>(null)
const popupRef = useRef<maplibregl.Popup | null>(null)
const pinsRef = useRef<PinHandle[]>([])
const activeRef = useRef<MapProject | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
mapRef.current = map
const openProject = useCallback((project: MapProject) => {
const popup = popupRef.current
const instance = mapRef.current
if (!popup || !instance) return
activeRef.current = project
popup.setLngLat([project.lng, project.lat]).addTo(instance)
ensurePopupVisible(instance, popup)
setActiveProject(project)
}, [])
const closePopup = useCallback(() => {
activeRef.current = null
const popup = popupRef.current
// remove() فقط روی پاپ‌آپ باز صدا زده می‌شود؛ پاپ‌آپ بسته مالک مرورگر است
if (popup?.isOpen()) popup.remove()
setActiveProject(null)
}, [])
/** پاپ‌آپ مشترک + المان میزبان محتوا — یک‌بار برای هر نمونه نقشه */
useEffect(() => {
if (!map) return
const host = document.createElement('div')
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: true,
offset: 46,
maxWidth: 'none',
className: 'lux-map-popup',
}).setDOMContent(host)
// بستن با کلیک روی نقشه یا دکمه‌ی کارت، وضعیت فعال را هم پاک می‌کند
popup.on('close', () => {
activeRef.current = null
setActiveProject(null)
})
popupRef.current = popup
setPopupHost(host)
return () => {
// فقط پاپ‌آپِ باز پایین آورده می‌شود؛ popup.remove خودش هم به رویداد
// remove نقشه گوش می‌دهد، پس این پاک‌سازی تکراری و بی‌خطر است
if (popup.isOpen()) popup.remove()
popupRef.current = null
setPopupHost(null)
}
}, [map])
/** نشانگرها — با تغییر فهرست پروژه‌ها یا نمونه‌ی نقشه بازساخته می‌شوند */
useEffect(() => {
if (!map) return
const pins: PinHandle[] = []
for (const project of projects) {
const element = document.createElement('div')
const marker = new maplibregl.Marker({ element, anchor: 'bottom' })
.setLngLat([project.lng, project.lat])
.addTo(map)
const root = createRoot(element)
root.render(<ProjectPin label={project.name} active={false} onClick={() => openProject(project)} />)
pins.push({ project, marker, root })
}
pinsRef.current = pins
return () => {
for (const pin of pins) {
// نشانگر خودش پاک‌سازی امن دارد (حذف شنونده‌ها + برداشتن المان)
pin.marker.remove()
/**
* unmount ریشه‌ی React هنگام جدا شدن والد، وسط چرخه‌ی رندر رخ می‌دهد و
* React 18 برای unmount همگام ریشه‌ی تودرتو هشدار می‌دهد
* («Attempted to synchronously unmount a root while React was already
* rendering»). پس به میکروتسک بعد از پایان کامیت موکول می‌شود؛ المان
* نشانگر تا آن لحظه از DOM جدا شده و unmount روی ظرف جدا امن است.
*/
queueMicrotask(() => pin.root.unmount())
}
pinsRef.current = []
}
}, [map, projects, openProject])
/** با تغییر پروژه‌ی فعال، فقط هاله‌ی سنجاق‌ها به‌روز می‌شود (بازسازی نمی‌شوند) */
useEffect(() => {
for (const pin of pinsRef.current) {
pin.root.render(
<ProjectPin
label={pin.project.name}
active={activeProject?.id === pin.project.id}
onClick={() => openProject(pin.project)}
/>,
)
}
}, [activeProject, openProject])
return { popupHost, activeProject, openProject, closePopup }
}
+43 -16
View File
@@ -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>
+2 -25
View File
@@ -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 />
</> </>
+1 -1
View File
@@ -19,8 +19,8 @@ export function Home() {
return ( return (
<> <>
<Hero /> <Hero />
<Story />
<Stats /> <Stats />
<Story />
<Services /> <Services />
<BeforeAfter /> <BeforeAfter />
<FeaturedProjects /> <FeaturedProjects />
+145 -109
View File
@@ -1,74 +1,98 @@
import React, { useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { 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}
+93 -20
View File
@@ -1,18 +1,20 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { PageHero } from '../components/PageHero'; import { 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 />
</> </>
); );
} }
+4 -3
View File
@@ -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
} }
/** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */ /** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */
+45
View File
@@ -0,0 +1,45 @@
const fs = require('fs');
const file = fs.readFileSync('src/data/projectsData.ts', 'utf8');
const lines = file.split('\n');
const newLines = [];
let insideArray = false;
for (const line of lines) {
if (line.includes('export const projectsData')) {
insideArray = true;
newLines.push(line);
continue;
}
if (insideArray && line.includes('{ id:')) {
const match = line.match(/{ id: '(\d+)', name: '(.*?)', status: '(.*?)' }/);
if (match) {
const id = match[1];
const nameFa = match[2];
const status = match[3];
const nameEn = 'Project ' + id;
const slug = 'project-' + id;
newLines.push(` { id: '${id}', slug: '${slug}', name: { en: '${nameEn}', fa: '${nameFa}' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: '${status}' },`);
} else {
newLines.push(line);
}
} else {
if (line.includes('export type StaticProject') || line.includes('id: string;') || line.includes('name: string;') || line.includes('status:') || line === '};') {
continue;
}
newLines.push(line);
}
}
const header = `export type BilingualText = { en: string; fa: string };
export type StaticProject = {
id: string;
slug: string;
name: BilingualText;
location: BilingualText;
area: BilingualText;
status: 'done' | 'progress';
};
`;
fs.writeFileSync('src/data/projectsData.ts', header + newLines.join('\n'));
+6
View File
@@ -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, // جلوگیری از تغییر خودکار پورت
+125
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1009 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

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