Author SHA1 Message Date
fatemeh sane 853ad67db5 feat(ui): update UI v1 2026-09-25 22:49:30 +03:30
MMD 805f4594d4 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/pages/ArticleDetail.tsx
2026-09-17 12:31:04 +03:30
fatemeh sane 7a0defc3d4 fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography 2026-09-17 10:10:50 +03:30
MMD fe6957bf83 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
2026-09-15 14:41:46 +03:30
fatemeh sane 049291c109 feat(admin): enhance editor UI (links, image captions) & fix categories
Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
2026-09-15 11:33:04 +03:30
MMD 53673d0f9b Merge branch 'bilingual-en'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
#	frontend/src/pages/ArticleDetail.tsx
2026-09-13 18:45:06 +03:30
MMD 9d55f521a0 Merge remote-tracking branch 'origin/dashboard/blog-changes' 2026-09-13 18:29:51 +03:30
MMD 35c03ef35e اصلاح عنوان تب صفحات جزئیات — brand به‌جای brand.name 2026-09-13 18:29:34 +03:30
MMD 4fb57dfd67 اصلاحات بازبینی: تطبیق backfill با متن فارسی، رفع باگ متای /blog (ستون image ناموجود)، پاک‌سازی og:locale و برند در پوسته سئو، محدودکردن Traefik به پروژه خود، اصلاح مسیریابی Routes و عنوان برند در صفحات جزئیات 2026-09-13 00:10:53 +03:30
MMD 6b49d035c2 فرانت‌اند: نسخه انگلیسی با مسیر /en/ — مسیریابی دوزبانه، دیکشنری رابط، بومی‌سازی داده، تغییر زبان در هدر، ویرایش انگلیسی در پنل و پشتیبانی LTR 2026-09-12 23:37:55 +03:30
MMD 6bb6226e06 بک‌اند: مدل دوزبانه — ستون‌های _en، ترجمه کامل seed انگلیسی، backfill خودکار و پوسته سئوی /en/ 2026-09-12 22:57:06 +03:30
MMD 9242b87677 ادغام site-v2: به‌روزرسانی رابط کاربری (صفحات پروژه‌ها، نمونه‌کارها، تایم‌لاین، سرمایه‌گذاری و کارت‌ها) 2026-09-12 20:08:11 +03:30
131 changed files with 14980 additions and 1257 deletions
+237
View File
@@ -15,6 +15,119 @@ POSITION_TABLES = [
"offices", "gallery_images", "nav_items",
]
# ستون‌های انگلیسی — (جدول، ستون، نوع). ستون‌های JSON بدون default اند تا
# سطرهای موجود NULL بگیرند (یعنی «ترجمه ثبت نشده») و فرانت به فارسی برگردد.
EN_COLUMNS: list[tuple[str, str, str]] = [
("brand", "name_en", "VARCHAR(128)"),
("brand", "address_en", "TEXT"),
("services", "title_en", "VARCHAR(256)"),
("services", "desc_en", "TEXT"),
("projects", "title_en", "VARCHAR(256)"),
("projects", "location_en", "VARCHAR(128)"),
("projects", "year_en", "VARCHAR(64)"),
("projects", "area_en", "VARCHAR(64)"),
("projects", "floors_en", "VARCHAR(64)"),
("projects", "units_en", "VARCHAR(64)"),
("projects", "client_en", "VARCHAR(128)"),
("projects", "summary_en", "TEXT"),
("projects", "body_en", "JSON"),
("articles", "title_en", "VARCHAR(256)"),
("articles", "date_en", "VARCHAR(64)"),
("articles", "category_en", "VARCHAR(64)"),
("articles", "read_en", "VARCHAR(32)"),
("articles", "excerpt_en", "TEXT"),
("articles", "body_en", "JSON"),
("testimonials", "name_en", "VARCHAR(128)"),
("testimonials", "role_en", "VARCHAR(128)"),
("testimonials", "quote_en", "TEXT"),
("faqs", "question_en", "TEXT"),
("faqs", "answer_en", "TEXT"),
("team", "name_en", "VARCHAR(128)"),
("team", "role_en", "VARCHAR(128)"),
("team", "bio_en", "TEXT"),
("timeline", "year_en", "VARCHAR(32)"),
("timeline", "title_en", "VARCHAR(256)"),
("timeline", "desc_en", "TEXT"),
("stats", "suffix_en", "VARCHAR(16)"),
("stats", "label_en", "VARCHAR(128)"),
("why_us", "title_en", "VARCHAR(256)"),
("why_us", "desc_en", "TEXT"),
("before_after", "title_en", "VARCHAR(256)"),
("before_after", "lead_en", "TEXT"),
("before_after", "before_label_en", "VARCHAR(128)"),
("before_after", "after_label_en", "VARCHAR(128)"),
("nav_items", "label_en", "VARCHAR(64)"),
("hero", "kicker_en", "TEXT"),
("hero", "title_en", "TEXT"),
("hero", "title_highlight_en", "VARCHAR(128)"),
("hero", "subtitle_en", "TEXT"),
("hero", "badge_value_en", "VARCHAR(32)"),
("hero", "badge_label_en", "TEXT"),
("hero", "cta_primary_label_en", "VARCHAR(128)"),
("hero", "cta_secondary_label_en", "VARCHAR(128)"),
("hero", "scroll_label_en", "VARCHAR(64)"),
("story", "title_en", "VARCHAR(256)"),
("story", "paragraphs_en", "JSON"),
("story", "quote_en", "TEXT"),
("story", "quote_author_en", "TEXT"),
("story", "stat1_value_en", "VARCHAR(32)"),
("story", "stat1_label_en", "VARCHAR(128)"),
("story", "stat2_value_en", "VARCHAR(32)"),
("story", "stat2_label_en", "VARCHAR(128)"),
("story", "link_label_en", "VARCHAR(128)"),
("story", "bento_en", "JSON"),
("section_headings", "label_en", "VARCHAR(64)"),
("section_headings", "title_en", "VARCHAR(256)"),
("section_headings", "lead_en", "TEXT"),
("section_headings", "cta_label_en", "VARCHAR(128)"),
("page_heroes", "title_en", "VARCHAR(256)"),
("page_heroes", "lead_en", "TEXT"),
("investment_models", "title_en", "VARCHAR(256)"),
("investment_models", "desc_en", "TEXT"),
("investment_models", "points_en", "JSON"),
("process_steps", "step_en", "VARCHAR(16)"),
("process_steps", "title_en", "VARCHAR(256)"),
("process_steps", "desc_en", "TEXT"),
("offices", "city_en", "VARCHAR(64)"),
("offices", "address_en", "TEXT"),
("offices", "hours_en", "VARCHAR(128)"),
("gallery_images", "alt_en", "TEXT"),
("status_labels", "label_en", "VARCHAR(64)"),
("contact_section", "label_en", "VARCHAR(64)"),
("contact_section", "title_en", "VARCHAR(256)"),
("contact_section", "lead_en", "TEXT"),
("contact_section", "address_label_en", "VARCHAR(64)"),
("contact_section", "phone_label_en", "VARCHAR(64)"),
("contact_section", "email_label_en", "VARCHAR(64)"),
("contact_section", "name_label_en", "VARCHAR(64)"),
("contact_section", "name_placeholder_en", "VARCHAR(128)"),
("contact_section", "phone_field_label_en", "VARCHAR(64)"),
("contact_section", "phone_placeholder_en", "VARCHAR(64)"),
("contact_section", "note_label_en", "VARCHAR(64)"),
("contact_section", "note_optional_label_en", "VARCHAR(64)"),
("contact_section", "note_placeholder_en", "VARCHAR(256)"),
("contact_section", "submit_label_en", "VARCHAR(128)"),
("contact_section", "sending_label_en", "VARCHAR(64)"),
("contact_section", "success_title_en", "VARCHAR(256)"),
("contact_section", "success_text_en", "TEXT"),
("contact_section", "success_button_label_en", "VARCHAR(128)"),
("newsletter", "title_en", "VARCHAR(256)"),
("newsletter", "lead_en", "TEXT"),
("newsletter", "placeholder_en", "VARCHAR(128)"),
("newsletter", "button_label_en", "VARCHAR(64)"),
("newsletter", "sending_label_en", "VARCHAR(64)"),
("newsletter", "success_text_en", "VARCHAR(256)"),
("footer", "about_text_en", "TEXT"),
("footer", "pages_heading_en", "VARCHAR(64)"),
("footer", "services_heading_en", "VARCHAR(64)"),
("footer", "contact_heading_en", "VARCHAR(64)"),
("footer", "copyright_en", "VARCHAR(256)"),
("footer", "tagline_en", "VARCHAR(256)"),
("footer", "socials_en", "JSON"),
("seo_meta", "title_en", "VARCHAR(256)"),
("seo_meta", "description_en", "TEXT"),
]
def run_ddl_migrations() -> None:
"""ستون‌های جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
@@ -32,6 +145,14 @@ def run_ddl_migrations() -> None:
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
)
# ستون‌های انگلیسی — رشته‌ها با پیش‌فرض خالی، JSON بدون پیش‌فرض (NULL = بدون ترجمه)
for table, column, col_type in EN_COLUMNS:
if col_type == "JSON":
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} JSON"))
else:
conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} {col_type} NOT NULL DEFAULT ''")
)
def paragraphs_to_doc(paragraphs: list) -> dict:
@@ -66,6 +187,122 @@ def migrate_article_bodies() -> None:
db.close()
def _is_empty(value: object) -> bool:
"""مقدار خالی انگلیسی — یعنی هنوز ترجمه‌ای ثبت نشده و قابل پر کردن است."""
if value is None or value == "" or value == [] or value == {}:
return True
if isinstance(value, dict) and value.get("content") == [{"type": "paragraph"}]:
return True
return False
def _backfill_row(row, en_fields: dict) -> int:
"""پر کردن فیلدهای خالی *_en یک سطر از دیکشنری ترجمه — تعداد فیلدهای پرشده."""
filled = 0
for key, value in en_fields.items():
if not key.endswith("_en"):
continue
if not _is_empty(getattr(row, key, None)):
continue
setattr(row, key, value)
filled += 1
return filled
def backfill_en_content() -> None:
"""پر کردن ترجمه‌های انگلیسی سطرهای موجود از seed_data_en — idempotent.
تطبیق سطرها: جدول‌های تک‌سطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
ویرایش کرده باشد سطر تطبیق نمی‌خورد و ترجمه‌اش خالی می‌ماند تا از پنل پر شود.
هیچ مقدار انگلیسیِ ثبت‌شده بازنویسی نمی‌شود.
"""
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقه‌ای
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تک‌سطری‌ها)
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")),
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)),
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
]
db = SessionLocal()
try:
total_rows = 0
total_fields = 0
unmatched: list[str] = []
for model, fa_rows, en_rows, match_keys in PLAN:
rows = db.query(model).all()
if not rows or not en_rows:
continue
table_name = getattr(model, "__tablename__", str(model))
if not match_keys:
if len(rows) != 1 or len(en_rows) != 1:
unmatched.append(table_name)
continue
pairs = [(rows[0], en_rows[0])]
else:
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
en_lookups: list[dict[object, dict]] = []
for key in match_keys:
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
pairs = []
matched_en_ids: set[int] = set()
for row in rows:
en_row = None
for key, lookup in zip(match_keys, en_lookups):
en_row = lookup.get(getattr(row, key, None))
if en_row is not None:
break
if en_row is not None and id(en_row) not in matched_en_ids:
matched_en_ids.add(id(en_row))
pairs.append((row, en_row))
for row, en_row in pairs:
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل می‌شود
en_fields = dict(en_row)
if model is models.Article and isinstance(en_fields.get("body_en"), list):
en_fields["body_en"] = paragraphs_to_doc(en_fields["body_en"])
filled = _backfill_row(row, en_fields)
total_fields += filled
if filled:
total_rows += 1
db.commit()
if total_fields:
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
if unmatched:
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
for name in unmatched:
print(f" - {name}")
finally:
db.close()
def run_all() -> None:
run_ddl_migrations()
migrate_article_bodies()
backfill_en_content()
+113 -1
View File
@@ -10,6 +10,10 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc)
# ستون‌های *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
# مقدار خالی یعنی «ترجمه‌ای ثبت نشده» و فرانت‌اند به نسخه فارسی برمی‌گردد.
class AdminUser(Base):
__tablename__ = "admin_users"
@@ -23,6 +27,7 @@ class Brand(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), default="")
name_en: Mapped[str] = mapped_column(String(128), default="")
logo: Mapped[str] = mapped_column(Text, default="")
hero: Mapped[str] = mapped_column(Text, default="")
contact_bg: Mapped[str] = mapped_column(Text, default="")
@@ -30,6 +35,7 @@ class Brand(Base):
phone_href: Mapped[str] = mapped_column(String(128), default="")
email: Mapped[str] = mapped_column(String(128), default="")
address: Mapped[str] = mapped_column(Text, default="")
address_en: Mapped[str] = mapped_column(Text, default="")
class Service(Base):
@@ -39,7 +45,9 @@ class Service(Base):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
icon: Mapped[str] = mapped_column(String(32), default="draft")
@@ -51,17 +59,26 @@ class Project(Base):
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
location: Mapped[str] = mapped_column(String(128), default="")
location_en: Mapped[str] = mapped_column(String(128), default="")
year: Mapped[str] = mapped_column(String(64), default="")
year_en: Mapped[str] = mapped_column(String(64), default="")
status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
image: Mapped[str] = mapped_column(Text, default="")
gallery: Mapped[list] = mapped_column(JSON, default=list)
area: Mapped[str] = mapped_column(String(64), default="")
area_en: Mapped[str] = mapped_column(String(64), default="")
floors: Mapped[str] = mapped_column(String(64), default="")
floors_en: Mapped[str] = mapped_column(String(64), default="")
units: Mapped[str] = mapped_column(String(64), default="")
units_en: Mapped[str] = mapped_column(String(64), default="")
client: Mapped[str] = mapped_column(String(128), default="")
client_en: Mapped[str] = mapped_column(String(128), default="")
summary: Mapped[str] = mapped_column(Text, default="")
summary_en: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list)
body_en: Mapped[list] = mapped_column(JSON, default=list)
class Article(Base):
@@ -72,12 +89,18 @@ class Article(Base):
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
date: Mapped[str] = mapped_column(String(64), default="")
date_en: Mapped[str] = mapped_column(String(64), default="")
category: Mapped[str] = mapped_column(String(64), default="")
category_en: Mapped[str] = mapped_column(String(64), default="")
image: Mapped[str] = mapped_column(Text, default="")
read: Mapped[str] = mapped_column(String(32), default="")
read_en: Mapped[str] = mapped_column(String(32), default="")
excerpt: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list)
excerpt_en: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
# draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود
status: Mapped[str] = mapped_column(String(16), default="published")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
@@ -91,8 +114,11 @@ class Testimonial(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128))
name_en: Mapped[str] = mapped_column(String(128), default="")
role: Mapped[str] = mapped_column(String(128), default="")
role_en: Mapped[str] = mapped_column(String(128), default="")
quote: Mapped[str] = mapped_column(Text, default="")
quote_en: Mapped[str] = mapped_column(Text, default="")
class Faq(Base):
@@ -102,7 +128,9 @@ class Faq(Base):
id: Mapped[int] = mapped_column(primary_key=True)
question: Mapped[str] = mapped_column(Text)
question_en: Mapped[str] = mapped_column(Text, default="")
answer: Mapped[str] = mapped_column(Text, default="")
answer_en: Mapped[str] = mapped_column(Text, default="")
class TeamMember(Base):
@@ -112,8 +140,11 @@ class TeamMember(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128))
name_en: Mapped[str] = mapped_column(String(128), default="")
role: Mapped[str] = mapped_column(String(128), default="")
role_en: Mapped[str] = mapped_column(String(128), default="")
bio: Mapped[str] = mapped_column(Text, default="")
bio_en: Mapped[str] = mapped_column(Text, default="")
class TimelineItem(Base):
@@ -123,8 +154,11 @@ class TimelineItem(Base):
id: Mapped[int] = mapped_column(primary_key=True)
year: Mapped[str] = mapped_column(String(32))
year_en: Mapped[str] = mapped_column(String(32), default="")
title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
image: Mapped[str] = mapped_column(Text, default="")
@@ -136,7 +170,9 @@ class Stat(Base):
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer)
suffix: Mapped[str] = mapped_column(String(16), default="")
suffix_en: Mapped[str] = mapped_column(String(16), default="")
label: Mapped[str] = mapped_column(String(128))
label_en: Mapped[str] = mapped_column(String(128), default="")
class WhyUsItem(Base):
@@ -146,7 +182,9 @@ class WhyUsItem(Base):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
class BeforeAfter(Base):
@@ -154,11 +192,15 @@ class BeforeAfter(Base):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
before: Mapped[str] = mapped_column(Text, default="")
after: Mapped[str] = mapped_column(Text, default="")
before_label: Mapped[str] = mapped_column(String(128), default="")
before_label_en: Mapped[str] = mapped_column(String(128), default="")
after_label: Mapped[str] = mapped_column(String(128), default="")
after_label_en: Mapped[str] = mapped_column(String(128), default="")
class NavItem(Base):
@@ -168,6 +210,7 @@ class NavItem(Base):
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(64))
label_en: Mapped[str] = mapped_column(String(64), default="")
href: Mapped[str] = mapped_column(String(128))
@@ -178,16 +221,25 @@ class Hero(Base):
id: Mapped[int] = mapped_column(primary_key=True)
kicker: Mapped[str] = mapped_column(Text, default="")
kicker_en: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(Text, default="")
title_en: Mapped[str] = mapped_column(Text, default="")
title_highlight: Mapped[str] = mapped_column(String(128), default="")
title_highlight_en: Mapped[str] = mapped_column(String(128), default="")
subtitle: Mapped[str] = mapped_column(Text, default="")
subtitle_en: Mapped[str] = mapped_column(Text, default="")
badge_value: Mapped[str] = mapped_column(String(32), default="")
badge_value_en: Mapped[str] = mapped_column(String(32), default="")
badge_label: Mapped[str] = mapped_column(Text, default="")
badge_label_en: Mapped[str] = mapped_column(Text, default="")
cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
cta_primary_label_en: Mapped[str] = mapped_column(String(128), default="")
cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
cta_secondary_label_en: Mapped[str] = mapped_column(String(128), default="")
cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
scroll_label: Mapped[str] = mapped_column(String(64), default="")
scroll_label_en: Mapped[str] = mapped_column(String(64), default="")
class StorySection(Base):
@@ -197,16 +249,26 @@ class StorySection(Base):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
paragraphs: Mapped[list] = mapped_column(JSON, default=list)
paragraphs_en: Mapped[list] = mapped_column(JSON, default=list)
quote: Mapped[str] = mapped_column(Text, default="")
quote_en: Mapped[str] = mapped_column(Text, default="")
quote_author: Mapped[str] = mapped_column(Text, default="")
quote_author_en: Mapped[str] = mapped_column(Text, default="")
stat1_value: Mapped[str] = mapped_column(String(32), default="")
stat1_value_en: Mapped[str] = mapped_column(String(32), default="")
stat1_label: Mapped[str] = mapped_column(String(128), default="")
stat1_label_en: Mapped[str] = mapped_column(String(128), default="")
stat2_value: Mapped[str] = mapped_column(String(32), default="")
stat2_value_en: Mapped[str] = mapped_column(String(32), default="")
stat2_label: Mapped[str] = mapped_column(String(128), default="")
stat2_label_en: Mapped[str] = mapped_column(String(128), default="")
link_label: Mapped[str] = mapped_column(String(128), default="")
link_label_en: Mapped[str] = mapped_column(String(128), default="")
link_href: Mapped[str] = mapped_column(String(128), default="")
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
bento_en: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] انگلیسی
class SectionHeadingContent(Base):
@@ -217,10 +279,14 @@ class SectionHeadingContent(Base):
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
label: Mapped[str] = mapped_column(String(64), default="")
label_en: Mapped[str] = mapped_column(String(64), default="")
title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
image: Mapped[str] = mapped_column(Text, default="")
cta_label: Mapped[str] = mapped_column(String(128), default="")
cta_label_en: Mapped[str] = mapped_column(String(128), default="")
cta_href: Mapped[str] = mapped_column(String(128), default="")
@@ -232,7 +298,9 @@ class PageHeroContent(Base):
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
image: Mapped[str] = mapped_column(Text, default="")
@@ -243,8 +311,11 @@ class InvestmentModel(Base):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
points: Mapped[list] = mapped_column(JSON, default=list)
points_en: Mapped[list] = mapped_column(JSON, default=list)
class ProcessStep(Base):
@@ -254,8 +325,11 @@ class ProcessStep(Base):
id: Mapped[int] = mapped_column(primary_key=True)
step: Mapped[str] = mapped_column(String(16), default="")
step_en: Mapped[str] = mapped_column(String(16), default="")
title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
class Office(Base):
@@ -265,8 +339,11 @@ class Office(Base):
id: Mapped[int] = mapped_column(primary_key=True)
city: Mapped[str] = mapped_column(String(64))
city_en: Mapped[str] = mapped_column(String(64), default="")
address: Mapped[str] = mapped_column(Text, default="")
address_en: Mapped[str] = mapped_column(Text, default="")
hours: Mapped[str] = mapped_column(String(128), default="")
hours_en: Mapped[str] = mapped_column(String(128), default="")
class GalleryImage(Base):
@@ -277,6 +354,7 @@ class GalleryImage(Base):
id: Mapped[int] = mapped_column(primary_key=True)
src: Mapped[str] = mapped_column(Text, default="")
alt: Mapped[str] = mapped_column(Text, default="")
alt_en: Mapped[str] = mapped_column(Text, default="")
class StatusLabel(Base):
@@ -287,6 +365,7 @@ class StatusLabel(Base):
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
label: Mapped[str] = mapped_column(String(64))
label_en: Mapped[str] = mapped_column(String(64), default="")
class ContactSectionContent(Base):
@@ -296,23 +375,41 @@ class ContactSectionContent(Base):
id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(64), default="")
label_en: Mapped[str] = mapped_column(String(64), default="")
title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
address_label: Mapped[str] = mapped_column(String(64), default="")
address_label_en: Mapped[str] = mapped_column(String(64), default="")
phone_label: Mapped[str] = mapped_column(String(64), default="")
phone_label_en: Mapped[str] = mapped_column(String(64), default="")
email_label: Mapped[str] = mapped_column(String(64), default="")
email_label_en: Mapped[str] = mapped_column(String(64), default="")
name_label: Mapped[str] = mapped_column(String(64), default="")
name_label_en: Mapped[str] = mapped_column(String(64), default="")
name_placeholder: Mapped[str] = mapped_column(String(128), default="")
name_placeholder_en: Mapped[str] = mapped_column(String(128), default="")
phone_field_label: Mapped[str] = mapped_column(String(64), default="")
phone_field_label_en: Mapped[str] = mapped_column(String(64), default="")
phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
phone_placeholder_en: Mapped[str] = mapped_column(String(64), default="")
note_label: Mapped[str] = mapped_column(String(64), default="")
note_label_en: Mapped[str] = mapped_column(String(64), default="")
note_optional_label: Mapped[str] = mapped_column(String(64), default="")
note_optional_label_en: Mapped[str] = mapped_column(String(64), default="")
note_placeholder: Mapped[str] = mapped_column(String(256), default="")
note_placeholder_en: Mapped[str] = mapped_column(String(256), default="")
submit_label: Mapped[str] = mapped_column(String(128), default="")
submit_label_en: Mapped[str] = mapped_column(String(128), default="")
sending_label: Mapped[str] = mapped_column(String(64), default="")
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
success_title: Mapped[str] = mapped_column(String(256), default="")
success_title_en: Mapped[str] = mapped_column(String(256), default="")
success_text: Mapped[str] = mapped_column(Text, default="")
success_text_en: Mapped[str] = mapped_column(Text, default="")
success_button_label: Mapped[str] = mapped_column(String(128), default="")
success_button_label_en: Mapped[str] = mapped_column(String(128), default="")
class NewsletterSectionContent(Base):
@@ -322,11 +419,17 @@ class NewsletterSectionContent(Base):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
placeholder: Mapped[str] = mapped_column(String(128), default="")
placeholder_en: Mapped[str] = mapped_column(String(128), default="")
button_label: Mapped[str] = mapped_column(String(64), default="")
button_label_en: Mapped[str] = mapped_column(String(64), default="")
sending_label: Mapped[str] = mapped_column(String(64), default="")
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
success_text: Mapped[str] = mapped_column(String(256), default="")
success_text_en: Mapped[str] = mapped_column(String(256), default="")
class FooterContent(Base):
@@ -336,12 +439,19 @@ class FooterContent(Base):
id: Mapped[int] = mapped_column(primary_key=True)
about_text: Mapped[str] = mapped_column(Text, default="")
about_text_en: Mapped[str] = mapped_column(Text, default="")
pages_heading: Mapped[str] = mapped_column(String(64), default="")
pages_heading_en: Mapped[str] = mapped_column(String(64), default="")
services_heading: Mapped[str] = mapped_column(String(64), default="")
services_heading_en: Mapped[str] = mapped_column(String(64), default="")
contact_heading: Mapped[str] = mapped_column(String(64), default="")
contact_heading_en: Mapped[str] = mapped_column(String(64), default="")
copyright: Mapped[str] = mapped_column(String(256), default="")
copyright_en: Mapped[str] = mapped_column(String(256), default="")
tagline: Mapped[str] = mapped_column(String(256), default="")
tagline_en: Mapped[str] = mapped_column(String(256), default="")
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
socials_en: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] انگلیسی
class SeoMeta(Base):
@@ -352,7 +462,9 @@ class SeoMeta(Base):
id: Mapped[int] = mapped_column(primary_key=True)
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
description: Mapped[str] = mapped_column(Text, default="")
description_en: Mapped[str] = mapped_column(Text, default="")
class ContactMessage(Base):
+229
View File
@@ -10,12 +10,16 @@ class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
# فیلدهای *_en معادل انگلیسی‌اند؛ خالی بودنشان یعنی «بدون ترجمه».
# ---------- خروجی‌ها ----------
class BrandOut(ORMModel):
id: int
name: str
name_en: str = ""
logo: str
hero: str
contact_bg: str
@@ -23,12 +27,15 @@ class BrandOut(ORMModel):
phone_href: str
email: str
address: str
address_en: str = ""
class ServiceOut(ORMModel):
id: int
title: str
title_en: str = ""
desc: str
desc_en: str = ""
icon: str
@@ -36,29 +43,44 @@ class ProjectOut(ORMModel):
id: int
slug: str
title: str
title_en: str = ""
location: str
location_en: str = ""
year: str
year_en: str = ""
status: str
image: str
gallery: list[str]
area: str
area_en: str = ""
floors: str
floors_en: str = ""
units: str
units_en: str = ""
client: str
client_en: str = ""
summary: str
summary_en: str = ""
body: list[str]
body_en: Optional[list[str]] = None
class ArticleOut(ORMModel):
id: int
slug: str
title: str
title_en: str = ""
date: str
date_en: str = ""
category: str
category_en: str = ""
image: str
read: str
read_en: str = ""
excerpt: str
excerpt_en: str = ""
body: dict
body_en: Optional[dict] = None
status: str
seo_keyphrase: str
@@ -66,28 +88,39 @@ class ArticleOut(ORMModel):
class TestimonialOut(ORMModel):
id: int
name: str
name_en: str = ""
role: str
role_en: str = ""
quote: str
quote_en: str = ""
class FaqOut(ORMModel):
id: int
question: str
question_en: str = ""
answer: str
answer_en: str = ""
class TeamMemberOut(ORMModel):
id: int
name: str
name_en: str = ""
role: str
role_en: str = ""
bio: str
bio_en: str = ""
class TimelineItemOut(ORMModel):
id: int
year: str
year_en: str = ""
title: str
title_en: str = ""
desc: str
desc_en: str = ""
image: str
@@ -95,69 +128,101 @@ class StatOut(ORMModel):
id: int
value: int
suffix: str
suffix_en: str = ""
label: str
label_en: str = ""
class WhyUsItemOut(ORMModel):
id: int
title: str
title_en: str = ""
desc: str
desc_en: str = ""
class BeforeAfterOut(ORMModel):
id: int
title: str
title_en: str = ""
lead: str
lead_en: str = ""
before: str
after: str
before_label: str
before_label_en: str = ""
after_label: str
after_label_en: str = ""
class NavItemOut(ORMModel):
id: int
label: str
label_en: str = ""
href: str
class HeroOut(ORMModel):
id: int
kicker: str
kicker_en: str = ""
title: str
title_en: str = ""
title_highlight: str
title_highlight_en: str = ""
subtitle: str
subtitle_en: str = ""
badge_value: str
badge_value_en: str = ""
badge_label: str
badge_label_en: str = ""
cta_primary_label: str
cta_primary_label_en: str = ""
cta_primary_href: str
cta_secondary_label: str
cta_secondary_label_en: str = ""
cta_secondary_href: str
scroll_label: str
scroll_label_en: str = ""
class StoryOut(ORMModel):
id: int
title: str
title_en: str = ""
paragraphs: list[str]
paragraphs_en: Optional[list[str]] = None
quote: str
quote_en: str = ""
quote_author: str
quote_author_en: str = ""
stat1_value: str
stat1_value_en: str = ""
stat1_label: str
stat1_label_en: str = ""
stat2_value: str
stat2_value_en: str = ""
stat2_label: str
stat2_label_en: str = ""
link_label: str
link_label_en: str = ""
link_href: str
bento: list[dict]
bento_en: Optional[list[dict]] = None
class SectionHeadingOut(ORMModel):
id: int
key: str
label: str
label_en: str = ""
title: str
title_en: str = ""
lead: str
lead_en: str = ""
image: str
cta_label: str
cta_label_en: str = ""
cta_href: str
@@ -165,91 +230,137 @@ class PageHeroOut(ORMModel):
id: int
key: str
title: str
title_en: str = ""
lead: str
lead_en: str = ""
image: str
class InvestmentModelOut(ORMModel):
id: int
title: str
title_en: str = ""
desc: str
desc_en: str = ""
points: list[str]
points_en: Optional[list[str]] = None
class ProcessStepOut(ORMModel):
id: int
step: str
step_en: str = ""
title: str
title_en: str = ""
desc: str
desc_en: str = ""
class OfficeOut(ORMModel):
id: int
city: str
city_en: str = ""
address: str
address_en: str = ""
hours: str
hours_en: str = ""
class GalleryImageOut(ORMModel):
id: int
src: str
alt: str
alt_en: str = ""
class StatusLabelOut(ORMModel):
id: int
key: str
label: str
label_en: str = ""
class ContactSectionOut(ORMModel):
id: int
label: str
label_en: str = ""
title: str
title_en: str = ""
lead: str
lead_en: str = ""
address_label: str
address_label_en: str = ""
phone_label: str
phone_label_en: str = ""
email_label: str
email_label_en: str = ""
name_label: str
name_label_en: str = ""
name_placeholder: str
name_placeholder_en: str = ""
phone_field_label: str
phone_field_label_en: str = ""
phone_placeholder: str
phone_placeholder_en: str = ""
note_label: str
note_label_en: str = ""
note_optional_label: str
note_optional_label_en: str = ""
note_placeholder: str
note_placeholder_en: str = ""
submit_label: str
submit_label_en: str = ""
sending_label: str
sending_label_en: str = ""
success_title: str
success_title_en: str = ""
success_text: str
success_text_en: str = ""
success_button_label: str
success_button_label_en: str = ""
class NewsletterOut(ORMModel):
id: int
title: str
title_en: str = ""
lead: str
lead_en: str = ""
placeholder: str
placeholder_en: str = ""
button_label: str
button_label_en: str = ""
sending_label: str
sending_label_en: str = ""
success_text: str
success_text_en: str = ""
class FooterOut(ORMModel):
id: int
about_text: str
about_text_en: str = ""
pages_heading: str
pages_heading_en: str = ""
services_heading: str
services_heading_en: str = ""
contact_heading: str
contact_heading_en: str = ""
copyright: str
copyright_en: str = ""
tagline: str
tagline_en: str = ""
socials: list[dict]
socials_en: Optional[list[dict]] = None
class SeoMetaOut(ORMModel):
id: int
page_key: str
title: str
title_en: str = ""
description: str
description_en: str = ""
class ContactMessageOut(ORMModel):
@@ -301,6 +412,7 @@ class ChangePasswordIn(BaseModel):
class BrandIn(BaseModel):
name: str = ""
name_en: str = ""
logo: str = ""
hero: str = ""
contact_bg: str = ""
@@ -308,28 +420,40 @@ class BrandIn(BaseModel):
phone_href: str = ""
email: str = ""
address: str = ""
address_en: str = ""
class ServiceIn(BaseModel):
title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = ""
desc_en: str = ""
icon: str = "draft"
class ProjectIn(BaseModel):
slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256)
title_en: str = ""
location: str = ""
location_en: str = ""
year: str = ""
year_en: str = ""
status: Literal["done", "progress"] = "done"
image: str = ""
gallery: list[str] = []
area: str = ""
area_en: str = ""
floors: str = ""
floors_en: str = ""
units: str = ""
units_en: str = ""
client: str = ""
client_en: str = ""
summary: str = ""
summary_en: str = ""
body: list[str] = []
body_en: list[str] = []
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
@@ -347,189 +471,287 @@ def validate_doc_body(value: object) -> dict:
class ArticleIn(BaseModel):
slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256)
title_en: str = ""
date: str = ""
date_en: str = ""
category: str = ""
category_en: str = ""
image: str = ""
read: str = ""
read_en: str = ""
excerpt: str = ""
excerpt_en: str = ""
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
status: Literal["draft", "published"] = "draft"
seo_keyphrase: str = Field(default="", max_length=128)
_validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body)
class TestimonialIn(BaseModel):
name: str = Field(min_length=1, max_length=128)
name_en: str = ""
role: str = ""
role_en: str = ""
quote: str = ""
quote_en: str = ""
class FaqIn(BaseModel):
question: str = Field(min_length=1)
question_en: str = ""
answer: str = ""
answer_en: str = ""
class TeamMemberIn(BaseModel):
name: str = Field(min_length=1, max_length=128)
name_en: str = ""
role: str = ""
role_en: str = ""
bio: str = ""
bio_en: str = ""
class TimelineItemIn(BaseModel):
year: str = Field(min_length=1, max_length=32)
year_en: str = ""
title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = ""
desc_en: str = ""
image: str = ""
class StatIn(BaseModel):
value: int
suffix: str = ""
suffix_en: str = ""
label: str = Field(min_length=1, max_length=128)
label_en: str = ""
class WhyUsItemIn(BaseModel):
title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = ""
desc_en: str = ""
class BeforeAfterIn(BaseModel):
title: str = ""
title_en: str = ""
lead: str = ""
lead_en: str = ""
before: str = ""
after: str = ""
before_label: str = ""
before_label_en: str = ""
after_label: str = ""
after_label_en: str = ""
class NavItemIn(BaseModel):
label: str = Field(min_length=1, max_length=64)
label_en: str = ""
href: str = Field(min_length=1, max_length=128)
class HeroIn(BaseModel):
kicker: str = ""
kicker_en: str = ""
title: str = ""
title_en: str = ""
title_highlight: str = ""
title_highlight_en: str = ""
subtitle: str = ""
subtitle_en: str = ""
badge_value: str = ""
badge_value_en: str = ""
badge_label: str = ""
badge_label_en: str = ""
cta_primary_label: str = ""
cta_primary_label_en: str = ""
cta_primary_href: str = ""
cta_secondary_label: str = ""
cta_secondary_label_en: str = ""
cta_secondary_href: str = ""
scroll_label: str = ""
scroll_label_en: str = ""
class StoryIn(BaseModel):
title: str = ""
title_en: str = ""
paragraphs: list[str] = []
paragraphs_en: list[str] = []
quote: str = ""
quote_en: str = ""
quote_author: str = ""
quote_author_en: str = ""
stat1_value: str = ""
stat1_value_en: str = ""
stat1_label: str = ""
stat1_label_en: str = ""
stat2_value: str = ""
stat2_value_en: str = ""
stat2_label: str = ""
stat2_label_en: str = ""
link_label: str = ""
link_label_en: str = ""
link_href: str = ""
bento: list[dict] = []
bento_en: list[dict] = []
class SectionHeadingIn(BaseModel):
key: str = Field(min_length=1, max_length=64)
label: str = ""
label_en: str = ""
title: str = ""
title_en: str = ""
lead: str = ""
lead_en: str = ""
image: str = ""
cta_label: str = ""
cta_label_en: str = ""
cta_href: str = ""
class PageHeroIn(BaseModel):
key: str = Field(min_length=1, max_length=32)
title: str = ""
title_en: str = ""
lead: str = ""
lead_en: str = ""
image: str = ""
class InvestmentModelIn(BaseModel):
title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = ""
desc_en: str = ""
points: list[str] = []
points_en: list[str] = []
class ProcessStepIn(BaseModel):
step: str = ""
step_en: str = ""
title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = ""
desc_en: str = ""
class OfficeIn(BaseModel):
city: str = Field(min_length=1, max_length=64)
city_en: str = ""
address: str = ""
address_en: str = ""
hours: str = ""
hours_en: str = ""
class GalleryImageIn(BaseModel):
src: str = ""
alt: str = ""
alt_en: str = ""
class StatusLabelIn(BaseModel):
key: str = Field(min_length=1, max_length=16)
label: str = Field(min_length=1, max_length=64)
label_en: str = ""
class ContactSectionIn(BaseModel):
label: str = ""
label_en: str = ""
title: str = ""
title_en: str = ""
lead: str = ""
lead_en: str = ""
address_label: str = ""
address_label_en: str = ""
phone_label: str = ""
phone_label_en: str = ""
email_label: str = ""
email_label_en: str = ""
name_label: str = ""
name_label_en: str = ""
name_placeholder: str = ""
name_placeholder_en: str = ""
phone_field_label: str = ""
phone_field_label_en: str = ""
phone_placeholder: str = ""
phone_placeholder_en: str = ""
note_label: str = ""
note_label_en: str = ""
note_optional_label: str = ""
note_optional_label_en: str = ""
note_placeholder: str = ""
note_placeholder_en: str = ""
submit_label: str = ""
submit_label_en: str = ""
sending_label: str = ""
sending_label_en: str = ""
success_title: str = ""
success_title_en: str = ""
success_text: str = ""
success_text_en: str = ""
success_button_label: str = ""
success_button_label_en: str = ""
class NewsletterSectionIn(BaseModel):
title: str = ""
title_en: str = ""
lead: str = ""
lead_en: str = ""
placeholder: str = ""
placeholder_en: str = ""
button_label: str = ""
button_label_en: str = ""
sending_label: str = ""
sending_label_en: str = ""
success_text: str = ""
success_text_en: str = ""
class FooterIn(BaseModel):
about_text: str = ""
about_text_en: str = ""
pages_heading: str = ""
pages_heading_en: str = ""
services_heading: str = ""
services_heading_en: str = ""
contact_heading: str = ""
contact_heading_en: str = ""
copyright: str = ""
copyright_en: str = ""
tagline: str = ""
tagline_en: str = ""
socials: list[dict] = []
socials_en: list[dict] = []
class SeoMetaIn(BaseModel):
page_key: str = Field(min_length=1, max_length=32)
title: str = ""
title_en: str = ""
description: str = ""
description_en: str = ""
class SeoMetaPatch(BaseModel):
page_key: Optional[str] = None
title: Optional[str] = None
title_en: Optional[str] = None
description: Optional[str] = None
description_en: Optional[str] = None
class MessageReadIn(BaseModel):
@@ -551,15 +773,22 @@ ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
class ArticlePatch(BaseModel):
slug: Optional[str] = None
title: Optional[str] = None
title_en: Optional[str] = None
date: Optional[str] = None
date_en: Optional[str] = None
category: Optional[str] = None
category_en: Optional[str] = None
image: Optional[str] = None
read: Optional[str] = None
read_en: Optional[str] = None
excerpt: Optional[str] = None
excerpt_en: Optional[str] = None
body: Optional[dict] = None
body_en: Optional[dict] = None
status: Optional[Literal["draft", "published"]] = None
seo_keyphrase: Optional[str] = None
_validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body)
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
FaqPatch = _make_patch("FaqPatch", FaqIn)
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
+38 -6
View File
@@ -11,7 +11,7 @@ seed می‌شوند و داده‌های قبلی دست‌نخورده می‌
import argparse
from typing import Any
from . import models, seed_data
from . import models, seed_data, seed_data_en
from .auth import hash_password
from .config import settings
from .database import Base, SessionLocal, engine
@@ -46,6 +46,35 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
]
# ترجمه انگلیسی همان جدول‌ها — هم‌ترتیب با CONTENT تا با zip ادغام شود
EN_CONTENT: list[list[dict]] = [
[seed_data_en.BRAND],
[seed_data_en.HERO],
[seed_data_en.STORY],
seed_data_en.SECTION_HEADINGS,
seed_data_en.PAGE_HEROES,
seed_data_en.NAV_ITEMS,
seed_data_en.INVESTMENT_MODELS,
seed_data_en.PROCESS_STEPS,
seed_data_en.OFFICES,
seed_data_en.GALLERY_IMAGES,
seed_data_en.STATUS_LABELS,
[seed_data_en.CONTACT_SECTION],
[seed_data_en.NEWSLETTER],
[seed_data_en.FOOTER],
seed_data_en.SERVICES,
seed_data_en.STATS,
seed_data_en.PROJECTS,
seed_data_en.ARTICLES,
seed_data_en.TESTIMONIALS,
seed_data_en.FAQS,
seed_data_en.TEAM,
seed_data_en.TIMELINE,
seed_data_en.WHY,
[seed_data_en.BEFORE_AFTER],
seed_data_en.SEO_META,
]
def seed(force: bool = False) -> None:
db = SessionLocal()
@@ -57,16 +86,19 @@ def seed(force: bool = False) -> None:
Base.metadata.create_all(bind=engine)
run_migrations()
for model, rows, label in CONTENT:
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
if db.query(model).count() > 0:
continue
for idx, row in enumerate(rows):
payload = dict(row)
# فایل فارسی مرجع است؛ فقط فیلدهای *_en از فایل انگلیسی اضافه می‌شود
payload = {**dict(en_rows[idx]), **dict(row)} if idx < len(en_rows) else dict(row)
if hasattr(model, "position") and "position" not in payload:
payload["position"] = idx
# بدنه مقالات در seed به‌صورت لیست پاراگراف است — به سند تبدیل می‌شود
if model is models.Article and isinstance(payload.get("body"), list):
payload["body"] = paragraphs_to_doc(payload["body"])
# بدنه مقاله (فارسی و انگلیسی) در seed به‌صورت لیست پاراگراف است — به سند تبدیل می‌شود
if model is models.Article:
for body_key in ("body", "body_en"):
if isinstance(payload.get(body_key), list):
payload[body_key] = paragraphs_to_doc(payload[body_key])
db.add(model(**payload))
if db.query(models.AdminUser).count() == 0:
+580
View File
@@ -0,0 +1,580 @@
"""ترجمه انگلیسی داده‌های اولیه سایت — ساختار یک‌به‌یک با seed_data.py.
هر فهرست هم‌طول و هم‌ترتیب با معادل فارسی خود است تا seed.py بتواند با zip
ادغام کند و backfill مهاجرت بتواند سطرهای موجود را پر کند. فقط فیلدهای
`*_en` ملاک ادغام‌اند؛ کلیدهای هم‌نام با فارسی (مثل title یا body) صرفاً
برای خواندن راحت‌تر مترجم آمده‌اند و در ادغام نادیده گرفته می‌شوند.
فیلدهای تطبیق (slug، key، page_key، src) عیناً مقدار فارسی‌اند.
"""
from .seed_data import IMAGES
BRAND = {
"name": "گروه ساختمانی آرامش",
"name_en": "Aramesh Construction Group",
"address": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم",
"address_en": "Valiasr St., Aramesh Tower, 18th Floor, Tehran, Iran",
}
SERVICES = [
{"title": "طراحی معماری", "title_en": "Architectural Design", "desc": "خلق فرم‌های معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشه‌های اجرایی و جزئیات نما.", "desc_en": "Contemporary forms with an Iranian eye — from the first concept to construction drawings and facade details.", "icon": "draft"},
{"title": "ساخت برج‌های مسکونی", "title_en": "Residential Towers", "desc": "اجرای برج‌های بلندمرتبه با استانداردهای سازه‌ای بین‌المللی و کیفیت تحویل بی‌نقص.", "desc_en": "High-rise execution built on international structural standards and a flawless handover quality.", "icon": "tower"},
{"title": "مجتمع‌های تجاری", "title_en": "Commercial Complexes", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "desc_en": "Retail destinations designed and built around precise visitor-flow studies and long-term returns.", "icon": "mall"},
{"title": "ویلاسازی", "title_en": "Private Villas", "desc": "ویلاهای اختصاصی هم‌نشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "desc_en": "Bespoke villas in harmony with nature — privacy, natural light and enduring materials.", "icon": "villa"},
{"title": "بازسازی", "title_en": "Renovation", "desc": "بازآفرینی ساختمان‌های موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "desc_en": "Reimagining existing buildings with upgraded structure, facades and living experience.", "icon": "renew"},
{"title": "مدیریت پروژه", "title_en": "Project Management", "desc": "کنترل هم‌زمان زمان، هزینه و کیفیت با گزارش‌دهی شفاف در تمام مراحل ساخت.", "desc_en": "Controlling time, cost and quality in parallel with transparent reporting at every stage.", "icon": "manage"},
{"title": "سرمایه‌گذاری ساختمانی", "title_en": "Real Estate Investment", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "desc_en": "Transparent partnership structures, risk assessment and property portfolios tailored to your capital.", "icon": "invest"},
{"title": "محوطه‌سازی و لنداسکیپ", "title_en": "Landscape Design", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "desc_en": "Courtyards, green roofs and pathways — architecture carried on into the open air.", "icon": "landscape"},
]
STATS = [
{"value": 21, "suffix": "+", "suffix_en": "+", "label": "سال تجربه ساخت", "label_en": "Years of building experience"},
{"value": 47, "suffix": "", "suffix_en": "", "label": "پروژه تحویل‌شده", "label_en": "Projects delivered"},
{"value": 860, "suffix": "هزار", "suffix_en": "K", "label": "مترمربع زیربنا", "label_en": "Square meters built"},
{"value": 1200, "suffix": "+", "suffix_en": "+", "label": "خانواده ساکن", "label_en": "Resident families"},
]
PROJECTS = [
{
"slug": "aramesh-elahieh",
"title": "برج آرامش الهیه",
"title_en": "Aramesh Elahieh Tower",
"location": "تهران، الهیه",
"location_en": "Elahieh, Tehran",
"year": "۱۴۰۲",
"year_en": "2023",
"area": "۲۴٬۰۰۰ متر مربع",
"area_en": "24,000 m²",
"floors": "۱۸ طبقه",
"floors_en": "18 floors",
"units": "۵۶ واحد",
"units_en": "56 units",
"client": "سرمایه‌گذاری خصوصی",
"client_en": "Private investor",
"summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوسته‌ای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.",
"summary_en": "A luxury residential tower with travertine cladding and a double-skin facade that cuts energy use by up to 35%.",
"body": [],
"body_en": [
"Aramesh Elahieh Tower was designed on a site with a natural northward slope — an opportunity that placed the lobby, pool and shared spaces on three different levels and gave every unit an unobstructed view of the city.",
"The outer shell combines light travertine stone with floor-to-ceiling triple-glazed windows. Copper-toned vertical louvers break the direct summer sun while letting winter light pour in.",
"Construction was completed in thirty-two months, and every unit was sold before handover.",
],
},
{
"slug": "parseh-mall",
"title": "مجتمع تجاری پارسه",
"title_en": "Parseh Shopping Mall",
"location": "اصفهان، مرکز شهر",
"location_en": "City center, Isfahan",
"year": "۱۴۰۱",
"year_en": "2022",
"area": "۳۸٬۵۰۰ متر مربع",
"area_en": "38,500 m²",
"floors": "۷ طبقه",
"floors_en": "7 floors",
"units": "۱۴۰ واحد تجاری",
"units_en": "140 retail units",
"client": "گروه سرمایه‌گذاری پارسه",
"client_en": "Parseh Investment Group",
"summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را به‌صورت طبیعی در همه ترازها توزیع می‌کند.",
"summary_en": "A retail center with a central atrium and roof light-wells that naturally distribute visitor flow across every level.",
"body": [],
"body_en": [
"A visitor-behavior study before design showed that the shortest vertical path was the key to the complex's commercial success — so the central atrium with diagonal escalators sits at the heart of the plan.",
"Low-E glazing on the roof light-well carries daylight down to level B2 and measurably cuts artificial lighting consumption.",
"The complex opened two months ahead of the contractual schedule.",
],
},
{
"slug": "daryakenar-villas",
"title": "ویلاهای دریاکنار",
"title_en": "Daryakenar Villas",
"location": "مازندران، نوشهر",
"location_en": "Nowshahr, Mazandaran",
"year": "۱۴۰۰",
"year_en": "2021",
"area": "۹٬۲۰۰ متر مربع",
"area_en": "9,200 m²",
"floors": "۲ طبقه",
"floors_en": "2 floors",
"units": "۱۲ ویلا",
"units_en": "12 villas",
"client": "مجموعه خصوصی دریاکنار",
"client_en": "Daryakenar Private Estate",
"summary": "دوازده ویلای اختصاصی با چیدمان شانه‌ای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم می‌کند.",
"summary_en": "Twelve bespoke villas in a comb layout that gives each unit a direct sea view and complete privacy.",
"body": [],
"body_en": [
"The comb layout of the villas is the result of dozens of view-angle studies: no villa overlooks another, and all of them open onto the sea.",
"The materials answer the humid northern climate: thermowood, exposed concrete and salt-resistant coatings on the facade metals.",
"The landscape was designed with native plants to keep annual irrigation demand to a minimum.",
],
},
{
"slug": "niavaran-twins",
"title": "برج دوقلوی نیاوران",
"title_en": "Niavaran Twin Towers",
"location": "تهران، نیاوران",
"location_en": "Niavaran, Tehran",
"year": "۱۳۹۹",
"year_en": "2020",
"area": "۳۱٬۷۰۰ متر مربع",
"area_en": "31,700 m²",
"floors": "۲۰ طبقه",
"floors_en": "20 floors",
"units": "۸۰ واحد",
"units_en": "80 units",
"client": "مشارکت با مالکان زمین",
"client_en": "Partnership with landowners",
"summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگ‌ها را در یک تراز واحد ادغام می‌کند.",
"summary_en": "Two identical towers on a shared podium that merges amenities and parking into a single level.",
"body": [],
"body_en": [
"The two towers rest on a shared podium — a decision that let the amenities, event hall and parking be shared between the buildings and reduced the footprint on the garden.",
"The structure uses shear walls with post-tensioned slabs so the interior spans stay free and the floor plans remain customizable.",
],
},
{
"slug": "kish-garden-tower",
"title": "باغ‌برج کیش",
"title_en": "Kish Garden Tower",
"location": "کیش، مروارید",
"location_en": "Marvàrid, Kish Island",
"year": "۱۴۰۵ (پیش‌بینی)",
"year_en": "2026 (expected)",
"area": "۱۸٬۴۰۰ متر مربع",
"area_en": "18,400 m²",
"floors": "۱۴ طبقه",
"floors_en": "14 floors",
"units": "۶۲ واحد اقامتی",
"units_en": "62 hospitality units",
"client": "صندوق پروژه‌محور آرامش",
"client_en": "Aramesh Project Fund",
"summary": "باغ‌برجی اقامتی با تراس‌های سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.",
"summary_en": "A hospitality garden tower with continuous green terraces, currently under construction — 48% complete.",
"body": [],
"body_en": [
"The continuous green terraces form a living skin that measurably lowers the facade surface temperature in the island's hot climate.",
"The structure has reached the ninth floor, and facade installation has begun on the southern elevation.",
],
},
{
"slug": "aramesh-business-center",
"title": "مرکز تجاری آرامش تهران",
"title_en": "Aramesh Business Center Tehran",
"location": "تهران، سعادت‌آباد",
"location_en": "Saadatabad, Tehran",
"year": "۱۴۰۶ (پیش‌بینی)",
"year_en": "2027 (expected)",
"area": "۲۷٬۳۰۰ متر مربع",
"area_en": "27,300 m²",
"floors": "۱۲ طبقه",
"floors_en": "12 floors",
"units": "۹۰ دفتر اداری",
"units_en": "90 offices",
"client": "مشارکت با هلدینگ آرامش",
"client_en": "Partnership with Aramesh Holding",
"summary": "مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفت‌کاری.",
"summary_en": "An office-retail complex with a transparent central core and double-height lobby; currently in structural works.",
"body": [],
"body_en": [
"The space planning was built around flexible offices: every level can be divided into two, three or six independent units.",
"Mechanical installations are progressing in parallel with the structural works to shorten the overall timeline.",
],
},
]
BEFORE_AFTER = {
"title": "از کانسپت تا خلق واقعیت",
"title_en": "From Concept to Reality",
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.",
"lead_en": "Drag the handle to see how a building facade turns from a raw concept into its modern form.",
"before_label": "کانسپت اولیه",
"before_label_en": "Initial concept",
"after_label": "پروژه تکمیل‌شده",
"after_label_en": "Completed project",
}
TIMELINE = [
{"year": "۱۳۸۳", "year_en": "2004", "title": "آغاز مسیر", "title_en": "The Journey Begins", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "desc_en": "Aramesh Architecture Office is founded by a four-person team, taking on its first renovation project in Tehran.", "image": IMAGES[2]},
{"year": "۱۳۸۵", "year_en": "2006", "title": "نخستین ساخت مستقل", "title_en": "First Independent Build", "desc": "تحویل اولین ساختمان مسکونی هفت‌طبقه؛ آغاز همکاری با پیمانکاران سازه‌ای بزرگ.", "desc_en": "Delivery of the first seven-story residential building and the start of partnerships with major structural contractors.", "image": IMAGES[0]},
{"year": "۱۳۸۷", "year_en": "2008", "title": "ورود به بازار تجاری", "title_en": "Entering Commercial Market", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "desc_en": "Design and execution of the first commercial complex, driven by capital returns and visitor flow.", "image": IMAGES[1]},
{"year": "۱۳۸۹", "year_en": "2010", "title": "استاندارد بین‌المللی", "title_en": "International Standards", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهی‌نامه‌های مدیریت پروژه.", "desc_en": "A quality-control system is established and project-management certifications are earned.", "image": IMAGES[4]},
{"year": "۱۳۹۵", "year_en": "2016", "title": "بلندمرتبه‌سازی", "title_en": "Going High-Rise", "desc": "آغاز ساخت نخستین برج بیست‌طبقه شرکت در شمال تهران.", "desc_en": "Construction begins on the company's first twenty-story tower in northern Tehran.", "image": IMAGES[3]},
{"year": "۱۴۰۳", "year_en": "Today", "title": "گروه ساختمانی آرامش امروز", "title_en": "Aramesh Construction Group Today", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایه‌گذاری ساختمانی اختصاصی.", "desc_en": "A portfolio of 47 projects, offices in three cities and a dedicated real estate investment fund.", "image": IMAGES[0]},
]
WHY = [
{"title": "کیفیت بدون مصالحه", "title_en": "Quality Without Compromise", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددهه‌ای، نه سریع‌ترین تحویل.", "desc_en": "Materials and subcontractors are chosen for decades of durability, not the fastest delivery."},
{"title": "شفافیت مالی کامل", "title_en": "Full Financial Transparency", "desc": "گزارش ماهانه هزینه‌ها و پیشرفت فیزیکی برای همه شرکای پروژه.", "desc_en": "Monthly reporting of costs and physical progress for every project partner."},
{"title": "تحویل به‌موقع", "title_en": "On-Time Delivery", "desc": "۹۴٪ پروژه‌های ما در بازه زمانی قراردادی یا زودتر تحویل داده شده‌اند.", "desc_en": "94% of our projects have been delivered on or ahead of the contractual schedule."},
{"title": "طراحی اختصاصی", "title_en": "Bespoke Design", "desc": "هیچ نقشه‌ای دو بار اجرا نمی‌شود؛ هر پروژه هویت معماری خودش را دارد.", "desc_en": "No drawing is ever executed twice — every project has its own architectural identity."},
{"title": "خدمات پس از تحویل", "title_en": "After-Sales Service", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.", "desc_en": "Five years of technical support and building-systems maintenance after handover."},
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
]
TESTIMONIALS = [
{"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایه‌گذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارش‌های ماهانه گروه ساختمانی آرامش آن‌قدر دقیق بود که هیچ‌وقت نیاز نشد نگران سرمایه‌ام باشم. پروژه دو ماه زودتر از موعد به بهره‌برداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."},
{"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده می‌شود که معمولاً کسی به آن‌ها فکر نمی‌کند؛ از عایق صوتی پنجره‌ها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."},
{"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربه‌ای واقعاً حرفه‌ای بود. ایده‌های ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."},
]
TEAM = [
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیان‌گذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
{"name": "نگار شریفی", "name_en": "Negar Sharifi", "role": "مدیر ارشد طراحی", "role_en": "Chief Design Officer", "bio": "معمار ارشد، فارغ‌التحصیل دانشگاه تهران و میلان.", "bio_en": "Senior architect, graduate of the University of Tehran and Milan."},
{"name": "بهزاد مرادی", "name_en": "Behzad Moradi", "role": "مدیر اجرایی پروژه‌ها", "role_en": "Director of Project Execution", "bio": "مسئول کنترل کیفیت و زمان‌بندی همه کارگاه‌ها.", "bio_en": "Responsible for quality control and scheduling across all sites."},
{"name": "الهام نیک‌آیین", "name_en": "Elham Nikaein", "role": "مدیر سرمایه‌گذاری", "role_en": "Investment Director", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.", "bio_en": "Designs partnership structures and analyzes property returns."},
]
ARTICLES = [
{
"slug": "natural-light",
"title": "معماری آرام: چرا نور طبیعی گران‌ترین مصالح ساختمان است؟",
"title_en": "Quiet Architecture: Why Is Natural Light the Most Expensive Building Material?",
"date": "۲۴ مرداد ۱۴۰۳",
"date_en": "August 14, 2024",
"category": "معماری",
"category_en": "Architecture",
"read": "۶ دقیقه",
"read_en": "6 min read",
"excerpt": "نور طبیعی نه‌تنها کیفیت زندگی در فضا را تعیین می‌کند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر می‌گذارد.",
"excerpt_en": "Natural light not only defines the quality of life in a space — it directly affects energy consumption and the long-term value of the property.",
"body": [],
"body_en": [
"In the construction market, a unit's value is usually measured in square meters. But our experience over the past twenty years shows that the quality of light is more decisive than a few extra meters.",
"Building orientation, bay depth and floor-to-ceiling height are the three main variables of natural light. The decision on all three is made before the first plan is drawn — and is hardly reversible afterwards.",
"In Aramesh Elahieh Tower, rotating the building mass 12 degrees off north added two hours of useful winter light to the units — a cost-free decision with a decades-long effect.",
"Our advice for choosing a unit is simple: visit at mid-morning and late afternoon, and judge the quality of light at exactly the hours you are actually home.",
],
},
{
"slug": "real-estate-portfolio",
"title": "راهنمای انتخاب سبد سرمایه‌گذاری ملکی در بازار امروز",
"title_en": "A Guide to Building a Real Estate Portfolio in Today's Market",
"date": "۱۰ مرداد ۱۴۰۳",
"date_en": "July 31, 2024",
"category": "سرمایه‌گذاری",
"category_en": "Investment",
"read": "۹ دقیقه",
"read_en": "9 min read",
"excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.",
"excerpt_en": "Three key criteria for building a balanced property portfolio, based on your time horizon and risk appetite.",
"body": [],
"body_en": [
"A balanced property portfolio mixes assets with different liquidity — not all capital in one long-term project, not all in ready units.",
"Time horizon is the first criterion. If your plan is shorter than two years, pre-purchasing in a project that has just started is a risky choice.",
"The second criterion is reporting transparency. Without monthly reports of physical and financial progress, assessing the state of your capital is practically impossible.",
"The third criterion is diversity of use. A residential–commercial mix measurably reduces the volatility of portfolio returns in downturns.",
],
},
{
"slug": "facade-quality",
"title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص می‌کند",
"title_en": "Five Technical Details That Reveal Facade Quality After Ten Years",
"date": "۲۸ تیر ۱۴۰۳",
"date_en": "July 18, 2024",
"category": "اجرا",
"category_en": "Construction",
"read": "۵ دقیقه",
"read_en": "5 min read",
"excerpt": "نمای ساختمان در سال اول زیبا به نظر می‌رسد؛ تفاوت کیفیت اجرا در سال دهم آشکار می‌شود.",
"excerpt_en": "A facade looks beautiful in its first year; the difference in workmanship shows in its tenth.",
"body": [],
"body_en": [
"Mechanical fixings of facade stone matter more than the stone itself. Adhesive fixing has a short life in Iran's hot-and-cold climate.",
"Contraction joints must be designed on the drawing, not decided on site — their absence is the first cause of horizontal facade cracks.",
"Window perimeter waterproofing is the most frequent point of failure. Installing waterproofing tape before the sill goes in removes that risk.",
"Hot-dip galvanized sub-structures cost marginally more than painted ones — and last many times longer.",
"Finally, take the annual facade inspection seriously: repairing one joint costs next to nothing compared with redoing the whole facade.",
],
},
{
"slug": "commercial-yield",
"title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟",
"title_en": "How to Estimate a Commercial Project's Yield Before Building It",
"date": "۱۵ تیر ۱۴۰۳",
"date_en": "July 5, 2024",
"category": "سرمایه‌گذاری",
"category_en": "Investment",
"read": "۷ دقیقه",
"read_en": "7 min read",
"excerpt": "مدل ساده‌ای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.",
"excerpt_en": "A simple yield-estimation model based on visitor flow, unit mix and local rental rates.",
"body": [],
"body_en": [
"Estimating commercial yield starts with three numbers: daily visitor flow, the rental rate per square meter in the area, and the net-to-gross floor ratio.",
"A common mistake is ignoring the upper levels. Without a well-designed vertical path, levels three and above usually fail to reach even half of the ground-floor rate.",
"Finally, write the conservative scenario too — if the project still makes sense in the pessimistic case, the investment is a rational one.",
],
},
{
"slug": "facade-materials",
"title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران",
"title_en": "Facade Materials: Stone, Composite and Thermowood in Iran's Climate",
"date": "۲ تیر ۱۴۰۳",
"date_en": "June 22, 2024",
"category": "اجرا",
"category_en": "Construction",
"read": "۸ دقیقه",
"read_en": "8 min read",
"excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار می‌کند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر می‌کند.",
"excerpt_en": "Every facade material works in the right climate; the wrong choice multiplies maintenance costs.",
"body": [],
"body_en": [
"In a dry climate, stone has the best durability — but its weight demands a precise sub-structure and structural calculations.",
"Composite is the fast, light choice, provided you pick a fire-rated grade and account for thermal expansion along the sheet.",
"In the humid northern climate, thermowood is the best aesthetic answer — with a twice-yearly maintenance schedule.",
],
},
{
"slug": "renovate-or-rebuild",
"title": "بازسازی یا نوسازی؟ معیارهای تصمیم‌گیری برای مالکان",
"title_en": "Renovate or Rebuild? Decision Criteria for Property Owners",
"date": "۲۰ خرداد ۱۴۰۳",
"date_en": "June 9, 2024",
"category": "معماری",
"category_en": "Architecture",
"read": "۶ دقیقه",
"read_en": "6 min read",
"excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی می‌تواند بازدهی بالاتری از تخریب کامل داشته باشد.",
"excerpt_en": "If the structure's age and city regulations allow, renovation can outperform full demolition and rebuild.",
"body": [],
"body_en": [
"First the structure is assessed: if seismic retrofitting costs more than 40% of a new build, rebuilding is the more rational option.",
"The second criterion is today's urban regulations. Sometimes demolition grants you less permissible floor area — which changes the decision entirely.",
"In renovation, the greatest added value comes from three places: the facade, the entrance and the re-planning of the interior.",
],
},
]
FAQS = [
{"question": "حداقل سرمایه برای مشارکت در پروژه‌های گروه ساختمانی آرامش چقدر است؟", "question_en": "What is the minimum capital to join Aramesh Construction Group projects?", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایه‌گذاری از سطح مشخصی آغاز می‌شوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه می‌شود.", "answer_en": "Each project's partnership structure is different, but investment units usually start from a defined level, and the exact details are presented in the initial assessment meeting."},
{"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "question_en": "How long does it take to build a residential tower?", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.", "answer_en": "Depending on the number of floors and site conditions, a residential tower takes on average 28 to 42 months from permit issuance to key handover."},
{"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "question_en": "Can the unit's interior layout be customized?", "answer": "بله. تا پیش از پایان مرحله سفت‌کاری، امکان شخصی‌سازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.", "answer_en": "Yes. Until the structural phase is complete, the interior plan can be customized in coordination with the design team and structural approval."},
{"question": "خدمات پس از تحویل شامل چه مواردی می‌شود؟", "question_en": "What does the after-sales service include?", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دوره‌ای سالانه و تضمین اجرای نما و عایق‌کاری.", "answer_en": "Five years of technical support for mechanical and electrical systems, an annual inspection, and warranty on facade work and insulation."},
{"question": "پروژه‌ها در چه شهرهایی اجرا می‌شوند؟", "question_en": "In which cities do you build?", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است.", "answer_en": "Tehran, Isfahan and the northern coasts are currently our main regions, and a project is also under construction on Kish Island."},
]
NAV_ITEMS = [
{"label": "صفحه اصلی", "label_en": "Home", "href": "/"},
{"label": "درباره ما", "label_en": "About", "href": "/about"},
{"label": "خدمات", "label_en": "Services", "href": "/services"},
{"label": "پروژه‌ها", "label_en": "Projects", "href": "/projects"},
{"label": "سرمایه‌گذاری", "label_en": "Investment", "href": "/investment"},
{"label": "وبلاگ", "label_en": "Blog", "href": "/blog"},
{"label": "سوالات متداول", "label_en": "FAQ", "href": "/faq"},
{"label": "تماس با ما", "label_en": "Contact", "href": "/contact"},
]
HERO = {
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
"kicker_en": "Since 2004 — Tehran, Isfahan, Northern Coasts",
"title": "ساختمانی که آرامش را معماری می‌کند",
"title_en": "A building that architects tranquility",
"title_highlight": "معماری",
"title_highlight_en": "architects",
"subtitle": "طراحی، ساخت و سرمایه‌گذاری در پروژه‌های مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده می‌شود و در سال‌ها دوام می‌آورد.",
"subtitle_en": "Design, construction and investment in luxury residential and commercial projects — to a standard you can see in the details and trust for years.",
"badge_value": "۴۷",
"badge_value_en": "47",
"badge_label": "پروژه تحویل‌شده در بیست‌ویک سال فعالیت حرفه‌ای",
"badge_label_en": "Projects delivered in twenty-one years of professional work",
"cta_primary_label": "مشاهده پروژه‌ها",
"cta_primary_label_en": "View Projects",
"cta_secondary_label": "مشاوره سرمایه‌گذاری",
"cta_secondary_label_en": "Investment Advisory",
"scroll_label": "اسکرول کنید",
"scroll_label_en": "Scroll",
}
STORY = {
"title": "درباره ی ما",
"title_en": "About Us",
"paragraphs": [],
"paragraphs_en": [
"Aramesh Construction Group began as a small architecture office and is today one of Iran's recognized names in luxury construction. Through all these years one principle has not changed: a building must be made for living, not for a quick sale.",
"Every project starts with a study of the land, light, climate and the residents' way of life; then the design, structural and execution teams carry it through a single process to key handover.",
],
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی می‌سازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
"quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d",
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
"quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group",
"stat1_value": "۹۴٪",
"stat1_value_en": "94%",
"stat1_label": "تحویل به‌موقع پروژه‌ها",
"stat1_label_en": "On-time project delivery",
"stat2_value": "۵ سال",
"stat2_value_en": "5 years",
"stat2_label": "پشتیبانی پس از تحویل",
"stat2_label_en": "Post-handover support",
"link_label": "داستان کامل ما",
"link_label_en": "Our full story",
"bento": [],
"bento_en": [
{"src": IMAGES[0], "alt": "Facade of an Aramesh residential tower in Tehran"},
{"src": IMAGES[1], "alt": "Central atrium of Parseh Shopping Mall"},
{"src": IMAGES[2], "alt": "Villas built on the northern coasts"},
{"src": IMAGES[3], "alt": "Night view of the Niavaran Twin Towers"},
],
}
SECTION_HEADINGS = [
{"key": "services", "title_en": "Services", "lead_en": "From the first line on paper to key handover, capital management and landscape design — every stage under one roof and one quality-control system."},
{"key": "featured_projects", "title_en": "Featured Projects", "lead_en": "The work that says the most about our standard — both the delivered projects and the ones under construction today.", "cta_label_en": "All Projects"},
{"key": "gallery", "title_en": "Gallery", "lead_en": "Frames from the sites and delivered projects of Aramesh Construction Group."},
{"key": "timeline", "title_en": "Our Path", "lead_en": "From a four-person office to 47 projects. Walk through the timeline of our work."},
{"key": "why_us", "title_en": "Why Aramesh Construction Group", "lead_en": "Six reasons clients and investors come back to us a second time."},
{"key": "testimonials", "title_en": "Client Testimonials"},
{"key": "team", "title_en": "Our Team", "lead_en": "The people who make each project's key decisions — and answer for them."},
{"key": "articles", "label_en": "Blog", "title_en": "Latest Articles", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market.", "cta_label_en": "All Articles"},
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Short answers to the questions investors and buyers ask most."},
{"key": "investment_models", "title_en": "Partnership Models", "lead_en": "Depending on your capital, time horizon and risk appetite, one of these three paths is right for you."},
{"key": "process", "title_en": "How We Work", "lead_en": "Four transparent steps, from the first meeting to key handover."},
]
PAGE_HEROES = [
{"key": "about", "title_en": "About Us", "lead_en": "Aramesh Construction Group is a team of architects, structural engineers and project managers working in luxury construction since 2004."},
{"key": "services", "title_en": "Services", "lead_en": "Eight specialized fields covering a building project's full cycle, from idea to operation."},
{"key": "projects", "title_en": "Projects", "lead_en": "A collection of residential towers, commercial centers and private villas — from delivered projects to today's active sites."},
{"key": "investment", "title_en": "Investment", "lead_en": "Three clear paths into the luxury construction market, with transparent financial structures and regular reporting."},
{"key": "blog", "title_en": "Blog", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market."},
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Before you call, the answer to your question might be right here. If not, we would be glad to talk directly."},
{"key": "contact", "title_en": "Contact Us", "lead_en": "For construction consultancy, investment or a project visit, our teams in three cities are ready to respond."},
]
INVESTMENT_MODELS = [
{
"title": "مشارکت در ساخت",
"title_en": "Joint Construction Partnership",
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک می‌شوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
"desc_en": "The landowner and Aramesh share the project's profit; execution, permits and sales are on us.",
"points": [],
"points_en": ["Free land assessment", "Transparent contract with performance guarantee", "Monthly progress reporting"],
},
{
"title": "صندوق پروژه‌محور",
"title_en": "Project-Based Fund",
"desc": "سرمایه‌گذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبه‌شده در پایان دوره.",
"desc_en": "Investment in a specific project with purchasable units and a calculated yield at the end of the term.",
"points": [],
"points_en": ["Minimum 24-month term", "Quarterly valuation reports", "Investment units are transferable"],
},
{
"title": "پیش‌خرید واحد",
"title_en": "Pre-Purchase",
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحله‌ای.",
"desc_en": "Buying a residential or commercial unit in the early stages of construction, at a pre-completion price with staged payments.",
"points": [],
"points_en": ["Payments staged to match progress", "Interior layout customization", "Delivery-time guarantee"],
},
]
PROCESS_STEPS = [
{"step": "۰۱", "step_en": "01", "title": "مطالعه و امکان‌سنجی", "title_en": "Study & Feasibility", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.", "desc_en": "Site review, urban regulations and an economic yield analysis of the project."},
{"step": "۰۲", "step_en": "02", "title": "طراحی و فاز اجرایی", "title_en": "Design & Construction Drawings", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشه‌های اجرایی.", "desc_en": "Architectural concept, structural and MEP design, and construction documentation."},
{"step": "۰۳", "step_en": "03", "title": "اجرا و کنترل کیفیت", "title_en": "Execution & Quality Control", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.", "desc_en": "Construction with continuous supervision, monthly progress reports and cost control."},
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
]
OFFICES = [
{"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"},
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
{"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"},
]
GALLERY_IMAGES = [
{"src": IMAGES[0], "alt_en": "Aramesh Construction Group project, No. 1"},
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 2"},
{"src": IMAGES[2], "alt_en": "Aramesh Construction Group project, No. 3"},
{"src": IMAGES[3], "alt_en": "Aramesh Construction Group project, No. 4"},
{"src": IMAGES[4], "alt_en": "Aramesh Construction Group project, No. 5"},
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 6"},
]
STATUS_LABELS = [
{"key": "done", "label_en": "Completed"},
{"key": "progress", "label_en": "In Progress"},
]
CONTACT_SECTION = {
"label": "تماس با ما",
"label_en": "Contact Us",
"title": "پروژه‌تان را با ما در میان بگذارید",
"title_en": "Tell Us About Your Project",
"lead": "شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.",
"lead_en": "Leave your number — our expert will call you within one business day for a free consultation session.",
"address_label": "دفتر مرکزی",
"address_label_en": "Head Office",
"phone_label": "تلفن",
"phone_label_en": "Phone",
"email_label": "ایمیل",
"email_label_en": "Email",
"name_label": "نام",
"name_label_en": "Name",
"name_placeholder": "نام و نام خانوادگی",
"name_placeholder_en": "Full name",
"phone_field_label": "شماره تلفن",
"phone_field_label_en": "Phone number",
"note_label": "توضیحات",
"note_label_en": "Details",
"note_optional_label": "(اختیاری)",
"note_optional_label_en": "(optional)",
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
"note_placeholder_en": "Tell us about your project or request…",
"submit_label": "ارسال درخواست مشاوره",
"submit_label_en": "Request a Consultation",
"sending_label": "در حال ارسال…",
"sending_label_en": "Sending…",
"success_title": "درخواست شما ثبت شد",
"success_title_en": "Your Request Was Received",
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما به‌زودی با شما تماس می‌گیرند.",
"success_text_en": "Thank you for your trust. Our experts will contact you shortly.",
"success_button_label": "ثبت درخواست جدید",
"success_button_label_en": "Submit Another Request",
}
NEWSLETTER = {
"title": "خبرنامه گروه ساختمانی آرامش",
"title_en": "Aramesh Construction Group Newsletter",
"lead": "هر ماه یک ایمیل: پروژه‌های جدید، فرصت‌های سرمایه‌گذاری و یادداشت‌های تیم معماری.",
"lead_en": "One email a month: new projects, investment opportunities and notes from the architecture team.",
"placeholder": "نشانی ایمیل شما",
"placeholder_en": "Your email address",
"button_label": "عضویت",
"button_label_en": "Subscribe",
"sending_label": "در حال ارسال…",
"sending_label_en": "Sending…",
"success_text": "عضویت شما ثبت شد.",
"success_text_en": "Your subscription has been registered.",
}
FOOTER = {
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمان‌هایی می‌سازد که قرار است چند نسل در آن‌ها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایه‌گذاری شفاف.",
"about_text_en": "Since 2004, Aramesh Construction Group has been building for generations to come: calm architecture, precise execution and transparent investment.",
"pages_heading": "صفحات",
"pages_heading_en": "Pages",
"services_heading": "خدمات",
"services_heading_en": "Services",
"contact_heading": "ارتباط با ما",
"contact_heading_en": "Contact",
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
"copyright_en": "© 2025 Aramesh Construction Group — All rights reserved.",
"tagline": "طراحی و ساخت با دقتِ معماری",
"tagline_en": "Designed and built with architectural precision",
"socials_en": [
{"label": "Instagram", "href": "#", "icon": "instagram"},
{"label": "LinkedIn", "href": "#", "icon": "linkedin"},
{"label": "Telegram", "href": "#", "icon": "telegram"},
],
}
SEO_META = [
{"page_key": "home", "title_en": "Aramesh Construction Group | Luxury Residential & Commercial Buildings", "description_en": "Design, construction and investment in luxury residential and commercial projects in Iran — 21 years of experience, 47 delivered projects."},
{"page_key": "about", "title_en": "About Us | Aramesh Construction Group", "description_en": "Architects, structural engineers and project managers in luxury construction since 2004."},
{"page_key": "services", "title_en": "Services | Aramesh Construction Group", "description_en": "Architectural design, high-rise construction, commercial complexes, villas, renovation and project management."},
{"page_key": "projects", "title_en": "Projects | Aramesh Construction Group", "description_en": "Residential towers, commercial centers and private villas — delivered projects and active sites."},
{"page_key": "investment", "title_en": "Investment | Aramesh Construction Group", "description_en": "Three transparent paths into the luxury construction market: joint construction, project-based funds and pre-purchase."},
{"page_key": "blog", "title_en": "Blog | Aramesh Construction Group", "description_en": "Articles on architecture, construction and real estate investment from the Aramesh team."},
{"page_key": "faq", "title_en": "FAQ | Aramesh Construction Group", "description_en": "Short answers to the most frequent questions from investors and buyers."},
{"page_key": "contact", "title_en": "Contact Us | Aramesh Construction Group", "description_en": "Get in touch for construction consultancy, investment or a project visit — offices in Tehran, Isfahan and Nowshahr."},
]
+225 -28
View File
@@ -2,8 +2,11 @@
فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند.
این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق می‌کند —
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند —
بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند.
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو می‌شود؛
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی می‌شوند.
"""
import html
@@ -22,6 +25,10 @@ from .database import get_db
router = APIRouter()
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
# صفحاتی که متای آن‌ها از seo_meta خوانده می‌شود — کلید همان page_key است
META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
FALLBACK_FA_BRAND = "گروه ساختمانی آرامش"
FALLBACK_EN_BRAND = "Aramesh Construction Group"
def _absolute(path: str) -> str:
@@ -33,6 +40,29 @@ def _absolute(path: str) -> str:
return f"{site}{path}" if site else path
def _en_path(path: str) -> str:
"""مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند."""
return "/en" if path == "/" else f"/en{path}"
def _fa_path(path: str) -> str:
"""مسیر فارسی معادل یک مسیر انگلیسی."""
if path == "/en":
return "/"
return path[3:] if path.startswith("/en/") else path
def _alternates(path: str, lang: str) -> str:
"""تگ‌های hreflang متقابل فارسی/انگلیسی برای head."""
fa = path if lang == "fa" else _fa_path(path)
en = _en_path(path) if lang == "fa" else path
return (
f'<link rel="alternate" hreflang="fa" href="{html.escape(_absolute(fa))}" />\n'
f' <link rel="alternate" hreflang="en" href="{html.escape(_absolute(en))}" />\n'
f' <link rel="alternate" hreflang="x-default" href="{html.escape(_absolute(fa))}" />'
)
def _read_index() -> str | None:
try:
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
@@ -64,17 +94,19 @@ def _shell(
title: str,
description: str,
path: str,
lang: str = "fa",
og_image: str = "",
og_type: str = "website",
jsonld: dict | None = None,
noscript: str = "",
) -> HTMLResponse:
"""index.html فرانت‌اند با متای تزریق‌شده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
title = title.strip() or "گروه ساختمانی آرامش"
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
description = description.strip()
canonical = _absolute(path)
image = _absolute(og_image)
esc = html.escape
alternates = _alternates(path, lang)
# تگ title جای تگ موجود index.html را می‌گیرد و در بلوک تکرار نمی‌شود
tags = [
@@ -83,6 +115,7 @@ def _shell(
f'<meta property="og:description" content="{esc(description)}" />',
f'<meta property="og:type" content="{esc(og_type)}" />',
f'<meta property="og:url" content="{esc(canonical)}" />',
f'<meta property="og:locale" content="{"en_US" if lang == "en" else "fa_IR"}" />',
f'<meta name="twitter:card" content="summary_large_image" />',
]
if image:
@@ -95,28 +128,61 @@ def _shell(
raw = _read_index()
if raw is None:
# بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند
lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"'
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
return HTMLResponse(
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>"
)
if lang == "en":
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
# description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
# og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود
og_locale = "en_US" if lang == "en" else "fa_IR"
if re.search(r'<meta property="og:locale"', raw):
raw = re.sub(
r'(<meta property="og:locale" content=")[^"]*(")',
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
raw,
count=1,
)
tags = [t for t in tags if 'og:locale' not in t]
block = "\n ".join(tags)
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
raw = raw.replace("</head>", f" {block}\n </head>", 1)
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
if noscript:
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
return HTMLResponse(raw)
def _brand_name(db: Session) -> str:
def _brand_name(db: Session, lang: str = "fa") -> str:
brand = db.query(models.Brand).first()
return brand.name if brand else "گروه ساختمانی آرامش"
if brand is None:
return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND
if lang == "en":
return brand.name_en or brand.name or FALLBACK_EN_BRAND
return brand.name or FALLBACK_FA_BRAND
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
"""عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی."""
meta = _page_meta(db, page_key)
if meta is None:
return "", ""
if lang == "en":
return meta.title_en or meta.title or "", meta.description_en or meta.description or ""
return meta.title or "", meta.description or ""
# ---------- robots.txt و sitemap.xml ----------
@@ -128,26 +194,35 @@ def robots(db: Session = Depends(get_db)):
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
def _sitemap_entry(loc: str, fa: str, en: str) -> str:
esc = html.escape
return (
f" <url><loc>{esc(_absolute(loc))}</loc>\n"
f' <xhtml:link rel="alternate" hreflang="fa" href="{esc(_absolute(fa))}" />\n'
f' <xhtml:link rel="alternate" hreflang="en" href="{esc(_absolute(en))}" />\n'
f" </url>"
)
@router.get("/sitemap.xml", response_class=Response)
def sitemap(db: Session = Depends(get_db)):
urls = list(STATIC_PATHS)
urls += [
fa_urls = list(STATIC_PATHS)
fa_urls += [
f"/blog/{a.slug}"
for a in db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
]
urls += [
fa_urls += [
f"/projects/{p.slug}"
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
]
items = "\n".join(
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
)
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
f"{items}\n</urlset>\n"
)
return Response(content=xml, media_type="application/xml; charset=utf-8")
@@ -163,10 +238,16 @@ def blog_list(db: Session = Depends(get_db)):
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
description=meta.description if meta else "",
path="/blog",
og_image=meta.image if meta else "",
)
def _article_pair(article: models.Article, lang: str) -> tuple[str, str]:
"""عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی."""
if lang == "en":
return article.title_en or article.title, article.excerpt_en or article.excerpt
return article.title, article.excerpt
@router.get("/blog/{slug}", response_class=HTMLResponse)
def blog_detail(slug: str, db: Session = Depends(get_db)):
article = (
@@ -179,22 +260,23 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
brand = _brand_name(db)
title, excerpt = _article_pair(article, "fa")
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
jsonld = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.title,
"description": article.excerpt,
"headline": title,
"description": excerpt,
"image": _absolute(article.image),
"inLanguage": "fa",
"author": {"@type": "Organization", "name": brand},
"publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
}
noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{article.title} | {brand}",
description=article.excerpt,
title=f"{title} | {brand}",
description=excerpt,
path=f"/blog/{article.slug}",
og_image=article.image,
og_type="article",
@@ -223,17 +305,132 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
brand = _brand_name(db)
jsonld = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": project.title,
"description": project.summary,
"image": _absolute(project.image),
"inLanguage": "fa",
}
return _shell(
title=f"{project.title} | {brand}",
description=project.summary,
path=f"/projects/{project.slug}",
og_image=project.image,
jsonld={
"@context": "https://schema.org",
"@type": "WebPage",
"name": project.title,
"description": project.summary,
"image": _absolute(project.image),
"inLanguage": "fa",
},
jsonld=jsonld,
)
# ---------- صفحات انگلیسی (/en/...) ----------
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
brand = _brand_name(db, "en")
title, excerpt = _article_pair(article, "en")
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])
jsonld = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": excerpt,
"image": _absolute(article.image),
"inLanguage": "en",
"author": {"@type": "Organization", "name": brand},
"publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
}
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{title} | {brand}",
description=excerpt,
path=f"/en/blog/{article.slug}",
lang="en",
og_image=article.image,
og_type="article",
jsonld=jsonld,
noscript=noscript,
)
@router.get("/en", response_class=HTMLResponse)
@router.get("/en/", response_class=HTMLResponse)
def en_home(db: Session = Depends(get_db)):
title, description = _meta_for(db, "home", "en")
if not title:
title = _brand_name(db, "en")
return _shell(title=title, description=description, path="/en", lang="en")
@router.get("/en/blog", response_class=HTMLResponse)
def en_blog_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "blog")
title = meta.title_en or meta.title if meta else ""
description = meta.description_en or meta.description if meta else ""
if not title:
title = "Blog | " + _brand_name(db, "en")
return _shell(title=title, description=description, path="/en/blog", lang="en")
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
def en_blog_detail(slug: str, db: Session = Depends(get_db)):
article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en")
return _en_blog_detail(article, db)
@router.get("/en/projects", response_class=HTMLResponse)
def en_projects_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "projects")
title = meta.title_en or meta.title if meta else ""
description = meta.description_en or meta.description if meta else ""
if not title:
title = "Projects | " + _brand_name(db, "en")
return _shell(title=title, description=description, path="/en/projects", lang="en")
@router.get("/en/projects/{slug}", response_class=HTMLResponse)
def en_project_detail(slug: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.slug == slug).first()
if project is None:
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en")
brand = _brand_name(db, "en")
title = project.title_en or project.title
summary = project.summary_en or project.summary
jsonld = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": title,
"description": summary,
"image": _absolute(project.image),
"inLanguage": "en",
}
return _shell(
title=f"{title} | {brand}",
description=summary,
path=f"/en/projects/{project.slug}",
lang="en",
og_image=project.image,
jsonld=jsonld,
)
@router.get("/en/{path:path}", response_class=HTMLResponse)
def en_page(path: str, db: Session = Depends(get_db)):
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی."""
page_key = path.split("/")[0] if path else ""
title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "")
brand = _brand_name(db, "en")
if not title:
# عنوان پیش‌فرض از برچسب ناوبری انگلیسی همان صفحه
nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None
label = (nav.label_en or nav.label) if nav else ""
title = f"{label} | {brand}" if label else brand
return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")
+88
View File
@@ -180,3 +180,91 @@ def test_head_not_injected_for_draft(client):
def test_head_replaces_title_once(client):
res = client.get("/blog/published-post")
assert len(re.findall(r"<title>", res.text)) == 1
# ---------- دوزبانه ----------
def test_bilingual_project_and_en_seo_shell(auth_client, client):
created = auth_client.post(
"/api/admin/projects",
json={
"slug": "bilingual-tower",
"title": "برج دوزبانه",
"title_en": "Bilingual Tower",
"summary": "خلاصه فارسی",
"summary_en": "English summary",
"body": [],
"body_en": ["English paragraph"],
},
)
assert created.status_code == 201, created.text
pid = created.json()["id"]
# GET /api/site هر دو زبان را برمی‌گرداند
project = next(p for p in client.get("/api/site").json()["projects"] if p["slug"] == "bilingual-tower")
assert project["title"] == "برج دوزبانه"
assert project["title_en"] == "Bilingual Tower"
assert project["body_en"] == ["English paragraph"]
# پوسته سئوی انگلیسی — جهت و متای انگلیسی و hreflang متقابل
res = client.get("/en/projects/bilingual-tower")
assert res.status_code == 200
assert 'lang="en" dir="ltr"' in res.text
assert "Bilingual Tower" in res.text
assert "English summary" in res.text
assert 'hreflang="fa"' in res.text
assert "https://test.example/projects/bilingual-tower" in res.text
assert "https://test.example/en/projects/bilingual-tower" in res.text
# sitemap دو زبانه با alternates
sm = client.get("/sitemap.xml").text
assert "xmlns:xhtml" in sm
assert 'hreflang="en"' in sm
assert "/en/projects/bilingual-tower" in sm
# پوسته فارسی هم alternates دارد
assert 'hreflang="en"' in client.get("/blog/published-post").text
# صفحه استاتیک انگلیسی از مسیر catch-all سرو می‌شود
res = client.get("/en/about")
assert res.status_code == 200
assert 'lang="en"' in res.text
assert auth_client.delete(f"/api/admin/projects/{pid}").status_code == 204
assert "bilingual-tower" not in client.get("/sitemap.xml").text
def test_bilingual_article_en_shell(auth_client, client):
articles = auth_client.get("/api/admin/articles").json()
article = next(a for a in articles if a["slug"] == "published-post")
res = auth_client.patch(
f"/api/admin/articles/{article['id']}",
json={
"title_en": "Published EN",
"excerpt_en": "English excerpt",
"body_en": DOC,
},
)
assert res.status_code == 200, res.text
res = client.get("/en/blog/published-post")
assert res.status_code == 200
assert "Published EN" in res.text
assert "English excerpt" in res.text
assert '"inLanguage": "en"' in res.text
def test_en_backfill_fills_only_empty_fields(auth_client, client):
"""backfill ترجمه‌های خالی را پر می‌کند و مقدار ثبت‌شده را بازنویسی نمی‌کند."""
from app.migrations import backfill_en_content
backfill_en_content()
brand = client.get("/api/site").json()["brand"]
assert brand["name_en"] == "Aramesh Construction Group"
# مقدار دستیِ ادمین محترم شمرده می‌شود
res = auth_client.patch(f"/api/admin/brand/{brand['id']}", json={"name_en": "Custom EN Name"})
assert res.status_code == 200
backfill_en_content()
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
+1 -1
View File
@@ -35,7 +35,7 @@ services:
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || 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.tls.certresolver=le
+4 -1
View File
@@ -10,6 +10,8 @@ services:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
# فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80
ports:
- "${TRAEFIK_HTTP_PORT:-5173}:80"
@@ -122,7 +124,8 @@ services:
- traefik.http.routers.api.priority=100
- traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
# مسیرهای /en/... هم همه به بک‌اند می‌روند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
- traefik.http.routers.seo.entrypoints=web
- traefik.http.routers.seo.priority=50
- traefik.http.routers.seo.service=api
File diff suppressed because it is too large Load Diff
+30 -7
View File
@@ -3,20 +3,37 @@
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
## Typography
## Typography & Smart Bidi Text
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
## Recent UI/UX Improvements
* **Projects Map Restoration**: Successfully re-integrated and mounted the interactive project overview map directly at the top of the Projects page to display all project markers.
* **Project Details Update**: Revamped and updated the presentation of project details for better clarity and structure.
* **Stats Section Relocation**: Moved the statistics (metrics) section higher up in the page layout for better visibility and impact.
* **FAQ Section Redesign**: Refactored the FAQ layout into a centered vertical stack with full RTL/LTR responsiveness, featuring an integrated centered header block (title, description, and accent line) and direction-aware (`text-start`) accordion content.
* **Newsletter Section Polish**: Updated the newsletter container to a clean solid `bg-copper` background while introducing high-contrast button styling (`bg-ink` default transitioning to white on hover) preserving site-wide kinetic animation logic.
* **Article Cards Spacing**: Refined vertical layout spacing and responsive gaps between title, excerpt, and footer blocks without impacting typography scales.
* **Tiptap Editor Fixes**:
* Resolved the initialization bug where the editor body remained empty during edit mode.
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
* **Dynamic TOC & Layout**:
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
## CMS, SEO & Editor Features
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities:
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
* **Advanced SEO Management:** Dynamic `meta_description` fields (150-160 characters) and fully managed Image `Alt` texts (with auto-fallback to titles/filenames) ensure high search engine visibility.
* **Tags & Categorization:** Integrated `react-select` for modern category selection and creatable dynamic tags.
* **Article Metadata UI:** A beautifully designed metadata component displaying the author's avatar, name, and publication date right below the H1 title.
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
* **Custom Tiptap WYSIWYG Editor:**
* Specific block-level toggling for Headings (H1: 18px, H2: 16px, H3: 14px) and Paragraphs (14px).
* A custom feature to wrap English text blocks in LTR styling (`<span dir="ltr" class="english-text">` at 12px) for perfect bi-directional text alignment.
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
* **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
@@ -50,4 +67,10 @@ npm run build
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
* `src/data/site.ts` — Shared TypeScript data models and interfaces.
```
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، می‌توانید از این کامیت یک‌خطی استفاده کنید:
```bash
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
+8
View File
@@ -16,6 +16,14 @@
</head>
<body>
<div id="root"></div>
<script>
// زبان و جهت سند پیش از اولین رندر، از خود مسیر — /en/... انگلیسی و LTR است
// تا جهت صفحه از نخستین فریم درست باشد (نسخه انگلیسی سمت سرور هم همین را تزریق می‌کند)
if (location.pathname === '/en' || location.pathname.startsWith('/en/')) {
document.documentElement.setAttribute('lang', 'en');
document.documentElement.setAttribute('dir', 'ltr');
}
</script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+271
View File
@@ -17,8 +17,12 @@
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"maplibre-gl": "^6.10.0",
"pmtiles": "^4.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
@@ -1040,6 +1044,92 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.4",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1972,6 +2062,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2449,6 +2545,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2782,6 +2887,12 @@
"csstype": "^3.0.2"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/electron-to-chromium": {
"version": "1.5.407",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
@@ -3156,6 +3267,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -3309,6 +3426,12 @@
"node": ">=6.9.0"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -3636,6 +3759,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -3649,6 +3778,12 @@
"node": ">=6"
}
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3752,6 +3887,39 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/maplibre-gl": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
"@maplibre/mlt": "^1.3.0",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"bidi-js": "^1.1.0",
"earcut": "^3.2.3",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -3795,6 +3963,15 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
@@ -3816,6 +3993,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -4041,6 +4224,18 @@
"node": ">=8"
}
},
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4080,6 +4275,15 @@
"node": ">= 6"
}
},
"node_modules/pmtiles": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
"license": "BSD-3-Clause",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -4252,6 +4456,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -4412,6 +4622,12 @@
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4443,6 +4659,12 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4484,6 +4706,31 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/react-hook-form": {
"version": "7.88.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.88.0.tgz",
"integrity": "sha512-QRaLOWhX93YCnMiRfnOFRSwWXZNt8qhm2JTZwypoDvKpSffTJHmpzMXt8U6PV5UThvL3IiiLDUWe2nHMtz6Mmw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -4606,6 +4853,15 @@
"node": ">=8.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -4636,6 +4892,15 @@
"node": ">=4"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -5029,6 +5294,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+5
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "npx vite",
"build": "npx vite build",
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"preview": "npx vite preview"
},
@@ -19,8 +20,12 @@
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"maplibre-gl": "^6.10.0",
"pmtiles": "^4.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
+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
})
+45 -26
View File
@@ -15,12 +15,35 @@ import { Blog } from './pages/Blog';
import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData } from './api';
import { SiteProvider, useSiteData, useSite } from './api';
import { applySeo, seoKeyForPath } from './seo';
import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage';
import { ArticleWritePage } from './admin/ArticleWritePage';
import { LangProvider, useLang } from './i18n/lang';
// مسیرهای عمومی یک بار تعریف می‌شوند و دو بار سرو می‌شوند: ریشه فارسی و /en انگلیسی
const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
{ path: '/', element: <Home /> },
{ path: '/about', element: <About /> },
{ path: '/services', element: <ServicesPage /> },
{ path: '/projects', element: <Projects /> },
{ path: '/projects/:slug', element: <ProjectDetail /> },
{ path: '/investment', element: <Investment /> },
{ path: '/blog', element: <Blog /> },
{ path: '/blog/:slug', element: <ArticleDetail /> },
{ path: '/faq', element: <FaqPage /> },
{ path: '/contact', element: <ContactPage /> },
];
// فرزند مستقیم <Routes> باید <Route> باشد — آرایه‌های Route به‌جای کامپوننت واسط
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
<Route key={path} path={path} element={element} />
));
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
));
function ScrollToTop() {
const { pathname } = useLocation();
@@ -45,17 +68,9 @@ function AnimatedRoutes() {
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:slug" element={<ProjectDetail />} />
<Route path="/investment" element={<Investment />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<ArticleDetail />} />
<Route path="/faq" element={<FaqPage />} />
<Route path="/contact" element={<ContactPage />} />
{FA_ROUTES}
<Route path="*" element={<Home />} />
{EN_ROUTES}
</Routes>
</motion.main>
</AnimatePresence>);
@@ -65,12 +80,13 @@ function AnimatedRoutes() {
function AppChrome() {
const { pathname } = useLocation();
const { isAuthenticated } = useAdmin();
const { data } = useSiteData();
const data = useSite();
const { lang } = useLang();
const isAdminPage = pathname.startsWith('/admin');
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند
useEffect(() => {
if (isAdminPage || !data) return;
if (isAdminPage) return;
const key = seoKeyForPath(pathname);
if (!key) return;
const meta = data.seo_meta?.find((s) => s.page_key === key);
@@ -80,8 +96,9 @@ function AppChrome() {
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '',
path: pathname,
lang,
});
}, [pathname, data, isAdminPage]);
}, [pathname, data, isAdminPage, lang]);
return (
<>
@@ -96,44 +113,46 @@ function AppChrome() {
function AppShell() {
const { data, error, reload } = useSiteData();
const { t, dir, lang } = useLang();
// overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky
// (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود.
return (
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
<Preloader />
{error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
<h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1>
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
{t('errorText')}
</p>
<button
type="button"
onClick={reload}
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
>
تلاش دوباره
{t('retry')}
</button>
</div>
) : data ? (
<BrowserRouter>
<>
<ScrollToTop />
<AppChrome />
</BrowserRouter>
</>
) : null}
</div>
);
}
export function App() {
useEffect(() => {
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'fa');
}, []);
return (
<SiteProvider>
<AdminProvider>
<AppShell />
<BrowserRouter>
<LangProvider>
<AppShell />
</LangProvider>
</BrowserRouter>
</AdminProvider>
</SiteProvider>
);
+14 -1
View File
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
const [pwOpen, setPwOpen] = useState(false)
return (
@@ -161,6 +161,19 @@ export function AdminBar() {
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
</button>
<button
type="button"
onClick={toggleEditLang}
title="تعیین می‌کند ویرایش‌ها روی کدام ستون زبان ذخیره شوند"
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
editLang === 'en'
? 'border-copper bg-copper/10 text-copper'
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
</button>
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
{STATUS_TEXT[saveStatus]}
+82 -5
View File
@@ -3,9 +3,12 @@ import type { ReactNode } from 'react'
import Swal from 'sweetalert2'
import type { SiteData } from '../api'
import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient'
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
import { ENTITIES, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error'
/** زبان ویرایش محتوا — رابط مدیریتی فارسی می‌ماند؛ این فقط تعیین می‌کند کدام ستون زبان ذخیره شود */
export type EditLang = 'fa' | 'en'
interface AdminContextValue {
isAuthenticated: boolean
@@ -16,6 +19,9 @@ interface AdminContextValue {
saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
runSave: (action: () => Promise<void>) => Promise<boolean>
/** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */
editLang: EditLang
toggleEditLang: () => void
}
const AdminContext = createContext<AdminContextValue | null>(null)
@@ -54,6 +60,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
const [isEditMode, setIsEditMode] = useState<boolean>(
() => sessionStorage.getItem('aramesh_edit_mode') === '1',
)
const [editLang, setEditLang] = useState<EditLang>(
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const { reload } = useSiteData()
@@ -64,6 +73,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
})
}, [])
const toggleEditLang = useCallback(() => {
setEditLang((v) => {
const next: EditLang = v === 'fa' ? 'en' : 'fa'
sessionStorage.setItem('aramesh_edit_lang', next)
return next
})
}, [])
const login = useCallback(async (username: string, password: string) => {
await adminApi.login(username, password)
setIsAuthenticated(true)
@@ -112,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
return () => document.removeEventListener('click', onClick, true)
}, [isEditMode])
// توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه
// دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند
useEffect(() => {
const syncAuth = () => setIsAuthenticated(!!getToken())
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.addEventListener('storage', syncAuth)
return () => {
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.removeEventListener('storage', syncAuth)
}
}, [])
// در حین ذخیره‌سازی، از بسته‌شدن تصادفی صفحه جلوگیری می‌شود
useEffect(() => {
if (saveStatus !== 'saving') return
@@ -131,8 +160,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
logout,
saveStatus,
runSave,
editLang,
toggleEditLang,
}),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
)
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
@@ -141,9 +172,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
/**
* پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت
* همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند.
*
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت می‌شوند؛ چون
* برخی mutatorهای کامپوننت‌ها نام فیلد را جداگانه می‌نویسند، مقدار قبلی فیلدهای
* فارسی اسنپ‌شات می‌شود و پس از اجرای mutator بازگردانده می‌شود تا متن انگلیسی
* هرگز جای متن فارسی در درخت خام ننشیند.
*/
export function useFieldSaver() {
const { runSave } = useAdmin()
const { runSave, editLang } = useAdmin()
const { updateSite } = useSiteData()
return useCallback(
@@ -154,9 +190,50 @@ export function useFieldSaver() {
apply: (data: SiteData) => void,
): Promise<boolean> =>
runSave(async () => {
const originalKeys = Object.keys(changes)
if (editLang === 'en') {
const mapped: Record<string, unknown> = {}
for (const [key, value] of Object.entries(changes)) {
mapped[isTranslatable(resource, key) ? `${key}_en` : key] = value
}
for (const key of Object.keys(changes)) delete changes[key]
Object.assign(changes, mapped)
}
await adminApi.patch(resource, id, changes)
updateSite(apply)
updateSite((d) => {
const config = ENTITIES[resource]
let node: Record<string, unknown> | undefined
if (config) {
const target = d[config.dataKey as keyof SiteData] as unknown
node =
config.kind === 'singleton'
? (target as Record<string, unknown> | undefined)
: (target as { id: number }[] | undefined)?.find((x) => x.id === id)
}
const snapshot =
editLang === 'en' && node
? Object.fromEntries(originalKeys.filter((k) => k !== 'id').map((k) => [k, node![k]]))
: null
apply(d)
if (snapshot && node) {
Object.assign(node, snapshot)
Object.assign(node, changes)
}
})
}),
[runSave, updateSite],
[runSave, updateSite, editLang],
)
}
/**
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
* پسوند _en می‌گیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره می‌شود.
*/
export function useEditField() {
const { editLang } = useAdmin()
return useCallback(
(resource: string, field: string) =>
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
[editLang],
)
}
+163 -35
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Controller, useController, useForm } from 'react-hook-form'
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { ArrowRightIcon, CheckIcon, EyeIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2'
@@ -9,14 +10,42 @@ import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
/**
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند:
* عنوان (مبنای Alt پویا)، دسته‌بندی و متادیتای تصویر شاخص.
* `category` در فرم یک شیء تک‌مقداری {value,label} یا null است
* که هنگام ذخیره به رشته (ستون category در بک‌اند) نگاشت می‌شود.
*/
type ArticleMetaForm = {
title: string
category: CategoryOption | null
image_alt: string
image_title: string
}
/** مقدار رشته‌ای ذخیره‌شده → گزینه react-select (یا null وقتی خالی است) */
function toCategoryOption(value: string): CategoryOption | null {
const category = (value ?? '').trim()
return category ? { label: category, value: category } : null
}
/** گزینه react-select → مقدار رشته‌ای ذخیره‌سازی در بک‌اند */
function categoryValue(option: CategoryOption | null): string {
return option?.value.trim() ?? ''
}
/** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */
function normalizeTags(value: unknown): string[] {
@@ -36,7 +65,7 @@ const STATUS_TEXT: Record<string, string> = {
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/
export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave } = useAdmin()
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data, updateSite } = useSiteData()
@@ -49,20 +78,51 @@ export function ArticleWritePage() {
id: 0,
slug: `article-${Date.now()}`,
title: '',
title_en: '',
date: new Date().toISOString().split('T')[0],
date_en: '',
category: ARTICLE_CATEGORIES[0],
category_en: '',
tags: [],
image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
read_en: '',
excerpt: '',
excerpt_en: '',
body: EMPTY_DOC,
body_en: EMPTY_DOC,
status: 'draft',
seo_keyphrase: '',
})
/**
* فرم متادیتا با React Hook Form — منبع نمایش فیلدها.
* هر تغییر، هم فرم و هم draft (مبنای ذخیره‌سازی خودکار) را به‌روز می‌کند.
*/
const { control, watch, reset } = useForm<ArticleMetaForm>({
defaultValues: {
title: '',
category: toCategoryOption(ARTICLE_CATEGORIES[0]),
image_alt: '',
image_title: '',
},
mode: 'onChange',
})
/** عنوان زنده مقاله — مبنای متن جایگزین (Alt) تصویر شاخص */
const watchedTitle = watch('title')
const { field: imageAltField } = useController({ name: 'image_alt', control })
const { field: imageTitleField } = useController({ name: 'image_title', control })
const [createdId, setCreatedId] = useState<number | null>(null)
/**
* شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
*/
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft)
draftRef.current = draft
@@ -93,12 +153,27 @@ export function ArticleWritePage() {
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => {
if (!article || createdRef.current) return
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({
...article,
tags: normalizeTags(article.tags),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc,
...(article as unknown as Draft),
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
// بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
body: toArticleBodyDoc(article.body),
body_en:
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
? (rawEnBody as ArticleBodyDoc)
: EMPTY_DOC,
})
setCreatedId(article.id)
// بدنه ویرایشگر با محتوای بارگذاری‌شده همگام می‌شود
setLoadedContentKey(article.id)
// فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود
reset({
title: article.title ?? '',
category: toCategoryOption(article.category ?? ''),
image_alt: article.image_alt ?? '',
image_title: article.image_title ?? '',
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [article?.id])
@@ -195,6 +270,13 @@ export function ArticleWritePage() {
})
const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
// متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
.filter(Boolean)
.join(' - ')
return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
@@ -211,6 +293,18 @@ export function ArticleWritePage() {
</Link>
<div className="flex items-center gap-4">
<button
type="button"
onClick={toggleEditLang}
title="تعیین می‌کند متن مقاله روی کدام زبان ذخیره شود"
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
editLang === 'en'
? 'bg-copper text-char'
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
</button>
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
{STATUS_TEXT[saveStatus]}
@@ -245,7 +339,7 @@ export function ArticleWritePage() {
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button>
<Link
to={`/blog/${draft.slug}`}
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
data-admin-link
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
@@ -276,44 +370,65 @@ export function ArticleWritePage() {
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p>
{/* عنوان */}
<input
type="text"
{...textField('title')}
placeholder="عنوان مقاله…"
aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره می‌شود */}
<Controller
name="title"
control={control}
render={({ field }) => (
<input
type="text"
{...field}
onChange={(event) => {
field.onChange(event.target.value)
queueSave({ [suf('title')]: event.target.value })
}}
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
/>
)}
/>
<div className="mt-4 h-px w-28 bg-copper" />
{/* تصویر شاخص + متن جایگزین */}
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصل‌اند) */}
<div className="mt-8">
<ImageField
label="تصویر شاخص مقاله"
value={draft.image}
alt={draft.image_alt ?? ''}
altFallback={draft.title}
alt={imageAltField.value}
altFallback={imageAltFallback}
title={imageTitleField.value}
onSave={async (url) => {
queueSave({ image: url })
return true
}}
onSaveAlt={async (alt) => {
imageAltField.onChange(alt)
queueSave({ image_alt: alt })
return true
}}
onSaveTitle={async (imageTitle) => {
imageTitleField.onChange(imageTitle)
queueSave({ image_title: imageTitle })
return true
}}
/>
</div>
{/* متن */}
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<div className="mt-8">
<ArticleEditor
doc={draft.body as unknown as Record<string, unknown>}
contentKey={loadedContentKey}
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => {
// محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن
const textContent = JSON.stringify(json)
const wordCount = textContent.split(/\s+/).length
const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه'
queueSave({ body: json, read: calculatedReadTime })
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json)
if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
queueSave({ body: json, read: `${minutes} دقیقه` })
}
}}
/>
</div>
@@ -373,8 +488,8 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...textField('excerpt')}
placeholder="خلاصه کوتاه مقاله برای کارت‌های وبلاگ…"
{...textField(suf('excerpt'))}
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارت‌های وبلاگ…'}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
@@ -394,11 +509,20 @@ export function ArticleWritePage() {
<div>
<label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<div className="mt-2">
<LuxeSelect
value={draft.category}
options={ARTICLE_CATEGORIES}
onChange={(category) => queueSave({ category })}
isClearable={false}
{/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
<Controller
name="category"
control={control}
render={({ field }) => (
<CategorySelect
value={field.value}
options={ARTICLE_CATEGORIES}
onChange={(option) => {
field.onChange(option)
queueSave({ category: categoryValue(option) })
}}
/>
)}
/>
</div>
</div>
@@ -428,9 +552,13 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input
type="text"
disabled
{...textField('read')}
className="mt-2 w-full cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light text-ivory/50"
disabled={editLang !== 'en'}
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
className={`mt-2 w-full text-sm font-light ${
editLang === 'en'
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
}`}
/>
</div>
<div>
+80 -24
View File
@@ -5,10 +5,12 @@ import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient'
import { editableFields } from './entities'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
import { LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = {
@@ -22,11 +24,16 @@ type EntityDrawerProps = {
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
const save = useFieldSaver()
const { runSave } = useAdmin()
const { runSave, editLang } = useAdmin()
const { reload } = useSiteData()
const [deleting, setDeleting] = useState(false)
const flushRef = useRef<() => void>(() => {})
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر می‌شوند؛
// متن فارسی همان فیلد به‌عنوان جای‌نگهدار نشان داده می‌شود تا مترجم زمینه را ببیند
const fields = editableFields(config, editLang)
const faBase = (name: string) => name.replace(/_en$/, '')
const closeWithFlush = () => {
flushRef.current()
onClose()
@@ -113,23 +120,36 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6">
{config.fields.map((field) => {
{fields.map((field) => {
const isImage = field.type === 'image'
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
const altKey =
field.type === 'image'
? field.name === 'src' && 'alt' in item
? 'alt'
: `${field.name}_alt`
: ''
const altKey = isImage
? field.name === 'src' && 'alt' in item
? 'alt'
: `${field.name}_alt`
: ''
const imageTitleKey = isImage ? `${field.name}_title` : ''
// پیشنهاد Alt از عنوان رکورد ساخته می‌شود (هرگز از URL)
const imageAltFallback = [
String(item[config.titleKey] ?? '').trim(),
field.altFallbackSuffix,
]
.filter(Boolean)
.join(' - ')
return (
<FieldControl
key={field.name}
field={field}
value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
altValue={altKey ? String(item[altKey] ?? '') : undefined}
altFallback={String(item[config.titleKey] ?? '')}
altFallback={imageAltFallback}
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
onSave={(value) => saveField(field.name, value)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
onSaveImageTitle={
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
@@ -166,8 +186,11 @@ function FieldControl({
value,
altValue,
altFallback,
placeholder,
imageTitleValue,
onSave,
onSaveAlt,
onSaveImageTitle,
registerFlush,
}: {
field: FieldConfig
@@ -175,12 +198,22 @@ function FieldControl({
/** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string
altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown
/** مقدار Title تصویر — فقط برای فیلدهای image */
imageTitleValue?: string
onSave: (value: unknown) => Promise<boolean>
onSaveAlt?: (alt: string) => Promise<boolean>
onSaveImageTitle?: (title: string) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [text, setText] = useState<string>(String(value ?? ''))
// همگام‌سازی مقدار محلی با داده‌ای که از بیرون می‌آید (بارگذاری/به‌روزرسانی خوش‌بینانه)
useEffect(() => {
setText(String(value ?? ''))
}, [value])
const commitText = async () => {
const next = field.type === 'number' ? Number(text) || 0 : text
if (String(value ?? '') === String(next)) return
@@ -194,26 +227,34 @@ function FieldControl({
value={String(value ?? '')}
alt={altValue}
altFallback={altFallback}
title={imageTitleValue}
onSave={(url) => onSave(url)}
onSaveAlt={onSaveAlt}
onSaveTitle={onSaveImageTitle}
/>
)
}
if (field.type === 'select') {
// مقدار نمایشی از وضعیت محلی می‌آید تا افزودن/حذف گزینه فوراً دیده شود
const selectProps = {
value: text,
options: field.options ?? [],
onChange: (next: string) => {
setText(next)
void onSave(next)
},
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<div className="mt-2">
<LuxeSelect
value={String(value ?? '')}
options={field.options ?? []}
onChange={(next) => {
setText(next)
void onSave(next)
}}
/>
{field.creatable ? (
<LuxeCreatableSelect {...selectProps} />
) : (
<LuxeSelect {...selectProps} />
)}
</div>
</div>
)
@@ -238,14 +279,22 @@ function FieldControl({
}
if (field.type === 'wysiwyg') {
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
return (
<WysiwygField
field={field}
value={value}
onSave={onSave}
registerFlush={registerFlush}
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را می‌بیند' : undefined}
/>
)
}
if (field.type === 'list') {
return field.ui === 'tags' ? (
<TagsField field={field} value={value} onSave={onSave} />
) : (
<ListField field={field} value={value} onSave={onSave} />
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
)
}
@@ -256,6 +305,7 @@ function FieldControl({
<textarea
dir="auto"
rows={4}
placeholder={placeholder ? String(placeholder) : undefined}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
@@ -272,6 +322,7 @@ function FieldControl({
<input
dir={field.type === 'number' ? 'ltr' : 'auto'}
type={field.type === 'number' ? 'number' : 'text'}
placeholder={placeholder ? String(placeholder) : undefined}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
@@ -314,10 +365,12 @@ function ListField({
field,
value,
onSave,
hint,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
hint?: string
}) {
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
@@ -330,6 +383,7 @@ function ListField({
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
<div className="mt-2 grid gap-2">
{lines.map((line, i) => (
<div key={i} className="flex items-start gap-2">
@@ -377,16 +431,17 @@ function WysiwygField({
value,
onSave,
registerFlush,
hint,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void
hint?: string
}) {
const [doc, setDoc] = useState<Record<string, unknown>>(() =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: { type: 'doc', content: [{ type: 'paragraph' }] },
const [doc, setDoc] = useState<Record<string, unknown>>(
// بدنه ذخیره‌شده (سند جدید، رشته JSON یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
() => toArticleBodyDoc(value),
)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null)
@@ -427,6 +482,7 @@ function WysiwygField({
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
<p className="mt-1 text-[10px] font-light text-ivory/40">
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
</p>
+4
View File
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token'
/** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string | null): void => {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
}
export class HttpError extends Error {
+60 -34
View File
@@ -1,4 +1,5 @@
import type { SiteData } from '../api'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
@@ -9,8 +10,14 @@ export type FieldConfig = {
hint?: string
/** فقط برای type === 'select' — گزینه‌های ثابت react-select */
options?: string[]
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
creatable?: boolean
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
altFallbackSuffix?: string
}
export type EntityConfig = {
@@ -28,13 +35,15 @@ const F = (
label: string,
type: FieldType = 'text',
hint?: string,
extra?: Partial<FieldConfig>,
sharedOrExtra?: boolean | Partial<FieldConfig>,
): FieldConfig => ({
name,
label,
type,
hint,
...extra,
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
? sharedOrExtra
: { shared: !!sharedOrExtra }),
})
/** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */
@@ -50,16 +59,16 @@ export const ENTITIES: Record<string, EntityConfig> = {
services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
},
projects: {
resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection',
titleKey: 'title',
fields: [
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
],
@@ -71,30 +80,31 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [
F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده (روی کارت مقاله)'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود'),
F('image', 'تصویر شاخص', 'image'),
F('category', 'دسته‌بندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
F('author', 'نویسنده'),
F('date', 'تاریخ انتشار (شمسی)', 'date'),
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true),
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
F('author', 'نویسنده', 'text', undefined, true),
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
F('read', 'زمان مطالعه (دقیقه)'),
F('slug', 'نشانی (slug)'),
F('slug', 'نشانی (slug)', 'text', undefined, true),
],
defaults: {
title: 'مقاله جدید',
slug: `article-${Date.now()}`,
date: new Date().toISOString().split('T')[0],
category: ARTICLE_CATEGORIES[0],
tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '',
defaults: {
title: 'مقاله جدید',
slug: `article-${Date.now()}`,
date: new Date().toISOString().split('T')[0],
category: ARTICLE_CATEGORIES[0],
tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
excerpt: '',
read: '۵ دقیقه',
excerpt: '',
seo_keyphrase: '',
body: { type: 'doc', content: [{ type: 'paragraph' }] }
body: { type: 'doc', content: [{ type: 'paragraph' }] }
},
},
testimonials: {
@@ -118,13 +128,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
timeline: {
resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection',
titleKey: 'title',
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)],
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
},
stats: {
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
titleKey: 'label',
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
},
why_us: {
@@ -154,20 +164,20 @@ export const ENTITIES: Record<string, EntityConfig> = {
gallery: {
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
titleKey: 'alt',
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
defaults: { src: '', alt: 'تصویر گالری' },
},
nav: {
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
titleKey: 'label',
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
defaults: { label: 'آیتم جدید', href: '/' },
},
seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key',
fields: [
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
],
@@ -176,25 +186,25 @@ export const ENTITIES: Record<string, EntityConfig> = {
brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name',
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پس‌زمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پس‌زمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')],
defaults: {},
},
hero: {
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
titleKey: 'title',
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
defaults: {},
},
story: {
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
titleKey: 'title',
fields: [F('quote', 'نقل‌قول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقل‌قول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
fields: [F('title', 'عنوان بخش'), F('paragraphs', 'پاراگراف‌های معرفی', 'list'), F('quote', 'نقل‌قول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقل‌قول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه', 'text', undefined, true)],
defaults: {},
},
before_after: {
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
defaults: {},
},
contact_section: {
@@ -225,6 +235,22 @@ export const ENTITIES: Record<string, EntityConfig> = {
},
}
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
export function isTranslatable(resource: string, name: string): boolean {
if (name.endsWith('_en')) return false
const config = ENTITIES[resource]
return !!config?.fields.some((f) => f.name === name && !f.shared)
}
/** فیلدهای قابل ویرایش یک منبع در زبان داده‌شده — انگلیسی: بدون فیلدهای مشترک و با پسوند _en */
export function editableFields(config: EntityConfig, lang: 'fa' | 'en'): FieldConfig[] {
if (lang === 'fa') return config.fields
return config.fields
.filter((f) => !f.shared)
.map((f) => ({ ...f, name: `${f.name}_en` }))
}
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
return data[config.dataKey as keyof SiteData] as never
}
}
+107 -24
View File
@@ -2,12 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site'
import { API_URL } from './config'
import { getToken } from './admin/adminClient'
import { getToken, setToken } from './admin/adminClient'
import { localizeSite } from './i18n/localize'
import { useLang } from './i18n/lang'
// انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند
export type BrandInfo = {
id: number
name: string
name_en?: string
logo: string
hero: string
contact_bg: string
@@ -15,105 +18,157 @@ export type BrandInfo = {
phone_href: string
email: string
address: string
address_en?: string
}
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
export type FaqItem = { id: number; question: string; answer: string }
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
export type StatItem = { id: number; value: number; suffix: string; label: string }
export type WhyUsItem = { id: number; title: string; desc: string }
export type TestimonialItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; quote: string; quote_en?: string }
export type FaqItem = { id: number; question: string; question_en?: string; answer: string; answer_en?: string }
export type TeamMemberItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; bio: string; bio_en?: string }
export type TimelineItem = { id: number; year: string; year_en?: string; title: string; title_en?: string; desc: string; desc_en?: string; image: string }
export type StatItem = { id: number; value: number; suffix: string; suffix_en?: string; label: string; label_en?: string }
export type WhyUsItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string }
export type BeforeAfterInfo = {
id: number
title: string
title_en?: string
lead: string
lead_en?: string
before: string
after: string
before_label: string
before_label_en?: string
after_label: string
after_label_en?: string
}
export type NavItem = { id: number; label: string; href: string }
export type NavItem = { id: number; label: string; label_en?: string; href: string }
export type HeroContent = {
id: number
kicker: string
kicker_en?: string
title: string
title_en?: string
title_highlight: string
title_highlight_en?: string
subtitle: string
subtitle_en?: string
badge_value: string
badge_value_en?: string
badge_label: string
badge_label_en?: string
cta_primary_label: string
cta_primary_label_en?: string
cta_primary_href: string
cta_secondary_label: string
cta_secondary_label_en?: string
cta_secondary_href: string
scroll_label: string
scroll_label_en?: string
}
export type StoryContent = {
id: number
title: string
title_en?: string
paragraphs: string[]
paragraphs_en?: string[] | null
quote: string
quote_en?: string
quote_author: string
quote_author_en?: string
stat1_value: string
stat1_value_en?: string
stat1_label: string
stat1_label_en?: string
stat2_value: string
stat2_value_en?: string
stat2_label: string
stat2_label_en?: string
link_label: string
link_label_en?: string
link_href: string
bento: { src: string; alt: string }[]
bento_en?: { src: string; alt: string }[] | null
}
export type SectionHeadingContent = {
id: number
key: string
label: string
label_en?: string
title: string
title_en?: string
lead: string
lead_en?: string
image: string
cta_label: string
cta_label_en?: string
cta_href: string
}
export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string }
export type PageHeroContent = { id: number; key: string; title: string; title_en?: string; lead: string; lead_en?: string; image: string }
export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] }
export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
export type OfficeItem = { id: number; city: string; address: string; hours: string }
export type GalleryImageItem = { id: number; src: string; alt: string }
export type StatusLabelItem = { id: number; key: string; label: string }
export type InvestmentModelItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string; points: string[]; points_en?: string[] | null }
export type ProcessStepItem = { id: number; step: string; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: string }
export type OfficeItem = { id: number; city: string; city_en?: string; address: string; address_en?: string; hours: string; hours_en?: string }
export type GalleryImageItem = { id: number; src: string; alt: string; alt_en?: string }
export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string }
export type ContactSectionContent = {
id: number
label: string
label_en?: string
title: string
title_en?: string
lead: string
lead_en?: string
address_label: string
address_label_en?: string
phone_label: string
phone_label_en?: string
email_label: string
email_label_en?: string
name_label: string
name_label_en?: string
name_placeholder: string
name_placeholder_en?: string
phone_field_label: string
phone_field_label_en?: string
phone_placeholder: string
phone_placeholder_en?: string
note_label: string
note_label_en?: string
note_optional_label: string
note_optional_label_en?: string
note_placeholder: string
note_placeholder_en?: string
submit_label: string
submit_label_en?: string
sending_label: string
sending_label_en?: string
success_title: string
success_title_en?: string
success_text: string
success_text_en?: string
success_button_label: string
success_button_label_en?: string
}
export type NewsletterContent = {
id: number
title: string
title_en?: string
lead: string
lead_en?: string
placeholder: string
placeholder_en?: string
button_label: string
button_label_en?: string
sending_label: string
sending_label_en?: string
success_text: string
success_text_en?: string
}
export type SocialItem = { label: string; href: string; icon: string }
@@ -121,12 +176,19 @@ export type SocialItem = { label: string; href: string; icon: string }
export type FooterContent = {
id: number
about_text: string
about_text_en?: string
pages_heading: string
pages_heading_en?: string
services_heading: string
services_heading_en?: string
contact_heading: string
contact_heading_en?: string
copyright: string
copyright_en?: string
tagline: string
tagline_en?: string
socials: SocialItem[]
socials_en?: SocialItem[] | null
}
export interface SiteData {
@@ -154,7 +216,7 @@ export interface SiteData {
contact_section: ContactSectionContent
newsletter: NewsletterContent
footer: FooterContent
seo_meta: { id: number; page_key: string; title: string; description: string }[]
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
}
interface SiteContextValue {
@@ -168,10 +230,16 @@ interface SiteContextValue {
const SiteContext = createContext<SiteContextValue>({
data: null,
error: false,
// پیش‌فرض‌های بی‌اثر — مصرف‌کننده بیرون از Provider هم بدون خطا رندر می‌شود
// eslint-disable-next-line @typescript-eslint/no-empty-function
reload: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
updateSite: () => {},
})
/** سقف انتظار داده سایت — سرور بی‌پاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بی‌پایان */
const SITE_TIMEOUT_MS = 15_000
export function SiteProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<SiteData | null>(null)
const [error, setError] = useState(false)
@@ -179,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const load = useCallback(() => {
let cancelled = false
setError(false)
fetch(`${API_URL}/api/site`)
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
fetch(`${API_URL}/api/site`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<SiteData>
@@ -193,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (res.ok) {
if (res.status === 401) {
// توکن منقضی/نامعتبر — پاک می‌شود تا بارگذاری‌های بعدی درخواست بی‌فایده نفرستند
setToken(null)
} else if (res.ok) {
const all = (await res.json()) as Article[]
const published = new Set(json.articles.map((a) => a.id))
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
json.articles = [...json.articles, ...drafts]
}
} catch {
/* بدون پیش‌نویس‌ها ادامه بده */
/* بدون پیش‌نویس‌ها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
}
}
if (!cancelled) {
@@ -213,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
})
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [])
@@ -244,13 +318,16 @@ export function useSiteData(): SiteContextValue {
return useContext(SiteContext)
}
// فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند
// فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند.
// خروجی بومی‌سازی‌شده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی می‌شوند (با برگشت به فارسی).
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده می‌شود.
export function useSite(): SiteData {
const { data } = useContext(SiteContext)
const { lang } = useLang()
if (!data) {
throw new Error('useSite باید پس از بارگذاری داده‌های سایت استفاده شود')
}
return data
return localizeSite(data, lang)
}
const EMPTY_HEADING: SectionHeadingContent = {
@@ -267,19 +344,25 @@ const EMPTY_HEADING: SectionHeadingContent = {
/** تیتر و توضیح یک بخش بر اساس key تعریف‌شده در پنل مدیریت */
export function useSectionHeading(key: string): SectionHeadingContent {
const { data } = useContext(SiteContext)
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return localized?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
}
/** هیرو بالای صفحه بر اساس key تعریف‌شده در پنل مدیریت */
export function usePageHero(key: string): PageHeroContent {
const { data } = useContext(SiteContext)
const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return (
data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
)
}
/** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
export function useStatusLabel(key: string): string {
const { data } = useContext(SiteContext)
return data?.status_labels.find((s) => s.key === key)?.label ?? key
const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
}
+17 -14
View File
@@ -3,48 +3,51 @@ import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { formatJalali } from '../utils/date'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang()
return (
<Link
to={`/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"
aria-label={`خواندن مقاله ${article.title}`}
aria-label={`${t('readArticlePrefix')} ${article.title}`}
>
<div className="relative overflow-hidden">
<img
src={article.image}
alt={`تصویر مقاله ${article.title}`}
alt={`${t('articleImagePrefix')} ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
{/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */}
<div className="absolute right-4 top-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-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)]">
{article.category}
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
<div className="flex flex-1 flex-col p-8">
<h3 className="mb-4 md:mb-6 text-xl font-bold leading-relaxed text-ivory transition-colors duration-300 group-hover:text-copper">
{article.title}
</h3>
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
<p className="mb-6 md:mb-10 text-[15px] font-light leading-loose text-ivory/60 line-clamp-3">
{article.excerpt}
</p>
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
<span className="flex items-center gap-3">
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-6 md:pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5">
<span className="flex items-center gap-2">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{article.read}
{toPersianDigits(article.read)}
</span>
</span>
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:hover:[&>svg]:-translate-x-1 ltr:hover:[&>svg]:translate-x-1">
{t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
</span>
</div>
</div>
+3 -2
View File
@@ -1,6 +1,7 @@
import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
type ArticleMetaProps = {
article: Article
@@ -41,11 +42,11 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
{dateLabel}
</span>
{/* زمان مطالعه */}
{/* زمان مطالعه — با ارقام فارسی */}
{article.read && (
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{article.read}
{toPersianDigits(article.read)}
</span>
)}
</div>
-110
View File
@@ -1,110 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
>
{children}
</span>
</>
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+17 -21
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from './Reveal'
import { Button } from './Button'
import { PremiumButton } from './PremiumButton'
import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable'
@@ -11,6 +12,7 @@ import { API_URL } from '../config'
export function ContactSection() {
const { brand, contact_section: content } = useSite()
const { t } = useLang()
const save = useFieldSaver()
const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false)
@@ -34,7 +36,7 @@ export function ContactSection() {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
confirmButtonText: t('understood'),
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-md bg-ink/70',
@@ -62,13 +64,13 @@ export function ContactSection() {
const phoneValue = form.phone.trim()
if (nameValue.length < 3) {
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
return
}
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
if (!phoneValue || !phoneRegex.test(phoneValue)) {
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
return
}
@@ -83,14 +85,14 @@ export function ContactSection() {
if (!res.ok) {
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در ثبت درخواست', detail)
typeof data?.detail === 'string' ? data.detail : t('formCheck')
showErrorAlert(t('submitErrorTitle'), detail)
return
}
setSent(true)
} catch {
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
} finally {
setSending(false)
}
@@ -192,17 +194,15 @@ export function ContactSection() {
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
</p>
<Button
type="button"
variant="outline"
className="mt-8 font-light"
<PremiumButton
onClick={() => {
setSent(false)
setForm({ name: '', phone: '', note: '' })
}}
className="mt-8 font-light"
>
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</Button>
</PremiumButton>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
@@ -232,7 +232,7 @@ export function ContactSection() {
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder={content.phone_placeholder}
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light ltr:text-left rtl:text-right text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
/>
</div>
@@ -251,21 +251,17 @@ export function ContactSection() {
</div>
<div className="md:col-span-2 flex justify-start">
<Button
<PremiumButton
type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending}
>
{sending ? (
content.sending_label
) : (
<>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
</>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
)}
</Button>
</PremiumButton>
</div>
</form>
)}
+4 -2
View File
@@ -1,15 +1,17 @@
import React from 'react';
import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api';
import { useLang } from '../i18n/lang';
export function FloatingContact() {
const { brand } = useSite();
const { t } = useLang();
return (
<a
href={brand.phone_href}
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
aria-label={t('callBrand')}
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
>
{/* پالس هاله نوری در پس‌زمینه مانند رادار */}
<span
+5 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from './admin/InlineEdit';
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
@@ -14,6 +15,7 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
export function Footer() {
const { brand, services, nav, footer } = useSite();
const { t, href } = useLang();
const save = useFieldSaver();
const saveFooter = (field: string, value: string) =>
@@ -38,7 +40,7 @@ export function Footer() {
<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 -left-10 top-1/2 -translate-y-1/2" />
<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)} />
@@ -70,7 +72,7 @@ export function Footer() {
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<Link
to={item.href}
to={href(item.href)}
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
>
<InlineEdit
@@ -136,7 +138,7 @@ export function Footer() {
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>بازگشت به بالا</span>
<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>
+14 -8
View File
@@ -2,12 +2,15 @@ import React, { useEffect, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
import { useSite } from "../api";
import { useLang } from "../i18n/lang";
import { useFieldSaver } from "../admin/AdminContext";
import { InlineEdit } from "./admin/InlineEdit";
import { EditableItem, SingletonPill } from "../admin/Editable";
import { LangToggle } from "./LangToggle";
export function Header() {
const { brand, nav } = useSite();
const { t, href } = useLang();
const save = useFieldSaver();
const saveNav = (id: number, changes: Record<string, unknown>) =>
@@ -40,7 +43,7 @@ export function Header() {
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
<div className="relative flex items-center">
<Link
to="/"
to={href("/")}
className="flex items-center gap-3"
aria-label={brand.name}
>
@@ -52,16 +55,16 @@ export function Header() {
}`}
/>
</Link>
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
</div>
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
<nav aria-label={t("mainNav")} className="hidden xl:block">
<ul className="flex items-center gap-7">
{nav.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<NavLink
to={item.href}
to={href(item.href)}
className={({ isActive }) =>
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
isActive ? "text-copper font-normal" : "text-ivory/80"
@@ -102,12 +105,15 @@ export function Header() {
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
</a>
{/* تغییر زبان — فارسی/انگلیسی */}
<LangToggle />
{/* دکمه منوی موبایل دایره‌ای شیشه‌ای */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory 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 xl:hidden"
aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-label={open ? t("closeMenu") : t("openMenu")}
aria-expanded={open}
>
{open ? (
@@ -121,7 +127,7 @@ export function Header() {
{open && (
<nav
aria-label="ناوبری موبایل"
aria-label={t("mobileNav")}
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
>
<ul className="grid gap-1">
@@ -129,7 +135,7 @@ export function Header() {
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<NavLink
to={item.href}
to={href(item.href)}
className={({ isActive }) =>
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
isActive ? "text-copper" : "text-ivory/80"
@@ -149,4 +155,4 @@ export function Header() {
)}
</header>
);
}
}
+93
View File
@@ -0,0 +1,93 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
/**
* تنظیمات ثابت هر زبان.
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
*/
const LANGS = {
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
en: { label: 'English', sliderTranslate: 'translate-x-full' },
} as const
type LangKey = keyof typeof LANGS
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) {
const { lang, t } = useLang()
const navigate = useNavigate()
const { pathname } = useLocation()
const target = counterpartPath(pathname, lang)
const switchLang = () => {
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
// پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد
sessionStorage.setItem('langAutoRedirect', '1')
navigate(target)
}
return (
<button
type="button"
onClick={switchLang}
aria-label={t('switchLangAria')}
title={t('switchLangAria')}
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(' ')}
>
{/* ── Inner Track ───────────────────────────────────────────────
این دیو داخلی باعث می‌شود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
*/}
<div className="relative w-full h-full flex items-center">
{/* ── Slider pill ─────────────────────────────────────────────── */}
<span
aria-hidden="true"
className={[
'absolute top-0 left-0', // چسبیده به لبه‌های Track داخلی
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
'bg-[#27221c] border border-[#d5874c]',
// subtle raised shadow — tactile without being flashy
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
'transition-transform duration-[380ms] ease-lux',
LANGS[lang as LangKey].sliderTranslate,
].join(' ')}
/>
{/* ── Labels ──────────────────────────────────────────────────── */}
<span className="relative z-10 flex w-full h-full">
{(Object.keys(LANGS) as LangKey[]).map((key) => {
const isActive = lang === key
return (
<span
key={key}
className={[
// each label occupies exactly half the capsule
'w-1/2 h-full flex justify-center items-center',
'text-[11.5px] font-semibold tracking-wide leading-none',
'transition-colors duration-[380ms] ease-lux',
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
].join(' ')}
>
{LANGS[key].label}
</span>
)
})}
</span>
</div>
</button>
)
}
+38 -26
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
type Props = {
images: string[]
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') prev()
if (e.key === 'ArrowLeft') next()
if (e.key === 'ArrowRight') next()
if (e.key === 'ArrowLeft') prev()
}
window.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
>
{/* Solid Brand Background */}
<div
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
className="absolute inset-0 bg-[#27221c]"
onClick={onClose}
aria-hidden="true"
/>
{/* Close Button at Top Right */}
<button
type="button"
onClick={onClose}
className="absolute left-6 top-6 z-10 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10 md:top-10"
className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
>
<XIcon className="h-5 w-5" />
<MdClose size={24} />
</button>
<motion.img
key={index}
src={images[index as number]}
alt=""
className="relative max-h-[80vh] w-auto max-w-full object-contain"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
{/* Main Content Area */}
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
{/* Prev Button */}
<button
type="button"
onClick={prev}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
>
<ChevronRightIcon className="h-5 w-5" />
<MdChevronRight size={28} />
</button>
<span className="text-sm font-light text-ivory/70" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
{/* Image */}
<motion.img
key={index}
src={images[index as number]}
alt=""
className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
/>
{/* Next Button */}
<button
type="button"
onClick={next}
className="flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
>
<ChevronLeftIcon className="h-5 w-5" />
<MdChevronLeft size={28} />
</button>
</div>
<div className="absolute bottom-6 z-10 flex items-center justify-center">
<span className="text-sm font-light text-copper" dir="ltr">
{(index as number) + 1} / {images.length}
</span>
</div>
</motion.div>
)}
+6 -4
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react'
import { usePageHero } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext'
import { SingletonPill } from '../admin/Editable'
import { InlineEdit } from './admin/InlineEdit'
@@ -15,6 +16,7 @@ type PageHeroProps = {
export function PageHero({ pageKey }: PageHeroProps) {
const hero = usePageHero(pageKey)
const { t, href } = useLang()
const save = useFieldSaver()
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
@@ -36,11 +38,11 @@ export function PageHero({ pageKey }: PageHeroProps) {
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
<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">
{t('home')}
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{hero.title}</span>
</nav>
<motion.h1
+4 -3
View File
@@ -1,18 +1,19 @@
import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useSiteData } from '../api'
import { useLang } from '../i18n/lang'
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری داده‌ها از دیتابیس استفاده می‌شود
const FALLBACK_LOGO = '/images/logo.png'
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
export function Preloader() {
const { data } = useSiteData()
const { t } = useLang()
const [done, setDone] = useState(false)
const [progress, setProgress] = useState(0)
const logo = data?.brand.logo || FALLBACK_LOGO
const name = data?.brand.name || FALLBACK_NAME
const name = data?.brand.name || t('brandFallback')
useEffect(() => {
const start = Date.now()
@@ -43,7 +44,7 @@ export function Preloader() {
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
aria-live="polite"
aria-label="در حال بارگذاری"
aria-label={t('loading')}
>
<div className="relative h-36 w-36">
<img
+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 -30
View File
@@ -1,57 +1,89 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon } from 'lucide-react'
import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang'
import { PremiumButton } from './PremiumButton'
import type { StaticProject } from '../data/projectsData'
import type { ProjectCardItem } from '../data/site'
export function ProjectCard({ project }: any) {
type ProjectCardProps = {
project: StaticProject | ProjectCardItem;
imageUrl?: string;
detailHref?: string;
}
export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps) {
const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status)
const { t, lang } = useLang()
// Type guards for backward compatibility
const isStatic = 'name' in project;
// Resolve localized texts
const title = isStatic
? (project.name[lang as 'en' | 'fa'] || project.name.en)
: (project as ProjectCardItem).title;
const locRaw = isStatic ? project.location : (project as ProjectCardItem).location;
const location = typeof locRaw === 'string' ? locRaw : (locRaw?.[lang as 'en' | 'fa'] || locRaw?.en);
const areaRaw = isStatic ? project.area : (project as ProjectCardItem).area;
const area = typeof areaRaw === 'string' ? areaRaw : (areaRaw?.[lang as 'en' | 'fa'] || areaRaw?.en);
const finalImageUrl = imageUrl || (project as ProjectCardItem).image;
const finalDetailHref = detailHref || (project as ProjectCardItem).detailHref || '#';
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return (
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */}
<div className="relative aspect-[4/3] w-full overflow-hidden">
<img
src={project.image}
alt={project.title}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/>
<Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
{/* Fixed height image wrapper */}
<div className="relative w-full overflow-hidden">
{finalImageUrl && (
<img
src={finalImageUrl}
alt={title}
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
/>
)}
{/* نشانگر وضعیت کپسولی شیشه‌ای */}
<div className={`absolute right-4 top-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
<div
className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
>
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText}
</div>
{/* گوی دکمه شیشه‌ای شناور برای ورود به پروژه */}
<div className="absolute bottom-4 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 transition-transform duration-300 group-hover:-translate-x-0.5" />
</div>
</div>
<div className="flex flex-1 flex-col p-7">
<div className="flex flex-1 flex-col p-7 pb-4">
<div className="text-center">
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
{project.title}
{title}
</h3>
<p className="mt-4 text-sm font-light text-ivory/60">
{project.location}
</p>
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
</div>
<div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
<span>سال: {project.year}</span>
<span>{project.area} متر مربع</span>
</div>
{area && (
<>
<div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-end text-sm font-light text-ivory/60">
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */}
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
</div>
</>
)}
</div>
</Link>
{/* پایهٔ کارت: یک دکمه واحد */}
<div className="flex flex-col gap-2 px-7 pb-7">
<PremiumButton to={finalDetailHref} className="w-full">
{t('viewProject')}
</PremiumButton>
</div>
</article>
)
}
}
@@ -0,0 +1,19 @@
import { MdApartment } from 'react-icons/md'
import { useLang } from '../i18n/lang'
/**
* جای‌نگهدار تصویر پروژه — همان زبان بصری کارت پاپ‌آپ، برای هر پروژه‌ای که
* تصویری در داده ندارد (مثل ردیف‌های شیت). تک‌منبع این ظاهر همین‌جاست.
*/
export function ProjectImagePlaceholder({ className = '' }: { className?: string }) {
const { t } = useLang()
return (
<div
className={`flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-char to-ink text-ivory/25 ${className}`}
>
<MdApartment className="h-8 w-8" aria-hidden="true" />
<span className="text-[10px] font-light">{t('mapImagePlaceholder')}</span>
</div>
)
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function SectionHeading({
})
return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
<InlineEdit
as="h2"
value={currentTitle}
+43 -12
View File
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 }
/**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود.
* متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
*/
export function extractToc(html: string): { items: TocItem[]; html: string } {
function headingText(element: Element): string {
const parts: string[] = []
element.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
else if (node.nodeType === Node.ELEMENT_NODE) {
const child = node as Element
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
}
})
return parts.join('')
}
/**
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
* (حتی داخل نقل‌قول) شناسه لنگر می‌گیرند؛ متن فارسی در شناسه حفظ می‌شود و تیترهای
* تکراری پسوند عددی می‌گیرند. HTML برگشتی همان است که باید رندر شود.
*/
export function extractToc(
html: string,
transform?: (root: HTMLElement) => void,
): { items: TocItem[]; html: string } {
if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>()
const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((el) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = headingText(element).replace(/\s+/g, ' ').trim()
if (!text) return
const id = uniqueSlug(slugifyFa(text), used)
el.setAttribute('id', id)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 })
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
element.setAttribute('id', id)
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
})
// تبدیل اختیاری (بسته‌بندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا می‌شود
transform?.(doc.body)
return { items, html: doc.body.innerHTML }
}
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
className?: string
}
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */
/**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
{title}
</p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3">
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود می‌شود تا بریده نشود */}
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
{items.map((item) => {
const isActive = item.id === activeId
return (
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
type="button"
onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined}
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : ''
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'
+294 -158
View File
@@ -1,61 +1,107 @@
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core'
import {
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ItalicIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
LanguagesIcon,
ItalicIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
Loader2Icon,
PilcrowIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
UnderlineIcon,
Undo2Icon,
Loader2Icon,
} from 'lucide-react'
import Swal from 'sweetalert2'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
import { ENGLISH_TEXT_MARK, EnglishText } from './EnglishText'
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage'
import { BidiTypography, bidiPluginKey } from './BidiTypography'
import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal'
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
import type { LinkModalValue } from './LinkModal'
const SmallSizeMark = Mark.create({
/**
* مارک‌های قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آن‌ها از نوار ابزار برداشته شده و
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شده‌اند.
*/
const LegacySmallSizeMark = Mark.create({
name: 'smallSize',
parseHTML() { return [{ tag: 'span[data-small]' }] },
parseHTML() {
return [{ tag: 'span[data-small]' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
}
},
})
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [1, 2, 3] },
}),
Image.configure({ allowBase64: false }),
ArticleImage.configure({ allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => /^(https?:\/\/|\/|#|mailto:|tel:)/i.test(uri),
isAllowedUri: (uri: string) => isAllowedHref(uri),
}),
SmallSizeMark,
LegacySmallSizeMark,
EnglishText,
BidiTypography,
]
type ArticleEditorProps = {
doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void
/**
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری می‌شود
* (مثلاً وقتی مقاله از سرور می‌رسد). بدون این prop محتوا فقط یک‌بار مقداردهی می‌شود.
*/
contentKey?: string | number
}
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمی‌گرداند */
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
type TextDirection = 'rtl' | 'ltr'
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */
type LinkApplyMode = 'selection' | 'insert' | 'range'
type LinkModalState = {
open: boolean
editing: boolean
mode: LinkApplyMode
range: { from: number; to: number } | null
initial: LinkModalValue
}
const CLOSED_LINK_MODAL: LinkModalState = {
open: false,
editing: false,
mode: 'selection',
range: null,
initial: { href: '', newTab: true, nofollow: false },
}
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */
const URL_LOOKBACK = 300
function ToolButton({
onClick,
active = false,
@@ -72,6 +118,7 @@ function ToolButton({
type="button"
title={label}
aria-label={label}
aria-pressed={active}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`flex h-8 w-8 items-center justify-center border transition-colors duration-150 ${
@@ -87,10 +134,16 @@ function ToolButton({
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
/**
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور،
* کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
*/
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [direction, setDirection] = useState<TextDirection>('rtl')
const [linkModal, setLinkModal] = useState<LinkModalState>(CLOSED_LINK_MODAL)
const editor = useEditor({
extensions: ARTICLE_EXTENSIONS,
@@ -99,16 +152,30 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
editorProps: {
attributes: {
dir: 'rtl',
lang: 'fa',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true',
},
handlePaste: (_event_ignored, event) => {
handlePaste: (_view, event, _slice) => {
const file = event.clipboardData?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
return true
}
// چسباندن یک نشانی → مودال لینک بلافاصله باز می‌شود
const text = event.clipboardData?.getData('text/plain')?.trim() ?? ''
if (isUrlLike(text)) {
event.preventDefault()
setLinkModal({
open: true,
editing: false,
mode: 'insert',
range: null,
initial: { href: normalizeHref(text) ?? text, newTab: true, nofollow: false },
})
return true
}
return false
},
handleDrop: (_view, event, _slice, moved) => {
@@ -121,176 +188,245 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
}
return false
},
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود
handleTextInput: (view, from, to, text) => {
if (text !== ' ' || from !== to) return false
const start = Math.max(0, from - URL_LOOKBACK)
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
if (!isUrlLike(lastToken)) return false
setLinkModal({
open: true,
editing: false,
mode: 'range',
range: { from: from - lastToken.length, to: from },
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
})
return false
},
// Enter داخل تیتر، بلوک تازه را پاراگراف می‌کند تا قالب تیتر سرایت نکند
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
},
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
})
// جهت متن فقط روی ناحیه ویرایش اعمال می‌شود؛ نوار ابزار همیشه راست‌چین می‌ماند
useEffect(() => {
if (!editor) return
editor.view.dom.setAttribute('dir', direction)
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
// تزئین‌های دوزبانه (اندازه ۲ پیکسل کوچک‌تر متن لاتین) با جهت تازه بازمحاسبه می‌شوند
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
}, [editor, direction])
/**
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
*/
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساخته‌شدن نمونه ویرایشگر
useEffect(() => {
if (!editor || contentKey === undefined) return
const loaded = loadedContentRef.current
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
const incoming = doc && Object.keys(doc).length ? doc : null
if (!incoming) return
loadedContentRef.current = { editor, key: contentKey }
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بی‌مورد را فعال نکند
editor.commands.setContent(incoming, { emitUpdate: false })
}, [editor, contentKey, doc])
async function uploadAndInsert(file: File) {
if (!editor) return
setUploading(true)
// استفاده از ریگکس یونیکد اصلاح‌شده برای نام فایل و Alt
const originalName = file.name.replace(/\.[^.]+$/, '')
const cleanAlt = originalName.replace(/[^\w\u0600-\u06FF\s-]/g, '-').replace(/\s+/g, '-').toLowerCase()
await runSave(async () => {
const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: cleanAlt }).run()
// نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor
.chain()
.focus()
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
.run()
})
setUploading(false)
}
const setLink = () => {
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
const openLinkModalForSelection = () => {
if (!editor) return
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
return
const attrs = editor.getAttributes('link') as {
href?: string | null
target?: string | null
rel?: string | null
}
void Swal.fire({
title: 'نشانی لینک',
input: 'text',
inputValue: 'https://',
background: '#1e1a16',
color: '#F5F3EF',
showCancelButton: true,
confirmButtonText: 'ثبت',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
title: 'font-pinar font-bold text-ivory',
input: 'font-pinar',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5 mx-2',
cancelButton: 'border border-ivory/30 text-ivory font-light px-8 py-2.5 mx-2',
const editing = Boolean(attrs.href)
setLinkModal({
open: true,
editing,
mode: 'selection',
range: null,
initial: {
href: attrs.href ?? '',
newTab: editing ? attrs.target === '_blank' : true,
nofollow: editing ? parseLinkRel(attrs.rel).nofollow : false,
},
}).then((result) => {
if (!result.isConfirmed || !result.value) return
editor.chain().focus().extendMarkRange('link').setLink({ href: result.value }).run()
})
}
const applyLink = ({ href, newTab, nofollow }: LinkModalValue) => {
if (!editor) return
const linkAttrs = { href, target: newTab ? '_blank' : null, rel: buildLinkRel({ newTab, nofollow }) }
const chain = editor.chain().focus()
if (linkModal.mode === 'insert') {
// نشانی چسبانده‌شده: متن لینک تازه درج می‌شود
chain.insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: linkAttrs }] }).run()
} else if (linkModal.mode === 'range' && linkModal.range) {
// نشانی تایپ‌شده: همان محدوده به لینک تبدیل می‌شود
chain.setTextSelection(linkModal.range).extendMarkRange('link').setLink(linkAttrs).run()
} else {
chain.extendMarkRange('link').setLink(linkAttrs).run()
}
setLinkModal((state) => ({ ...state, open: false }))
}
const removeLink = () => {
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
setLinkModal((state) => ({ ...state, open: false }))
}
if (!editor) return null
return (
<div className="border border-ivory/15 bg-char">
<div className="flex flex-wrap items-center gap-1 border-b border-ivory/10 px-2 py-1.5">
{/* قالب‌بندی بلوک — پاراگراف و تیترها با سایز مشخص */}
<ToolButton
label="پاراگراف (۱۴ پیکسل)"
active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()}
<>
<div className="border border-ivory/15 bg-char" data-dir={direction}>
{/* نوار ابزار: همیشه راست‌چین و در بالای ویرایشگر ثابت می‌ماند */}
<div
dir="rtl"
className="sticky top-14 z-20 flex flex-wrap items-center gap-1 border-b border-ivory/10 bg-char px-2 py-1.5"
>
<PilcrowIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۱ (۱۸ پیکسل)"
active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۲ (۱۶ پیکسل)"
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۳ (۱۴ پیکسل)"
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="پاراگراف"
active={editor.isActive('paragraph')}
onClick={() => applyBlockFormat(editor.view, 'paragraph')}
>
<PilcrowIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۱"
active={editor.isActive('heading', { level: 1 })}
onClick={() => applyBlockFormat(editor.view, 1)}
>
<Heading1Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۲"
active={editor.isActive('heading', { level: 2 })}
onClick={() => applyBlockFormat(editor.view, 2)}
>
<Heading2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="تیتر ۳"
active={editor.isActive('heading', { level: 3 })}
onClick={() => applyBlockFormat(editor.view, 3)}
>
<Heading3Icon className="h-4 w-4" />
</ToolButton>
<Divider />
<Divider />
<select
className="h-8 bg-ink border border-ivory/15 text-ivory/90 text-xs px-2 outline-none focus:border-copper cursor-pointer transition-colors"
value={editor.isActive('smallSize') ? '12' : '14'}
{/* جهت متن — فقط محتوای ویرایشگر را عوض می‌کند */}
<ToolButton
label="راست‌چین (فارسی)"
active={direction === 'rtl'}
onClick={() => setDirection('rtl')}
>
<AlignRightIcon className="h-4 w-4" />
</ToolButton>
<ToolButton
label="چپ‌چین (انگلیسی)"
active={direction === 'ltr'}
onClick={() => setDirection('ltr')}
>
<AlignLeftIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<ItalicIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
<UnderlineIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="خط‌خورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
<StrikethroughIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لیست نقطه‌ای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<ListIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="لیست شماره‌ای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
<ListOrderedIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="نقل‌قول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="افزودن لینک" active={editor.isActive('link')} onClick={openLinkModalForSelection}>
<Link2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
</ToolButton>
<Divider />
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
<Undo2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
<Redo2Icon className="h-4 w-4" />
</ToolButton>
</div>
<EditorContent editor={editor} />
<input
ref={fileRef}
type="file"
data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
if (e.target.value === '12') {
editor.chain().focus().setMark('smallSize').run()
} else {
editor.chain().focus().unsetMark('smallSize').run()
}
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
>
<option value="14">سایز ۱۴ (فارسی)</option>
<option value="12">سایز ۱۲ (انگلیسی)</option>
</select>
{/* متن انگلیسی — span با dir=ltr و سایز ۱۲ پیکسل */}
<ToolButton
label="متن انگلیسی (۱۲ پیکسل، چپ‌به‌راست)"
active={editor.isActive(ENGLISH_TEXT_MARK)}
onClick={() => editor.chain().focus().toggleMark(ENGLISH_TEXT_MARK).run()}
>
<LanguagesIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ایتالیک" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
<ItalicIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="زیرخط" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
<UnderlineIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="خط‌خورده" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
<StrikethroughIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لیست نقطه‌ای" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<ListIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="لیست شماره‌ای" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
<ListOrderedIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="نقل‌قول" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<QuoteIcon className="h-4 w-4" />
</ToolButton>
<Divider />
<ToolButton label="لینک" active={editor.isActive('link')} onClick={setLink}>
<Link2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="درج تصویر" onClick={() => fileRef.current?.click()}>
{uploading ? <Loader2Icon className="h-4 w-4 animate-spin" /> : <ImagePlusIcon className="h-4 w-4" />}
</ToolButton>
<Divider />
<ToolButton label="واگرد" onClick={() => editor.chain().focus().undo().run()}>
<Undo2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ازنو" onClick={() => editor.chain().focus().redo().run()}>
<Redo2Icon className="h-4 w-4" />
</ToolButton>
/>
</div>
<EditorContent editor={editor} />
<input
ref={fileRef}
type="file"
data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
/>
</div>
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && (
<LinkModal
initial={linkModal.initial}
editing={linkModal.editing}
onApply={applyLink}
onRemove={removeLink}
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
/>
)}
</>
)
}
}
@@ -0,0 +1,67 @@
import Image from '@tiptap/extension-image'
import { mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { ImageNodeView } from './ImageNodeView'
/**
* تصویر مقاله با کپشن.
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده می‌شود.
*/
export const ArticleImage = Image.extend({
addAttributes() {
return {
// ویژگی‌های استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
...this.parent?.(),
caption: {
default: '',
parseHTML: (element) =>
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
},
}
},
parseHTML() {
return [
{
tag: 'figure',
priority: 60,
getAttrs: (element) => {
if (!(element instanceof HTMLElement)) return false
const img = element.querySelector('img')
if (!img) return false
return {
src: img.getAttribute('src'),
alt: img.getAttribute('alt'),
title: img.getAttribute('title'),
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
}
},
},
// تصاویر بدون figure (محتوای قدیمی یا چسبانده‌شده)
{ tag: 'img[src]:not([src^="data:"])' },
]
},
renderHTML({ HTMLAttributes }) {
const { caption, ...imageAttributes } = HTMLAttributes
const image: [string, Record<string, unknown>] = [
'img',
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
]
// کپشن خالی → عنصر figcaption اصلاً رندر نمی‌شود (فقط تصویر می‌ماند)
return caption
? [
'figure',
{ class: 'article-figure' },
image,
['figcaption', { class: 'article-figure__caption' }, caption],
]
: ['figure', { class: 'article-figure' }, image]
},
addNodeView() {
return ReactNodeViewRenderer(ImageNodeView)
},
})
@@ -0,0 +1,64 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorState } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
/** نام مارک‌های قدیمی که خودشان اندازه/جهت متن را تعیین می‌کنند */
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
type BidiState = { dir: 'rtl' | 'ltr' }
/** کلید پلاگین — جهت متن با متای همین کلید به‌روز می‌شود */
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
/**
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
* span جهت‌دار نشانه‌گذاری می‌شود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچک‌تر»
* خروجی سایت را نشان دهد. تزئین‌ها سند را تغییر نمی‌دهند و ذخیره نمی‌شوند.
*/
export const BidiTypography = Extension.create({
name: 'bidiTypography',
addProseMirrorPlugins() {
return [
new Plugin<BidiState>({
key: bidiPluginKey,
state: {
init: () => ({ dir: 'rtl' }),
apply: (tr, value) => {
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
return dir && dir !== value.dir ? { dir } : value
},
},
props: {
decorations: (state) =>
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
},
}),
]
},
})
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
const decorations: Decoration[] = []
const { doc, schema } = state
doc.descendants((node, pos, parent) => {
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
if (!node.isText || !node.text) return
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
return
}
for (const run of findScriptRuns(node.text, dir)) {
decorations.push(
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
)
}
})
return DecorationSet.create(doc, decorations)
}
+120 -78
View File
@@ -1,126 +1,168 @@
import { useEffect, useId, useRef, useState } from 'react'
import { ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { useId, useRef, useState } from 'react'
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
import { useSmartTextField } from './useSmartTextField'
type ImageFieldProps = {
label: string
value: string
onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) — با ارسال این prop ورودی Alt نمایش داده می‌شود */
/** متن جایگزین (Alt) */
alt?: string
/** ذخیره متن جایگزین — بدون آن، Alt فقط برای پیش‌نمایش و پیشنهاد استفاده می‌شود */
onSaveAlt?: (alt: string) => Promise<boolean>
/** پیشنهاد Alt وقتی خالی است (مثل عنوان مقاله یا نام موجودیت) */
/** عنوان تصویر (Title) */
title?: string
onSaveTitle?: (title: string) => Promise<boolean>
/**
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
* هرگز از URL یا نام فایل ساخته نمی‌شود.
*/
altFallback?: string
/** پیشنهاد عنوان تصویر — پیش‌فرض «Photo 1» */
titleFallback?: string
}
const META_FIELD_CLASS =
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
/**
* انتخاب و آپلود تصویر به MinIO — در کشوهای ویرایش و صفحه نوشتن مقاله.
* پس از آپلود موفق، URL نسبی /media/... ذخیره می‌شود.
* اگر Alt خالی باشد، پیش از فراخوانی API از عنوان موجودیت یا نام فایل ساخته می‌شود.
* انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود.
* پیش‌فرض خودکار با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمی‌گردد.
*/
export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback = '' }: ImageFieldProps) {
export function ImageField({
label,
value,
onSave,
alt,
onSaveAlt,
title,
onSaveTitle,
altFallback = '',
titleFallback = '',
}: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null)
const altInputId = useId()
const titleId = useId()
const altId = useId()
const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقدار ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده)
useEffect(() => {
setAltText(alt ?? '')
}, [alt])
const titleField = useSmartTextField({
value: title ?? '',
fallback: titleFallback,
hardFallback: DEFAULT_IMAGE_TITLE,
onCommit: onSaveTitle,
})
const altField = useSmartTextField({
value: alt ?? '',
fallback: altFallback,
hardFallback: label,
onCommit: onSaveAlt,
})
if (!isAuthenticated || !isEditMode) return null
/** متن جایگزین نهایی: ورودی کاربر → عنوان موجودیت → نام فایل */
const resolveAlt = (fileName: string): string => {
const typed = altText.trim()
if (typed) return typed
const fromEntity = altFallback.trim()
if (fromEntity) return fromEntity
return fileName.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || label
}
const pick = async (file: File | undefined) => {
if (!file) return
const generatedAlt = resolveAlt(file.name)
setUploading(true)
try {
await runSave(async () => {
const { url } = await adminApi.upload(file)
const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد')
// Alt ساخته‌شده هم ذخیره می‌شود تا تصویر بدون متن جایگزین نماند
if (showAlt && generatedAlt !== (alt ?? '').trim()) {
setAltText(generatedAlt)
if (onSaveAlt) await onSaveAlt(generatedAlt)
}
// پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit()
if (showAlt) await altField.commit()
})
} finally {
setUploading(false)
}
}
const commitAlt = () => {
const next = altText.trim()
if (!onSaveAlt || next === (alt ?? '').trim()) return
void onSaveAlt(next)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
<div className="mt-2 flex items-center gap-3">
{/* پیش‌نمایش بزرگ تصویر */}
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
{value ? (
<img
src={value}
alt={altText || label}
className="h-16 w-24 border border-ivory/15 object-cover"
alt={altField.display || label}
title={titleField.display || undefined}
className="h-[27rem] w-full object-cover"
/>
) : (
<div className="flex h-16 w-24 items-center justify-center border border-dashed border-ivory/20 text-ivory/30">
—
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
<ImageIcon className="h-7 w-7" aria-hidden="true" />
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
</div>
)}
<div className="min-w-0 flex-1">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : 'انتخاب تصویر'}
</button>
{value && <p className="mt-1.5 truncate text-[10px] font-light text-ivory/35" dir="ltr">{value}</p>}
</div>
</div>
{showAlt && (
<div className="mt-3">
<div className="flex items-center justify-between gap-3">
<label htmlFor={altInputId} className="text-[11px] font-light text-ivory/50">
متن جایگزین (Alt)
</label>
<span className="text-[10px] font-light text-ivory/30">برای سئو و صفحه‌خوان‌ها</span>
</div>
<input
id={altInputId}
type="text"
dir="auto"
value={altText}
onChange={(e) => setAltText(e.target.value)}
onBlur={commitAlt}
placeholder={altFallback ? `پیش‌فرض: ${altFallback}` : 'توضیح کوتاه تصویر…'}
className="mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
/>
<div className="mt-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button>
</div>
{(showTitle || showAlt) && (
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
{showTitle && (
<div>
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
عنوان تصویر (Title)
</label>
<input
id={titleId}
type="text"
dir="auto"
value={titleField.display}
onChange={(event) => titleField.onChange(event.target.value)}
onFocus={titleField.onFocus}
onBlur={titleField.onBlur}
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
className={META_FIELD_CLASS}
/>
</div>
)}
{showAlt && (
<div>
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
متن جایگزین (Alt)
</label>
<input
id={altId}
type="text"
dir="auto"
value={altField.display}
onChange={(event) => altField.onChange(event.target.value)}
onFocus={altField.onFocus}
onBlur={altField.onBlur}
placeholder={
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها'
}
className={META_FIELD_CLASS}
/>
</div>
)}
</div>
)}
@@ -129,9 +171,9 @@ export function ImageField({ label, value, onSave, alt, onSaveAlt, altFallback =
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
void pick(e.target.files?.[0])
e.target.value = ''
onChange={(event) => {
void pick(event.target.files?.[0])
event.target.value = ''
}}
/>
</div>
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react'
import { NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { PlusIcon, XIcon } from 'lucide-react'
type ImageAttrs = {
src: string
alt: string | null
title: string | null
caption: string | null
}
const DELETE_BUTTON_CLASS =
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
const INLINE_FIELD_CLASS =
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
const ADD_CAPTION_CLASS =
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
/**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure>
*
* ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال):
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند: صفت کپشن به مقدار پیش‌فرض برمی‌گردد و عنصر
* figcaption با ورودی‌اش از DOM ویرایشگر (و از خروجی ذخیره‌شده) حذف می‌شود؛
* فقط تصویر می‌ماند و «افزودن کپشن» دوباره جعبه را باز می‌کند.
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت می‌شود.
* - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs
const hasCaption = Boolean((caption ?? '').trim())
const [captionOpen, setCaptionOpen] = useState(true)
const captionInputRef = useRef<HTMLInputElement>(null)
const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption
// فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله)
useEffect(() => {
if (!focusOnOpen.current) return
focusOnOpen.current = false
captionInputRef.current?.focus()
}, [captionOpen])
/** حذف کامل کپشن: عنصر figcaption و ورودی‌اش از DOM ویرایشگر بیرون می‌رود */
const removeCaption = () => {
updateAttributes({ caption: '' })
setCaptionOpen(false)
}
const addCaption = () => {
focusOnOpen.current = true
setCaptionOpen(true)
}
return (
<NodeViewWrapper
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
data-drag-handle
>
<figure className="article-figure relative">
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
{/* حذف کل تصویر */}
<button
type="button"
contentEditable={false}
onClick={() => deleteNode()}
aria-label="حذف تصویر"
title="حذف تصویر"
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
{showCaption ? (
<figcaption className="article-figure__caption" contentEditable={false}>
<input
ref={captionInputRef}
type="text"
value={caption ?? ''}
onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر"
className={INLINE_FIELD_CLASS}
/>
{/* حذف کامل کپشن — تصویر دست‌نخورده می‌ماند */}
<button
type="button"
onClick={removeCaption}
aria-label="حذف کپشن"
title="حذف کپشن (تصویر حذف نمی‌شود)"
className={DELETE_BUTTON_CLASS}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</figcaption>
) : (
<button
type="button"
contentEditable={false}
onClick={addCaption}
className={ADD_CAPTION_CLASS}
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)}
</figure>
</NodeViewWrapper>
)
}
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react'
import type { FormEvent } from 'react'
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
import { buildLinkRel, normalizeHref } from '../../utils/link'
export type LinkModalValue = {
href: string
newTab: boolean
nofollow: boolean
}
type LinkModalProps = {
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
initial: LinkModalValue
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده می‌شود) */
editing: boolean
onApply: (value: LinkModalValue) => void
onRemove: () => void
onClose: () => void
}
const FIELD_CLASS =
'mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
/** مدت انیمیشن ورود/خروج — با duration-300 کلاس‌ها هماهنگ است */
const TRANSITION_MS = 300
/**
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاس‌های Tailwind.
* ورود با یک فریم تاخیر (تا کلاس‌های بسته اعمال شوند) و خروج پیش از بستن مودال اجرا می‌شود.
* جایگزین پنجره پیش‌فرض مرورگر برای لینک شده است.
*/
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
const [href, setHref] = useState(initial.href)
const [newTab, setNewTab] = useState(initial.newTab)
const [nofollow, setNofollow] = useState(initial.nofollow)
const [error, setError] = useState('')
const [visible, setVisible] = useState(false)
const [closing, setClosing] = useState(false)
// انیمیشن ورود: ابتدا حالت بسته رندر می‌شود، سپس به حالت باز می‌رود
useEffect(() => {
const frame = window.requestAnimationFrame(() => setVisible(true))
return () => window.cancelAnimationFrame(frame)
}, [])
/** بستن نرم: اول انیمیشن خروج کامل می‌شود، بعد عملیات و unmount */
const closeWith = (action?: () => void) => {
if (closing) return
setClosing(true)
setVisible(false)
window.setTimeout(() => {
action?.()
onClose()
}, TRANSITION_MS)
}
const submit = (event: FormEvent) => {
event.preventDefault()
const normalized = normalizeHref(href)
if (!normalized) {
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
return
}
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
}
const relPreview = buildLinkRel({ newTab, nofollow })
return (
<div
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
role="dialog"
aria-modal="true"
aria-label="ثبت لینک"
>
{/* پس‌زمینه تار — fade نرم */}
<div
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
visible ? 'opacity-100' : 'opacity-0'
}`}
onClick={() => closeWith()}
aria-hidden="true"
/>
{/* کارت مودال — fade + scale نرم */}
<form
onSubmit={submit}
dir="rtl"
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
}`}
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
<Link2Icon className="h-4 w-4" aria-hidden="true" />
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
</h2>
<button
type="button"
onClick={() => closeWith()}
aria-label="بستن"
className="text-ivory/50 transition-colors hover:text-ivory"
>
<XIcon className="h-4 w-4" />
</button>
</div>
{/* نشانی */}
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
نشانی پیوند (URL)
</label>
<input
id="link-href"
type="text"
dir="ltr"
autoFocus
value={href}
onChange={(event) => {
setHref(event.target.value)
if (error) setError('')
}}
placeholder="https://example.com"
className={FIELD_CLASS}
/>
{error && (
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
{error}
</p>
)}
{/* باز شدن در تب جدید */}
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
<input
type="checkbox"
checked={newTab}
onChange={(event) => setNewTab(event.target.checked)}
className="h-4 w-4 accent-copper"
/>
باز شدن در تب جدید
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target=&quot;_blank&quot;</span>
</label>
{/* نوع لینک از نظر سئو */}
<fieldset className="mt-5">
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
<div className="mt-3 grid gap-2.5">
{[
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل می‌شود', value: false },
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
].map((option) => (
<label
key={option.key}
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
nofollow === option.value
? 'border-copper/60 bg-copper/10'
: 'border-ivory/12 hover:border-ivory/25'
}`}
>
<input
type="radio"
name="link-rel"
checked={nofollow === option.value}
onChange={() => setNofollow(option.value)}
className="mt-0.5 h-4 w-4 accent-copper"
/>
<span className="flex flex-col">
<span className="text-sm font-light text-ivory">{option.label}</span>
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
</span>
</label>
))}
</div>
</fieldset>
{relPreview && (
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
rel=&quot;{relPreview}&quot;
</p>
)}
{/* دکمه‌ها */}
<div className="mt-7 flex items-center justify-between gap-3">
{editing ? (
<button
type="button"
onClick={() => closeWith(onRemove)}
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
>
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
حذف لینک
</button>
) : (
<span />
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => closeWith()}
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
>
انصراف
</button>
<button
type="submit"
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
>
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
</button>
</div>
</div>
</form>
</div>
)
}
+220 -88
View File
@@ -1,89 +1,82 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import Select from 'react-select'
import CreatableSelect from 'react-select/creatable'
import type { StylesConfig } from 'react-select'
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
import { XIcon } from 'lucide-react'
export type LuxeOption = { label: string; value: string }
const COPPER = '#d5874c'
const IVORY = '#F5F3EF'
const SURFACE = '#1e1a16'
const BORDER = 'rgba(245,243,239,0.15)'
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
LuxeOption,
IsMulti,
GroupBase<LuxeOption>
>
/** استایل تیره و لوکس react-select هماهنگ با پالت سایت (مسی روی زغالی) */
function makeStyles<IsMulti extends boolean>(): StylesConfig<LuxeOption, IsMulti> {
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
isFocused
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
}`
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
`!cursor-pointer !rounded-none !text-[13px] ${
isSelected
? '!bg-copper/25 !font-bold !text-copper'
: isFocused
? '!bg-copper/15 !text-ivory'
: '!bg-transparent !text-ivory/85'
}`
/**
* کلاس‌های Tailwind برای همه اسلات‌های react-select — تم تیره پروژه.
* react-select برای هر اسلات تابع می‌خواهد، پس همه با تابع برگردانده می‌شوند.
*/
function makeClassNames<IsMulti extends boolean>(
extras?: Partial<LuxeClassNames<IsMulti>>,
): LuxeClassNames<IsMulti> {
return {
control: (base, state) => ({
...base,
minHeight: 42,
backgroundColor: SURFACE,
borderColor: state.isFocused ? COPPER : BORDER,
borderRadius: 0,
boxShadow: state.isFocused ? '0 0 0 1px rgba(213,135,76,0.35)' : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
':hover': { borderColor: state.isFocused ? COPPER : 'rgba(213,135,76,0.5)' },
}),
valueContainer: (base) => ({ ...base, padding: '2px 12px' }),
placeholder: (base) => ({ ...base, color: 'rgba(245,243,239,0.35)', fontSize: 13 }),
singleValue: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
input: (base) => ({ ...base, color: IVORY, fontSize: 13 }),
menu: (base) => ({
...base,
backgroundColor: SURFACE,
border: `1px solid ${BORDER}`,
borderRadius: 0,
boxShadow: '0 24px 50px -18px rgba(0,0,0,0.75)',
overflow: 'hidden',
zIndex: 60,
}),
menuList: (base) => ({ ...base, padding: 4 }),
menuPortal: (base) => ({ ...base, zIndex: 200 }),
option: (base, state) => ({
...base,
backgroundColor: state.isSelected
? 'rgba(213,135,76,0.25)'
: state.isFocused
? 'rgba(213,135,76,0.12)'
: 'transparent',
color: state.isSelected ? COPPER : 'rgba(245,243,239,0.85)',
fontSize: 13,
fontWeight: state.isSelected ? 700 : 400,
cursor: 'pointer',
borderRadius: 0,
':active': { backgroundColor: 'rgba(213,135,76,0.3)' },
}),
noOptionsMessage: (base) => ({ ...base, color: 'rgba(245,243,239,0.45)', fontSize: 12 }),
indicatorSeparator: (base) => ({ ...base, backgroundColor: BORDER }),
dropdownIndicator: (base, state) => ({
...base,
color: state.isFocused ? COPPER : 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
clearIndicator: (base) => ({
...base,
color: 'rgba(245,243,239,0.4)',
':hover': { color: COPPER },
}),
multiValue: (base) => ({
...base,
backgroundColor: 'rgba(213,135,76,0.15)',
border: '1px solid rgba(213,135,76,0.4)',
borderRadius: 999,
paddingInline: 2,
}),
multiValueLabel: (base) => ({ ...base, color: COPPER, fontSize: 12 }),
multiValueRemove: (base) => ({
...base,
color: COPPER,
borderRadius: 999,
':hover': { backgroundColor: COPPER, color: SURFACE },
}),
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
valueContainer: () => '!gap-1 !px-3 !py-0.5',
placeholder: () => '!text-[13px] !text-ivory/35',
singleValue: () => '!text-[13px] !text-ivory',
input: () => '!text-[13px] !text-ivory',
indicatorsContainer: () => '!text-ivory/40',
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
indicatorSeparator: () => '!bg-ivory/15',
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
menuList: () => '!p-1',
noOptionsMessage: () => '!text-xs !text-ivory/45',
loadingMessage: () => '!text-xs !text-ivory/45',
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
multiValueLabel: () => '!text-xs !text-copper',
...extras,
}
}
/** دکمه ضربدر حذف برچسب انتخاب‌شده — همیشه واضح و قابل کلیک */
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
return (
<div
{...innerProps}
role="button"
aria-label="حذف برچسب"
title="حذف برچسب"
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
>
<XIcon className="h-3 w-3" />
</div>
)
}
const portalTarget = (): HTMLElement | undefined =>
typeof document === 'undefined' ? undefined : document.body
const uniqueOptions = (values: string[]): LuxeOption[] =>
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
type LuxeSelectProps = {
value: string
options: string[]
@@ -94,7 +87,7 @@ type LuxeSelectProps = {
inputId?: string
}
/** react-select تکی با ظاهر تیره پروژه — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
export function LuxeSelect({
value,
options,
@@ -104,9 +97,10 @@ export function LuxeSelect({
isDisabled = false,
inputId,
}: LuxeSelectProps) {
const selectOptions = useMemo(() => [...new Set([...options, ...(value ? [value] : [])])].map(
(item) => ({ label: item, value: item }),
), [options, value])
const selectOptions = useMemo(
() => uniqueOptions([...options, ...(value ? [value] : [])]),
[options, value],
)
return (
<Select<LuxeOption, false>
@@ -119,7 +113,141 @@ export function LuxeSelect({
isClearable={isClearable}
isDisabled={isDisabled}
noOptionsMessage={() => 'موردی یافت نشد'}
styles={makeStyles<false>()}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
type LuxeCreatableSelectProps = {
value: string
options: string[]
onChange: (value: string) => void
placeholder?: string
inputId?: string
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
createLabel?: (input: string) => string
}
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه می‌سازد */
export function LuxeCreatableSelect({
value,
options,
onChange,
placeholder = 'انتخاب یا نوشتن مورد جدید…',
inputId,
createLabel = (input) => `افزودن «${input}»`,
}: LuxeCreatableSelectProps) {
const selectOptions = useMemo(
() => uniqueOptions([...options, ...(value ? [value] : [])]),
[options, value],
)
return (
<CreatableSelect<LuxeOption, false>
inputId={inputId}
classNamePrefix="luxe"
options={selectOptions}
value={selectOptions.find((option) => option.value === value) ?? null}
onChange={(option) => onChange(option?.value?.trim() ?? '')}
placeholder={placeholder}
formatCreateLabel={(input) => createLabel(input)}
noOptionsMessage={() => 'موردی یافت نشد'}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
isClearable
/>
)
}
export type CategoryOption = { label: string; value: string }
/** کلید نگهداری دسته‌های ساخته‌شده در مرورگر */
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
/** دسته‌هایی که ادمین ساخته است — برای مقاله‌های بعدی هم در فهرست می‌مانند */
function readCreatedCategories(): string[] {
try {
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
const parsed: unknown = raw ? JSON.parse(raw) : []
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
}
function storeCreatedCategory(category: string): void {
try {
const current = readCreatedCategories()
if (current.includes(category)) return
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
} catch {
/* حافظه مرورگر در دسترس نیست — بی‌صدا رد شو */
}
}
type CategorySelectProps = {
/** مقدار انتخاب‌شده — یک شیء {value,label} یا null */
value: CategoryOption | null
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
onChange: (option: CategoryOption | null) => void
/** دسته‌های پایه */
options: string[]
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
createLabel?: (input: string) => string
}
/**
* انتخابگر تک‌مقداری دسته‌بندی (react-select Creatable).
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک می‌کند و گزینه‌ها دست‌نخورده می‌مانند.
* - ساخت دسته جدید: به انتهای فهرست گزینه‌ها اضافه و برای مقاله جاری انتخاب می‌شود و
* در همین مرورگر برای مقاله‌های بعدی نگه داشته می‌شود.
*/
export function CategorySelect({
value,
onChange,
options,
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
}: CategorySelectProps) {
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
// گزینه‌های پایه + دسته‌های ساخته‌شده (همیشه در انتهای فهرست)
const selectOptions = useMemo(
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
[options, createdOptions],
)
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
const handleCreate = (input: string) => {
const category = input.trim()
if (!category) return
setCreatedOptions((previous) =>
previous.includes(category) ? previous : [...previous, category],
)
storeCreatedCategory(category)
onChange({ label: category, value: category })
}
return (
<CreatableSelect<CategoryOption, false>
classNamePrefix="luxe"
isMulti={false}
isClearable
options={selectOptions}
value={value}
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
onCreateOption={handleCreate}
formatCreateLabel={(input) => createLabel(input)}
placeholder="انتخاب یا نوشتن دسته جدید…"
noOptionsMessage={() => 'موردی یافت نشد'}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
@@ -130,18 +258,19 @@ type LuxeTagsSelectProps = {
value: string[]
onChange: (value: string[]) => void
placeholder?: string
}
/** هشتگ‌ها با react-select قابل ساخت — هر تایپ جدید با Enter به فهرست اضافه می‌شود */
}/** هشتگ‌ها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
export function LuxeTagsSelect({
value,
onChange,
placeholder = 'هشتگ بنویسید و Enter بزنید…',
}: LuxeTagsSelectProps) {
const options = useMemo(
() => [...new Set(value.filter(Boolean))].map((item) => ({ label: item, value: item })),
[value],
)
const options = useMemo(() => uniqueOptions(value), [value])
/** پاک‌سازی و حذف تکراری‌ها هنگام افزودن یا حذف برچسب */
const applyTags = (next: readonly LuxeOption[]) => {
const tags = next.map((option) => option.value.trim()).filter(Boolean)
onChange([...new Set(tags)])
}
return (
<CreatableSelect<LuxeOption, true>
@@ -149,11 +278,14 @@ export function LuxeTagsSelect({
isMulti
options={options}
value={options}
onChange={(next) => onChange(next.map((option) => option.value))}
onChange={applyTags}
placeholder={placeholder}
formatCreateLabel={(input) => `افزودن «${input}»`}
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
noOptionsMessage={() => 'هشتگی یافت نشد'}
styles={makeStyles<true>()}
closeMenuOnSelect={false}
components={{ MultiValueRemove: TagRemoveButton }}
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
+3 -74
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
import type { ArticleBodyDoc } from '../../data/site'
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
export type SeoInput = {
title: string
@@ -13,78 +14,6 @@ export type SeoInput = {
type Status = 'good' | 'ok' | 'bad'
type Check = { label: string; status: Status; hint: string }
// ---------- استخراج متن/ساختار از سند TipTap ----------
function docToText(body: SeoInput['body']): string {
if (Array.isArray(body)) return body.join(' ')
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && n.text) out.push(n.text)
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out.join(' ')
}
function docHeadings(body: SeoInput['body']): string[] {
if (Array.isArray(body)) return []
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] }
if (n.type === 'heading' && n.attrs?.level === 2) {
const texts: string[] = []
const collect = (inner: unknown) => {
if (!inner || typeof inner !== 'object') return
const t = inner as { type?: string; text?: string; content?: unknown[] }
if (t.type === 'text' && t.text) texts.push(t.text)
for (const c of t.content ?? []) collect(c)
}
collect(n)
out.push(texts.join(''))
}
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function docImages(body: SeoInput['body']): { src: string; alt: string }[] {
if (Array.isArray(body)) return []
const out: { src: string; alt: string }[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] }
if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' })
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function docLinks(body: SeoInput['body']): string[] {
if (Array.isArray(body)) return []
const out: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] }
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href)
for (const c of n.content ?? []) walk(c)
}
walk(body)
return out
}
function firstParagraph(body: SeoInput['body']): string {
if (Array.isArray(body)) return body[0] ?? ''
const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find(
(c) => c.type === 'paragraph',
)
return docToText({ type: 'doc', content: p ? [p] : [] } as never)
}
// ---------- محاسبه بررسی‌ها ----------
export function useSeoChecks(input: SeoInput): Check[] {
@@ -117,7 +46,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
)
const h2s = docHeadings(input.body)
const h2s = docHeadings(input.body, 2)
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
else
checks.push(
@@ -140,7 +69,7 @@ export function useSeoChecks(input: SeoInput): Check[] {
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
// تیتر H2
const h2s = docHeadings(input.body)
const h2s = docHeadings(input.body, 2)
if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' })
else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخش‌بندی کنید.' })
@@ -0,0 +1,121 @@
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
import type { EditorView } from '@tiptap/pm/view'
import type { ResolvedPos } from '@tiptap/pm/model'
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
export type BlockFormat = 'paragraph' | 1 | 2 | 3
type LineBounds = {
/** ابتدای بلوک جاری */
start: number
/** پایان بلوک جاری */
end: number
/** ابتدای خط جاری (پس از نزدیک‌ترین شکست نرم قبل از نشانگر) */
lineStart: number
/** پایان خط جاری (نزدیک‌ترین شکست نرم پس از نشانگر) */
lineEnd: number
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
before: number | null
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
after: number | null
}
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شده‌اند */
function lineBounds($from: ResolvedPos): LineBounds {
const start = $from.start()
const end = $from.end()
let lineStart = start
let lineEnd = end
let before: number | null = null
let after: number | null = null
$from.parent.forEach((node, offset) => {
if (node.type.name !== 'hardBreak') return
const pos = start + offset
if (pos < $from.pos) {
lineStart = pos + 1
before = pos
} else if (after === null) {
lineEnd = pos
after = pos
}
})
return { start, end, lineStart, lineEnd, before, after }
}
/**
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسبانده‌شده از Word).
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض می‌کند.
* اگر گزینش از خط عبور کند (قالب‌بندی چندخطیِ عمدی) دست‌نخورده می‌ماند.
*/
function isolateCurrentLine(view: EditorView): boolean {
const { state } = view
const { selection } = state
const bounds = lineBounds(selection.$from)
if (bounds.before === null && bounds.after === null) return false
const selectionInsideLine =
selection.empty ||
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
if (!selectionInsideLine) return false
const { tr } = state
if (bounds.after !== null) {
tr.delete(bounds.after, bounds.after + 1)
tr.split(bounds.after)
}
if (bounds.before !== null) {
tr.delete(bounds.before, bounds.before + 1)
tr.split(bounds.before)
}
if (!tr.docChanged) return false
view.dispatch(tr)
return true
}
/**
* قالب‌بندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
* پاراگراف برمی‌گرداند (رفتار toggle).
*/
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
view.focus()
isolateCurrentLine(view)
const { state } = view
const paragraph = state.schema.nodes.paragraph
const heading = state.schema.nodes.heading
if (!paragraph || !heading) return false
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
const current = state.selection.$from.parent
const sameHeading = current.type === heading && current.attrs.level === format
return sameHeading
? setBlockType(paragraph)(state, view.dispatch, view)
: setBlockType(heading, { level: format })(state, view.dispatch, view)
}
/**
* Enter داخل تیتر: بلوک تازه پاراگراف می‌شود (نه تیتر تازه) تا قالب تیتر به خط
* بعدی سرایت نکند و مرز بلوک‌ها تمیز بماند.
* این هندلر پیش از keymap پیش‌فرض اجرا می‌شود و فقط همین حالت را می‌گیرد.
*/
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
return false
}
const { state } = view
const { selection } = state
const paragraph = state.schema.nodes.paragraph
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل می‌شود
if (!splitBlock(state, view.dispatch, view)) return false
setBlockType(paragraph)(view.state, view.dispatch, view)
return true
}
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useRef, useState } from 'react'
type SmartTextFieldOptions = {
/** مقدار ذخیره‌شده در بیرون (منبع حقیقت) */
value: string
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره می‌شود */
fallback?: string
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
hardFallback?: string
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیره‌شده تغییر کرده باشد */
onCommit?: (next: string) => void | Promise<unknown>
}
/**
* فیلد متنی با «پیش‌فرض هوشمند» برای Alt/Title تصویر:
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی می‌ماند؛ در حین تایپ نه trim می‌شود و نه از
* بیرون بازنویسی می‌شود، پس فاصله انتهای متن و جای نشانگر حفظ می‌شود.
* - با فوکوس، پیش‌فرض خودکار پاک می‌شود تا کاربر بلافاصله تایپ کند.
* - با بلور، اگر فیلد خالی بماند پیش‌فرض خودکار برمی‌گردد و مقدار نهایی ذخیره می‌شود.
*/
export function useSmartTextField({
value,
fallback = '',
hardFallback = '',
onCommit,
}: SmartTextFieldOptions) {
const [text, setText] = useState(value)
const [focused, setFocused] = useState(false)
// تازه‌ترین مقادیر در ref نگه داشته می‌شوند تا هندلرها به closure قدیمی وابسته نباشند
const textRef = useRef(text)
textRef.current = text
const focusedRef = useRef(focused)
focusedRef.current = focused
const fallbackRef = useRef(fallback)
fallbackRef.current = fallback
const hardFallbackRef = useRef(hardFallback)
hardFallbackRef.current = hardFallback
const valueRef = useRef(value)
valueRef.current = value
// همگام‌سازی با مقدار ذخیره‌شده از بیرون (بارگذاری مقاله، به‌روزرسانی خوش‌بینانه)
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر می‌پرد و فاصله‌ها می‌خورند
useEffect(() => {
if (focusedRef.current) return
setText(value)
}, [value])
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیش‌فرض ثابت */
const resolve = useCallback((): string => {
const current = textRef.current.trim()
if (current) return current
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
}, [])
const commit = useCallback(async (): Promise<void> => {
const next = resolve()
setText(next)
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
}, [onCommit, resolve])
const onFocus = useCallback(() => {
setFocused(true)
// پیش‌فرض خودکار متن واقعی نیست؛ با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند
const current = textRef.current.trim()
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
if (!current || current === auto) setText('')
}, [])
const onBlur = useCallback(() => {
setFocused(false)
void commit()
}, [commit])
const onChange = useCallback((next: string) => {
setText(next)
}, [])
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده می‌شود */
const display = focused ? text : text !== '' ? text : fallback
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
}
+7 -6
View File
@@ -1,15 +1,17 @@
import React from 'react';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button';
import { PremiumButton } from '../PremiumButton';
import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext';
import { InlineEdit } from '../admin/InlineEdit';
import { AddItemButton, EditableItem } from '../../admin/Editable';
export function Articles() {
const { articles } = useSite();
const { href } = useLang();
const heading = useSectionHeading('articles');
const save = useFieldSaver();
@@ -25,7 +27,7 @@ export function Articles() {
return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right">
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
<p className="text-sm font-light text-copper">
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
</p>
@@ -51,10 +53,9 @@ export function Articles() {
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
<PremiumButton to={href(heading.cta_href)} className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</PremiumButton>
</div>
</Reveal>
)}
+3 -1
View File
@@ -3,10 +3,12 @@ import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { SingletonPill } from '../../admin/Editable'
export function BeforeAfter() {
const { before_after } = useSite()
const { t } = useLang()
const containerRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState(52)
const dragging = useRef(false)
@@ -102,7 +104,7 @@ export function BeforeAfter() {
<button
type="button"
role="slider"
aria-label="مقایسه کانسپت و پروژه ساخته‌شده"
aria-label={t('compareAria')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(position)}
+13 -9
View File
@@ -4,6 +4,7 @@ import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable'
type FaqProps = {
@@ -12,24 +13,27 @@ type FaqProps = {
export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite()
const { t } = useLang()
const [open, setOpen] = useState<number | null>(0)
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
{/* Header — centered, full width */}
{!hideHeading && (
<div className="lg:col-span-4">
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
<SectionHeading
title="سوالات متداول"
lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."
title={t('fallbackFaqTitle')}
lead={t('fallbackFaqLead')}
headingKey="faq"
align="center"
/>
</div>
)}
<div className={hideHeading ? "" : "lg:col-span-8"}>
{/* Accordion list — constrained width, direction-aware */}
<div className="w-full max-w-3xl mx-auto">
<ul>
{faqs.map((faq, i) => {
const isOpen = open === i
@@ -41,9 +45,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
@@ -61,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
</motion.div>
)}
</AnimatePresence>
@@ -1,20 +1,23 @@
import React, { useState } from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site'
import { projectsData } from '../../data/projectsData'
export function FeaturedProjects() {
const { projects, status_labels } = useSite()
const { status_labels } = useSite()
const { t, href } = useLang()
const heading = useSectionHeading('featured_projects')
const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key)
if (!label) return
@@ -28,8 +31,12 @@ export function FeaturedProjects() {
{ key: 'done', label: useStatusLabel('done') },
{ key: 'progress', label: useStatusLabel('progress') },
]
const [status, setStatus] = useState<ProjectStatus>('done')
const list = projects.filter((p) => p.status === status).slice(0, 3)
// Filter and slice static project data
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
const BASE_IMG_URL = "/seed-media/2026/09/";
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
@@ -37,12 +44,11 @@ export function FeaturedProjects() {
<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="فیلتر وضعیت پروژه">
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => {
const isSelected = status === filter.key
return (
<React.Fragment key={filter.key}>
{/* خط جداکننده بین دکمه‌ها */}
{index > 0 && (
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
)}
@@ -80,19 +86,26 @@ export function FeaturedProjects() {
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="grid gap-6 lg:grid-cols-3"
>
{list.map((project, i) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<EditableItem resource="projects" item={project} className="h-full">
<ProjectCard project={project} />
</EditableItem>
</motion.div>
))}
{list.map((project, i) => {
const dummyImageName = `${(i % 6) + 1}.png`;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
className="h-full"
>
<ProjectCard
project={project}
imageUrl={imageUrl}
detailHref={`/projects/${project.slug}`}
/>
</motion.div>
)
})}
</motion.div>
</AnimatePresence>
</div>
@@ -101,24 +114,17 @@ export function FeaturedProjects() {
{heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}>
<div className="mt-12 flex justify-center">
<ButtonLink
to={heading.cta_href}
variant="outline"
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
>
<PremiumButton to={href(heading.cta_href)}>
<InlineEdit
value={heading.cta_label}
onSave={(v) =>
save('section-headings', heading.id, { cta_label: v }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id)
if (h) h.cta_label = v
if (h) (h as unknown as Record<string, unknown>)["cta_label"] = v
})
}
/>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
<ArrowLeftIcon className="h-3.5 w-3.5" />
</span>
</ButtonLink>
</PremiumButton>
</div>
</Reveal>
)}
+64 -40
View File
@@ -4,10 +4,12 @@ import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { useLang, formatNumber } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Gallery() {
const { gallery } = useSite()
const { t, lang } = useLang()
const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), [])
@@ -30,8 +32,8 @@ export function Gallery() {
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading
title="گالری تصاویر"
lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش."
title={t('fallbackGalleryTitle')}
lead={t('fallbackGalleryLead')}
headingKey="gallery"
align="center"
/>
@@ -44,7 +46,7 @@ export function Gallery() {
type="button"
onClick={() => setActive(i)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`بزرگ‌نمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
>
<img
src={image.src}
@@ -62,59 +64,81 @@ export function Gallery() {
<AnimatePresence>
{active !== null && gallery[active] && (
<motion.div
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
role="dialog"
aria-modal="true"
aria-label="نمایش تصویر"
aria-label={t('viewImage')}
onClick={close}
>
{/* Close Button */}
<button
type="button"
onClick={close}
className="absolute left-8 top-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"
aria-label="بستن"
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('close')}
>
<XIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(-1)
}}
className="absolute right-6 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:right-10"
aria-label="تصویر قبلی"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
step(1)
}}
className="absolute left-6 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"
aria-label="تصویر بعدی"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
<motion.img
key={active}
src={gallery[active].src}
alt=""
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
{/* Flex Container for Prev, Image, Next */}
<div
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
onClick={(e) => e.stopPropagation()}
/>
>
<button
type="button"
onClick={() => step(-1)}
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('prevImage')}
>
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
</button>
<motion.img
key={active}
src={gallery[active].src}
alt={gallery[active].alt}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
/>
<button
type="button"
onClick={() => step(1)}
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
aria-label={t('nextImage')}
>
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
</button>
</div>
{/* Modern Dot Pagination */}
<div
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
onClick={(e) => e.stopPropagation()}
>
{gallery.map((_, idx) => {
const isActive = idx === active;
return (
<button
key={idx}
type="button"
onClick={() => setActive(idx)}
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
}`}
/>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
+16 -39
View File
@@ -1,14 +1,16 @@
import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { ChevronDownIcon } from 'lucide-react'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
export function Hero() {
const { brand, hero } = useSite()
const { t, href } = useLang()
const save = useFieldSaver()
const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110])
@@ -21,11 +23,11 @@ export function Hero() {
})
return (
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label={t('heroAria')}>
{/* تصویر پس‌زمینه */}
<motion.img
src={brand.hero}
alt="نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش"
alt={t('heroImageAlt')}
style={{ y: imageY }}
className="absolute inset-0 h-[114%] w-full object-cover"
/>
@@ -62,7 +64,7 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
>
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
</motion.h1>
{/* زیرتیتر */}
@@ -82,51 +84,26 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4"
>
{/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<ButtonLink
to={hero.cta_primary_href}
variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
>
<span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 group-hover:-translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
</span>
</ButtonLink>
{/* دکمه اصلی */}
<PremiumButton to={href(hero.cta_primary_href)}>
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</PremiumButton>
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<ButtonLink
to={hero.cta_secondary_href}
variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
>
{/* دکمه ثانویه */}
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</ButtonLink>
</PremiumButton>
</motion.div>
</div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */}
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 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: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>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
<a
href="#story"
aria-label="رفتن به بخش بعدی"
aria-label={t('scrollDown')}
className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper"
>
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
+16 -15
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react'
import { CheckIcon, SendIcon } from 'lucide-react'
import { CheckIcon } from 'lucide-react'
import Swal from 'sweetalert2'
import { Reveal } from '../Reveal'
import { Button } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
@@ -11,6 +12,7 @@ import { API_URL } from '../../config'
export function Newsletter() {
const { newsletter } = useSite()
const { t } = useLang()
const save = useFieldSaver()
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
@@ -29,7 +31,7 @@ export function Newsletter() {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
confirmButtonText: t('understood'),
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-md bg-ink/70',
@@ -58,13 +60,13 @@ export function Newsletter() {
if (!emailValue) {
setStatus('error')
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
showErrorAlert(t('incompleteInfo'), t('emailRequired'))
return
}
if (!emailRegex.test(emailValue)) {
setStatus('error')
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText'))
return
}
@@ -80,8 +82,8 @@ export function Newsletter() {
setStatus('error')
const data = await res.json().catch(() => null)
const detail =
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
showErrorAlert('خطا در عضویت', detail)
typeof data?.detail === 'string' ? data.detail : t('emailCheck')
showErrorAlert(t('subscribeErrorTitle'), detail)
return
}
@@ -89,14 +91,14 @@ export function Newsletter() {
setEmail('')
} catch {
setStatus('error')
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
} finally {
setSending(false)
}
}
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" />
{/* هاله نور پس‌زمینه برای عمق بصری */}
@@ -140,19 +142,18 @@ export function Newsletter() {
)}
</div>
<Button
<button
type="submit"
variant="dark"
disabled={sending}
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
className="group/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
<span>
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
{sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)}
</span>
</Button>
</button>
</form>
</Reveal>
</div>
+58 -22
View File
@@ -1,41 +1,77 @@
import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion';
import { useSite } from '../../api';
import { useLang, formatNumber } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext';
import { AddItemButton, EditableItem } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
function toFa(n: number) {
return n.toLocaleString('fa-IR');
}
function Counter({ value }: {value: number;}) {
function useCounter(targetNumber: number, duration: number = 2000) {
const [count, setCount] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!inView) return;
let frame = 0;
const total = 60;
const id = window.setInterval(() => {
frame += 1;
const progress = 1 - Math.pow(1 - frame / total, 3);
setDisplay(Math.round(value * progress));
if (frame >= total) window.clearInterval(id);
}, 20);
return () => window.clearInterval(id);
}, [inView, value]);
const element = ref.current;
if (!element) return;
return <span ref={ref}>{toFa(display)}</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() {
const { stats } = useSite();
const { t } = useLang();
const save = useFieldSaver();
return (
<section className="bg-char py-16" aria-label="آمار و ارقام">
<section className="bg-char 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">
{stats.map((stat) =>
<EditableItem key={stat.id} resource="stats" item={stat}>
@@ -48,7 +84,7 @@ export function Stats() {
const s = d.stats.find((x) => x.id === stat.id);
if (s) s.suffix = v;
})}
className="mr-1 text-2xl"
className="ltr:ml-1 rtl:mr-1 text-2xl"
/>
</p>
<p className="mt-3 text-sm font-light text-ivory/55">
+7 -6
View File
@@ -1,8 +1,9 @@
import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button'
import { PremiumButton } from '../PremiumButton'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
@@ -17,6 +18,7 @@ type StoryProps = {
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
const { story } = useSite()
const { href } = useLang()
const save = useFieldSaver()
const saveStory = (field: string, value: unknown) =>
@@ -87,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && (
<Reveal delay={0.22}>
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
</PremiumButton>
</Reveal>
)}
@@ -102,7 +103,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
</div>
<div className="relative grid grid-cols-2 gap-4">
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 right-0" />
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 ltr:left-0 rtl:right-0" />
{story.bento.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden">
+9 -6
View File
@@ -2,16 +2,18 @@ import React from 'react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Team() {
const { team } = useSite()
const { t } = useLang()
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title" >
<div className="mx-auto max-w-site px-6 lg:px-10 ">
<SectionHeading
title="تیم ما"
lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."
title={t('fallbackTeamTitle')}
lead={t('fallbackTeamLead')}
headingKey="team"
align="center"
/>
@@ -20,7 +22,8 @@ export function Team() {
{team.map((member, i) => (
<Reveal key={member.id} delay={0.06 * i} className="h-full">
<EditableItem resource="team" item={member} className="h-full">
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
{/* اضافه شدن rounded-2xl برای گرد شدن گوشه‌ها */}
<article className="group flex h-full flex-col items-center rounded-2xl border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
{member.name.trim().charAt(0)}
</div>
@@ -38,4 +41,4 @@ export function Team() {
</div>
</section>
)
}
}
+10 -8
View File
@@ -3,10 +3,12 @@ import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading';
import { useSite } from '../../api';
import { useLang } from '../../i18n/lang';
import { EditableItem } from '../../admin/Editable';
export function Testimonials() {
const { testimonials } = useSite();
const { t } = useLang();
const [index, setIndex] = useState(0);
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
if (!current) return null;
@@ -44,20 +46,20 @@ export function Testimonials() {
<button
type="button"
onClick={() => go(1)}
aria-label="نظر قبلی"
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"
>
<ChevronRightIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:translate-x-0.5" />
<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" />
</button>
<div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر">
{testimonials.map((t, i) => (
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
{testimonials.map((item, i) => (
<button
key={t.id}
key={item.id}
type="button"
role="tab"
aria-selected={i === index}
aria-label={`نظر ${t.name}`}
aria-label={`${t("testimonialByPrefix")} ${item.name}`}
onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
i === index
@@ -71,10 +73,10 @@ export function Testimonials() {
<button
type="button"
onClick={() => go(-1)}
aria-label="نظر بعدی"
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"
>
<ChevronLeftIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:-translate-x-0.5" />
<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" />
</button>
</div>
</div>
+9 -5
View File
@@ -1,6 +1,7 @@
import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api'
import { useLang } from '../../i18n/lang'
import { SectionHeading } from '../SectionHeading'
import { AddItemButton, EditableItem } from '../../admin/Editable'
@@ -15,6 +16,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
return (
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
{/* دایره‌ها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
<span
aria-hidden="true"
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
@@ -34,7 +36,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
>
<EditableItem resource="timeline" item={item}>
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
{/* لایه نوری ظریف که در هاور روشن می‌شود */}
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
@@ -49,6 +50,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
export function Timeline() {
const { timeline } = useSite()
const { t } = useLang()
const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: ref,
@@ -62,21 +64,23 @@ export function Timeline() {
<div className="mb-16">
<SectionHeading
title="مسیر ما"
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."
title={t('fallbackTimelineTitle')}
lead={t('fallbackTimelineLead')}
headingKey="timeline"
align="center"
/>
</div>
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
{/* خط پس‌زمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
<span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
/>
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
<motion.span
aria-hidden="true"
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
style={{ scaleY }}
/>
+3 -1
View File
@@ -5,9 +5,11 @@ import { useSectionHeading, useSite } from '../../api';
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit';
import { useFieldSaver } from '../../admin/AdminContext';
import { useLang, formatNumber } from '../../i18n/lang';
export function WhyUs() {
const { why_us } = useSite();
const { lang } = useLang();
const heading = useSectionHeading('why_us');
const save = useFieldSaver();
@@ -43,7 +45,7 @@ export function WhyUs() {
<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">۰{(i + 1).toLocaleString('fa-IR')}</span>
<span className="text-xs font-light text-copper">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</span>
<h3 className="mt-4 text-lg font-bold text-ivory">
<InlineEdit
value={item.title}
+5
View File
@@ -1,3 +1,8 @@
// آدرس بک‌اند — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ (/api از طریق Traefik).
// فقط اگر بک‌اند روی مبدأ دیگری سرو می‌شود VITE_API_URL را تنظیم کنید.
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
// آدرس پایه کاشی‌های نقشه (MinIO: style.json، iran.pmtiles، فونت‌ها و اسپرایت).
// در صورت تغییر مبدا سرو کاشی‌ها VITE_MAP_URL را تنظیم کنید.
export const MAP_BASE_URL: string =
(import.meta.env.VITE_MAP_URL as string | undefined) ?? 'http://localhost:19000/maps'
+18
View File
@@ -0,0 +1,18 @@
/**
* ثابت‌های مشترک رابط کاربری — تنها منبع مقادیر پیش‌فرض (بدون متن پراکنده در کامپوننت‌ها).
*/
/** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
export const DEFAULT_IMAGE_TITLE = 'Photo 1'
/** متن جایگزین پیش‌فرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمی‌دهد) */
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
/** سرعت مطالعه مبنای محاسبه زمان مطالعه — واژه در دقیقه */
export const WORDS_PER_MINUTE = 200
/** برچسب زمان مطالعه در خروجی */
export const READING_TIME_LABEL = 'دقیقه مطالعه'
+247
View File
@@ -0,0 +1,247 @@
/**
* مختصات پروژه‌های گروه ساختمانی آرامش — برگرفته از شیت «Sheet1.csv».
*
* دو نکته‌ی داده‌ای که در همان‌جا حل می‌شوند:
* ۱) ستون‌های neshan_url و google_maps_url در شیت فقط برچسب بودند
* («Open in Neshan») و نشانی واقعی نداشتند؛ پس پیوندها از روی مختصات
* ساخته می‌شوند که الگوی تثبیت‌شده‌ی هر دو سرویس است.
* ۲) ستون توضیحات خالی است؛ متن پیش‌فرض در لایه‌ی رابط کاربری جایگزین می‌شود.
*/
import type { Project, ProjectCardItem } from './site'
export type MapProject = {
id: number
name: string
/** سال شمسی — ممکن است خالی باشد */
year: string
/** پروژه‌ی در جریان (ستون new شیت) */
isNew: boolean
lat: number
lng: number
description: string
}
/** مرکز پیش‌فرض نقشه — اصفهان */
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
/**
* ستون‌های برچسبی شیت حذف شده‌اند (نشانی واقعی نداشتند) و ستون توضیحات به
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
*/
const RAW_CSV = `name,year,new,lat,lng,description
پروژه سروش,1381,no,32.6662842,51.701735,
چهرازی-زیتون,1383,no,32.6700428,51.69985,
جعفریان,1384,no,32.6669259,51.701085,
بهار,1385,yes,32.6468963,51.6899119,
حسن مرادی,1385,no,32.6704278,51.6985567,
کوثر,1386,no,32.6522044,51.6856474,
صبا,1387,no,32.6541363,51.6847828,
پورمعالی - بهشت,1387,no,32.6500402,51.6919823,
2 پروژه- فردوس,1388,no,32.6614576,51.706429,
ارم,1388,no,32.6563712,51.710694,
سینا,1388,no,32.6469299,51.6855403,
مسجد سید,1389,yes,32.6669953,51.6557085,
هفده شهریور,1389,yes,32.7249998,51.6543819,
آرامش رفیع زاده,1390,no,32.6467107,51.6873398,
بن بست شریعت,1390,yes,32.6459939,51.6897556,
قصر - یاسین,1390,no,32.6663549,51.7065512,
پردیس,1390,no,32.6518961,51.6973621,
آرامش حکیم,1392,no,32.6426783,51.6813477,
آرامش دخانیات,1393,no,32.6411924,51.6797555,
بهشتی فرشادی,1393,no,32.6531989,51.6790984,
کاکتوس,1394,no,32.6486747,51.6859003,
آرامش - صیاد,1395,no,32.6486798,51.712659,
کوثر,1395,yes,32.6506204,51.6344053,
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424,
میرزا نصیر,1396,no,32.668195,51.7173079,
نیکان,1396,yes,32.6504663,51.6355982,
آرامش-رسا,1397,no,32.6522126,51.6857297,
نگین,1397,yes,32.6507084,51.6313395,
آرامش - بوعلی 2,1398,no,32.645391,51.6917828,
آرامش استاد همایی,1398,no,32.6563505,51.691988,
آرامش صدر,1398,no,32.6469234,51.6826176,
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268,
آرامش- صدر,1399,no,32.6469205,51.6829887,
آرامش - سنایی,1400,yes,32.6973339,51.6194128,
مغازه لباس فروشی,1400,no,32.6516965,51.7020497,
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244,
آرامش - بزرگمهر,1401,no,32.651381,51.6993575,
آرامش - سعدی,1401,yes,32.6970115,51.6196909,
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248,
آرامش 2,1401,yes,32.6505704,51.6337863,
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827,
آرامش احمدآباد,1401,no,32.6626101,51.6969828,
تجاری آتشگاه,1401,yes,32.6515087,51.6033278,
آرامش - شهیدان,1402,yes,32.6854968,51.6171964,
آرامش خواجو,1402,no,32.6419187,51.6831873,
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519,
آرامش - انقلاب,1403,yes,32.719769,51.645921,
آرامش - شریف,1404,no,32.6473319,51.6955996,
آرامش - کهندژ,1404,yes,32.6649497,51.630077,
آرامش جی,1404,no,32.6610717,51.7110184,
آرامش - بابک,1405,yes,32.674076,51.6368204,
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771,
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643,
آرامش - وحدت,1405,yes,32.6537564,51.6197007,
آرامش پروین,1405,no,32.6618223,51.7098152,
خلیفه سلطانی,1405,yes,32.7035398,51.6223296,
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975,
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342,
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436,
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,`
/**
* تجزیه‌ی یک خط CSV — پشتیبانی از فیلدهای گیومه‌دار و گیومه‌ی دوگانه‌ی درونی.
* جداکننده کاما و نویسه‌های مخفی ابتدای فایل (BOM) هم حذف می‌شوند.
*/
function parseCsvLine(line: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i += 1) {
const char = line[i]
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
current += '"'
i += 1
} else inQuotes = false
} else current += char
} else if (char === '"') inQuotes = true
else if (char === ',') {
fields.push(current.trim())
current = ''
} else current += char
}
fields.push(current.trim())
return fields
}
/** CSV خام → آرایه‌ی تایپ‌شده‌ی JSON */
export function parseProjectsCsv(raw: string): MapProject[] {
const rows: CsvRow[] = []
const lines = raw.replace(/^\ufeff/, '').split(/\r?\n/)
for (const line of lines.slice(1)) {
if (!line.trim()) continue
const [name, year = '', isNew = '', lat = '', lng = '', description = ''] = parseCsvLine(line)
const parsedLat = Number(lat)
const parsedLng = Number(lng)
if (!name || !Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) continue
rows.push({ name, year, isNew: isNew.toLowerCase() === 'yes', lat: parsedLat, lng: parsedLng, description })
}
return rows.map((row, index) => ({ id: index + 1, ...row }))
}
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
/** پیوند نشان روی نقشه برای سرویس نشان */
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string {
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z`
}
/** پیوند نقشه گوگل */
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
return `https://www.google.com/maps?q=${project.lat},${project.lng}`
}
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
/**
* نرمال‌سازی نام برای تطبیق شیت با داده‌ی بک‌اند: نویسه‌های نامرئی،
* ی/ک عربی، نشانه‌ها و واژه‌های عمومی حذف می‌شوند تا «آرامش - سنایی» و
* «پروژه سنایی» یک کلید بگیرند.
*/
export function normalizeProjectName(name: string): string {
let result = (name ?? '')
.normalize('NFC')
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim()
for (const noise of NAME_NOISE) result = result.replace(new RegExp(`(^|\\s)${noise}(?=\\s|$)`, 'g'), ' ')
return result.replace(/\s+/g, ' ').trim()
}
/** پروژه‌ی شیت متناظر با یک نام (تطبیق دقیق نرمال‌شده، سپس دربرگیری) */
export function findMapProjectByName(name: string): MapProject | undefined {
const key = normalizeProjectName(name)
if (!key) return undefined
const exact = MAP_PROJECTS.find((project) => normalizeProjectName(project.name) === key)
if (exact) return exact
return MAP_PROJECTS.find((project) => {
const candidate = normalizeProjectName(project.name)
return candidate.length >= 3 && key.length >= 3 && (candidate.includes(key) || key.includes(candidate))
})
}
/**
* محل جایگزین وقتی پروژه‌ای در شیت پیدا نشد — مرکز اصفهان با نام پروژه‌ی
* بک‌اند، تا نمای تک‌پروژه هرگز بدون نقشه نماند.
*/
export function createPlaceholderProject(name: string, description = ''): MapProject {
return {
id: 0,
name,
year: '',
isNew: false,
lat: ISFAHAN_CENTER[1],
lng: ISFAHAN_CENTER[0],
description,
}
}
/**
* کادر محدودهٔ نقشه از مختصات پروژه‌ها — با حاشیه تا لبهٔ نقشه خالی نماند و
* کاربر به بیرون از محدودهٔ پروژه‌ها جابه‌جا نشود.
*/
export function boundsForProjects(
projects: Pick<MapProject, 'lat' | 'lng'>[],
padding = 0.02,
): [[number, number], [number, number]] | undefined {
if (!projects.length) return undefined
const lats = projects.map((project) => project.lat)
const lngs = projects.map((project) => project.lng)
return [
[Math.min(...lngs) - padding, Math.min(...lats) - padding],
[Math.max(...lngs) + padding, Math.max(...lats) + padding],
]
}
/** ردیف شیت → کارت فهرست: عنوان از شیت، وضعیت از ستون new، نشانی از مختصات */
export function csvProjectToCard(project: MapProject, detailHref: string): ProjectCardItem {
return {
id: project.id,
title: project.name,
status: project.isNew ? 'progress' : 'done',
detailHref,
addressUrl: buildNeshanUrl(project),
}
}
/** پروژهٔ بک‌اند → کارت فهرست؛ نشانی نشان از تطبیق نامی با شیت می‌آید */
export function apiProjectToCard(project: Project, detailHref: string): ProjectCardItem {
const matched = findMapProjectByName(project.title)
return {
id: project.id,
title: project.title,
status: project.status,
detailHref,
location: project.location || undefined,
image: project.image || undefined,
area: project.area || undefined,
addressUrl: matched ? buildNeshanUrl(matched) : undefined,
}
}
+74
View File
@@ -0,0 +1,74 @@
export type BilingualText = { en: string; fa: string };
export type StaticProject = {
id: string;
slug: string;
name: BilingualText;
location: BilingualText;
area: BilingualText;
status: 'done' | 'progress';
};
export const projectsData: StaticProject[] = [
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
];
+39 -1
View File
@@ -1,9 +1,12 @@
// انواع مشترک داده‌ها — همه محتوای متنی سایت از بک‌اند (GET /api/site) بارگذاری می‌شود.
// فیلدهای *_en معادل انگلیسی‌اند (اختیاری — خالی یعنی بدون ترجمه و برگشت به فارسی).
export type Service = {
id: number;
title: string;
title_en?: string;
desc: string;
desc_en?: string;
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
};
@@ -13,17 +16,30 @@ export type Project = {
id: number;
slug: string;
title: string;
title_en?: string;
location: string;
location_en?: string;
year: string;
year_en?: string;
status: ProjectStatus;
image: string;
gallery: string[];
area: string;
area_en?: string;
floors: string;
floors_en?: string;
units: string;
units_en?: string;
client: string;
client_en?: string;
/** مختصات جغرافیایی پروژه — عرض جغرافیایی؛ ستون‌های آینده بک‌اند، اختیاری */
lat?: number;
/** مختصات جغرافیایی پروژه — طول جغرافیایی؛ ستون‌های آینده بک‌اند، اختیاری */
lng?: number;
summary: string;
summary_en?: string;
body: string[];
body_en?: string[] | null;
};
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
@@ -33,9 +49,12 @@ export type ArticleStatus = 'draft' | 'published';
export type Article = {
id: number;
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: 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;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[];
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
body_en?: ArticleBodyDoc | null;
/** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */
status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
@@ -44,8 +63,27 @@ export type Article = {
meta_description?: string;
/** متن جایگزین تصویر شاخص (Alt) */
image_alt?: string;
/** عنوان تصویر شاخص (Title) */
image_title?: string;
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */
author?: string;
/** هشتگ‌ها/برچسب‌های مقاله */
tags?: string[];
};
/**
* شکل پروژه در کارت‌های فهرست — از پروژهٔ بک‌اند یا ردیف شیت پر می‌شود؛
* فیلدهای اختیاری فقط وقتی رندر می‌شوند که داده‌شان موجود باشد.
*/
export type ProjectCardItem = {
id: number | string;
title: string;
status: ProjectStatus;
/** مسیر صفحهٔ جزئیات پروژه */
detailHref: string;
/** نشانی بیرونی پروژه (نشان) — در نبودش دکمهٔ آن رندر نمی‌شود */
addressUrl?: string;
location?: string;
image?: string;
area?: string;
};
+232
View File
@@ -0,0 +1,232 @@
// دیکشنری دوزبانه رشته‌های هاردکد رابط کاربری (بدون محتوای دیتابیس).
// کلید فارسی مرجع است؛ متن‌های دیتابیس (سئو، پروژه‌ها، مقاله‌ها و...) از localize.ts می‌آیند.
export const fa = {
// عمومی
brandFallback: 'گروه ساختمانی آرامش',
loading: 'در حال بارگذاری',
errorTitle: 'اتصال به سرور برقرار نشد',
errorText: 'محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.',
retry: 'تلاش دوباره',
backToTop: 'بازگشت به بالا',
switchLang: 'English',
switchLangAria: 'تغییر زبان به انگلیسی',
understood: 'متوجه شدم',
// هدر و ناوبری
mainNav: 'ناوبری اصلی',
mobileNav: 'ناوبری موبایل',
openMenu: 'باز کردن منو',
closeMenu: 'بستن منو',
callBrand: 'تماس تلفنی با گروه ساختمانی آرامش',
// فرم تماس — پیام‌های اعتبارسنجی و خطا
incompleteInfo: 'اطلاعات ناقص است',
nameRequired: 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).',
phoneInvalidTitle: 'شماره تلفن نامعتبر است',
phoneInvalidText: 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.',
submitErrorTitle: 'خطا در ثبت درخواست',
formCheck: 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.',
connectionErrorTitle: 'خطا در اتصال به سرور',
connectionErrorText: 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.',
// خبرنامه
emailRequired: 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.',
emailInvalidTitle: 'فرمت ایمیل نامعتبر است',
emailInvalidText: 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).',
subscribeErrorTitle: 'خطا در عضویت',
emailCheck: 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.',
// تیتر بخش‌ها — فقط جایگزین وقتی دیتابیس خالی است
fallbackGalleryTitle: 'گالری تصاویر',
fallbackGalleryLead: 'قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش.',
fallbackTeamTitle: 'تیم ما',
fallbackTeamLead: 'کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند.',
fallbackTimelineTitle: 'مسیر ما',
fallbackTimelineLead: 'از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید.',
fallbackFaqTitle: 'سوالات متداول',
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران.',
// کارت پروژه
sqm: 'متر مربع',
// جزئیات پروژه
projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
backToProjects: 'بازگشت به پروژه‌ها',
specLocation: 'موقعیت',
specYear: 'سال',
specArea: 'زیربنا',
specFloors: 'تعداد طبقات',
specUnits: 'واحدها',
specClient: 'کارفرما',
breadcrumbAria: 'مسیر صفحه',
home: 'صفحه اصلی',
projects: 'پروژه‌ها',
projectDetailsAria: 'جزئیات پروژه',
aboutProject: 'درباره پروژه',
similarProjectCta: 'پروژه‌ای مشابه دارید؟',
relatedProjectsAria: 'پروژه‌های مرتبط',
otherProjects: 'پروژه‌های دیگر',
// مقاله و وبلاگ
articleNotFound: 'این مقاله پیدا نشد',
articleNotFoundText: 'فهرست کامل مقالات وبلاگ را ببینید.',
backToBlog: 'بازگشت به وبلاگ',
blog: 'وبلاگ',
relatedArticlesAria: 'مقالات مرتبط',
relatedArticles: 'مقالات مرتبط',
readArticlePrefix: 'خواندن مقاله',
articleImagePrefix: 'تصویر مقاله',
featuredArticle: 'مقاله ویژه',
readMore: 'ادامه مطلب',
draft: 'پیش‌نویس',
writeNewArticle: 'نوشتن مقاله جدید',
blogEmpty: 'هنوز مقاله‌ای منتشر نشده است',
blogEmptyText: 'به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.',
articlesAria: 'مقالات',
// برچسب‌های دسترس‌پذیری بخش‌ها
statsAria: 'آمار و ارقام',
projectsListAria: 'فهرست پروژه‌ها',
projectFilterAria: 'فیلتر وضعیت پروژه',
officesAria: 'دفاتر ما',
heroAria: 'معرفی گروه ساختمانی آرامش',
heroImageAlt: 'نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش',
heroTitleAria: 'تیتر اصلی صفحه',
scrollDown: 'رفتن به بخش بعدی',
compareAria: 'مقایسه کانسپت و پروژه ساخته‌شده',
zoomImagePrefix: 'بزرگ‌نمایی تصویر شماره',
viewImage: 'نمایش تصویر',
close: 'بستن',
prevImage: 'تصویر قبلی',
nextImage: 'تصویر بعدی',
prevTestimonial: 'نظر قبلی',
nextTestimonial: 'نظر بعدی',
selectTestimonialAria: 'انتخاب نظر',
testimonialByPrefix: 'نظر',
// نقشه پروژه‌ها (مپ‌لیبر)
mapAria: 'نقشه موقعیت پروژه‌ها',
mapLoading: 'در حال بارگذاری نقشه…',
mapErrorText: 'بارگذاری نقشه ناموفق بود — اتصال به سرور کاشی‌ها را بررسی کنید.',
viewProject: 'مشاهده پروژه',
mapFullscreen: 'نمایش تمام‌صفحه',
mapExitFullscreen: 'خروج از تمام‌صفحه',
viewAddress: 'مشاهده آدرس پروژه',
mapNewBadge: 'جدید',
mapProjectDescription: 'پروژه اجراشده در اصفهان',
mapImagePlaceholder: 'تصویر پروژه',
}
export type Dict = typeof fa
export const en: Dict = {
brandFallback: 'Aramesh Construction Group',
loading: 'Loading',
errorTitle: 'Could not reach the server',
errorText: 'Site content is loaded from the server; please check your internet connection or the server status.',
retry: 'Try again',
backToTop: 'Back to top',
switchLang: 'فا',
switchLangAria: 'Switch language to Persian (Farsi)',
understood: 'Got it',
mainNav: 'Main navigation',
mobileNav: 'Mobile navigation',
openMenu: 'Open menu',
closeMenu: 'Close menu',
callBrand: 'Call Aramesh Construction Group',
incompleteInfo: 'Incomplete information',
nameRequired: 'Please enter your full name (at least 3 characters).',
phoneInvalidTitle: 'Invalid phone number',
phoneInvalidText: 'Please enter a valid phone number so our experts can get in touch with you.',
submitErrorTitle: 'Error submitting the request',
formCheck: 'Please check the form and try again.',
connectionErrorTitle: 'Server connection error',
connectionErrorText: 'Could not reach the server. Please try again later.',
emailRequired: 'Please enter your email address to subscribe to the newsletter.',
emailInvalidTitle: 'Invalid email format',
emailInvalidText: 'Please enter your email correctly (e.g. example@domain.com).',
subscribeErrorTitle: 'Subscription error',
emailCheck: 'Please check your email and try again.',
fallbackGalleryTitle: 'Gallery',
fallbackGalleryLead: 'Frames from the construction sites and delivered projects of Aramesh Construction Group.',
fallbackTeamTitle: 'Our Team',
fallbackTeamLead: 'The people who make each project’s key decisions — and answer for them.',
fallbackTimelineTitle: 'Our Path',
fallbackTimelineLead: 'From a four-person office to 47 projects. Walk through the timeline of our work.',
fallbackFaqTitle: 'Frequently Asked Questions',
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
sqm: 'm²',
projectNotFound: 'Project not found',
projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
backToProjects: 'Back to projects',
specLocation: 'Location',
specYear: 'Year',
specArea: 'Built area',
specFloors: 'Floors',
specUnits: 'Units',
specClient: 'Client',
breadcrumbAria: 'Breadcrumb',
home: 'Home',
projects: 'Projects',
projectDetailsAria: 'Project details',
aboutProject: 'About the project',
similarProjectCta: 'Have a similar project in mind?',
relatedProjectsAria: 'Related projects',
otherProjects: 'More projects',
articleNotFound: 'Article not found',
articleNotFoundText: 'Browse the full list of blog articles.',
backToBlog: 'Back to blog',
blog: 'Blog',
relatedArticlesAria: 'Related articles',
relatedArticles: 'Related articles',
readArticlePrefix: 'Read article',
articleImagePrefix: 'Image of article',
featuredArticle: 'Featured article',
readMore: 'Read more',
draft: 'Draft',
writeNewArticle: 'Write a new article',
blogEmpty: 'No articles published yet',
blogEmptyText: 'Our first articles will be published here soon.',
articlesAria: 'Articles',
statsAria: 'Stats and figures',
projectsListAria: 'Project list',
projectFilterAria: 'Filter projects by status',
officesAria: 'Our offices',
heroAria: 'Introduction of Aramesh Construction Group',
heroImageAlt: 'Night view of a residential tower built by Aramesh Construction Group',
heroTitleAria: 'Main headline',
scrollDown: 'Go to the next section',
compareAria: 'Compare the concept with the built project',
zoomImagePrefix: 'Zoom image No.',
viewImage: 'View image',
close: 'Close',
prevImage: 'Previous image',
nextImage: 'Next image',
prevTestimonial: 'Previous testimonial',
nextTestimonial: 'Next testimonial',
selectTestimonialAria: 'Select testimonial',
testimonialByPrefix: 'Testimonial by',
mapAria: 'Projects location map',
mapLoading: 'Loading map…',
mapErrorText: 'The map failed to load — check the tile server connection.',
viewProject: 'View project',
mapFullscreen: 'Fullscreen',
mapExitFullscreen: 'Exit fullscreen',
viewAddress: 'View project location',
mapNewBadge: 'New',
mapProjectDescription: 'Project delivered in Isfahan',
mapImagePlaceholder: 'Project image',
}
export const dict = { fa, en }
export type DictKey = keyof Dict
+100
View File
@@ -0,0 +1,100 @@
import { createContext, useContext, useEffect, useMemo } from 'react'
import type { ReactNode } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { dict } from './dict'
import type { DictKey } from './dict'
export type Lang = 'fa' | 'en'
// زبان سایت از خود URL می‌آید: /en/... انگلیسی و بقیه فارسی — هر زبان یک نشانی واقعی و قابل ایندکس است.
const LANG_COOKIE = 'lang'
/** تشخیص زبان از مسیر — /en و هر زیرمسیر آن انگلیسی است */
export function langFromPathname(pathname: string): Lang {
return pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'fa'
}
/** مسیر معادل در زبان دیگر — برای دکمه تغییر زبان */
export function counterpartPath(pathname: string, lang: Lang): string {
if (lang === 'fa') return pathname === '/' ? '/en' : `/en${pathname}`
return pathname === '/en' ? '/' : pathname.slice(3)
}
/** تبدیل مسیر داخلی دیتابیس (مثل /about) به مسیر زبان جاری — لینک‌های خارجی و tel/mailto دست‌نخورده می‌مانند */
export function localeHref(href: string, lang: Lang): string {
if (!href.startsWith('/')) return href
if (lang === 'fa') return href
return href === '/' ? '/en' : `/en${href}`
}
export function readLangCookie(): Lang | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${LANG_COOKIE}=(fa|en)`))
return (match?.[1] as Lang) ?? null
}
export function writeLangCookie(lang: Lang): void {
document.cookie = `${LANG_COOKIE}=${lang}; path=/; max-age=31536000; samesite=lax`
}
type LangContextValue = {
lang: Lang
dir: 'rtl' | 'ltr'
/** متن‌های هاردکد رابط کاربری به زبان جاری */
t: (key: DictKey) => string
/** مسیر داخلی دیتابیس با پیشوند زبان جاری */
href: (path: string) => string
}
const LangContext = createContext<LangContextValue>({
lang: 'fa',
dir: 'rtl',
t: (key) => dict.fa[key],
href: (path) => path,
})
export function LangProvider({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
const navigate = useNavigate()
const lang = langFromPathname(pathname)
const dir = lang === 'fa' ? 'rtl' : 'ltr'
// زبان و جهت سند — واریانت‌های rtl:/ltr: تیلویند و دسترس‌پذیری به آن وابسته‌اند
useEffect(() => {
document.documentElement.setAttribute('lang', lang)
document.documentElement.setAttribute('dir', dir)
}, [lang, dir])
// انتخاب زبان در کوکی — برای بازدید بعدی؛ صفحه‌های مدیریتی زبان بازدیدکننده را عوض نمی‌کنند
useEffect(() => {
if (!pathname.startsWith('/admin')) writeLangCookie(lang)
}, [lang, pathname])
// بازدیدکننده‌ای که قبلاً انگلیسی انتخاب کرده، هنگام فرود روی / یک بار به /en می‌رود
useEffect(() => {
if (pathname !== '/' || readLangCookie() !== 'en') return
if (sessionStorage.getItem('langAutoRedirect') === '1') return
sessionStorage.setItem('langAutoRedirect', '1')
navigate('/en', { replace: true })
}, [pathname, navigate])
const value = useMemo<LangContextValue>(
() => ({
lang,
dir,
t: (key) => dict[lang][key],
href: (path) => localeHref(path, lang),
}),
[lang, dir],
)
return <LangContext.Provider value={value}>{children}</LangContext.Provider>
}
export function useLang(): LangContextValue {
return useContext(LangContext)
}
/** عدد با رقم‌های زبان جاری — فارسی fa-IR و انگلیسی لاتین */
export function formatNumber(value: number, lang: Lang): string {
return value.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')
}
+209
View File
@@ -0,0 +1,209 @@
// بومی‌سازی درخت داده سایت — انتخاب فیلدهای انگلیسی با برگشت به فارسی.
// فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان
// بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند.
import type { Article, ArticleBodyDoc } from '../data/site'
import type { SiteData } from '../api'
import type { Lang } from './lang'
/** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
/** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
/** سند TipTap انگلیسی فقط وقتی متن واقعی دارد جایگزین بدنه فارسی می‌شود */
function docHasText(doc: unknown): boolean {
if (!doc || typeof doc !== 'object') return false
const content = (doc as ArticleBodyDoc).content
if (!Array.isArray(content)) return false
let found = false
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object' || found) return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && typeof n.text === 'string' && n.text.trim()) found = true
n.content?.forEach(walk)
}
content.forEach(walk)
return found
}
export function localizeSite(data: SiteData, lang: Lang): SiteData {
if (lang === 'fa') return data
return {
brand: {
...data.brand,
name: s(data.brand.name, data.brand.name_en),
address: s(data.brand.address, data.brand.address_en),
},
nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })),
hero: {
...data.hero,
kicker: s(data.hero.kicker, data.hero.kicker_en),
title: s(data.hero.title, data.hero.title_en),
title_highlight: s(data.hero.title_highlight, data.hero.title_highlight_en),
subtitle: s(data.hero.subtitle, data.hero.subtitle_en),
badge_value: s(data.hero.badge_value, data.hero.badge_value_en),
badge_label: s(data.hero.badge_label, data.hero.badge_label_en),
cta_primary_label: s(data.hero.cta_primary_label, data.hero.cta_primary_label_en),
cta_secondary_label: s(data.hero.cta_secondary_label, data.hero.cta_secondary_label_en),
scroll_label: s(data.hero.scroll_label, data.hero.scroll_label_en),
},
story: {
...data.story,
title: s(data.story.title, data.story.title_en),
paragraphs: list(data.story.paragraphs, data.story.paragraphs_en),
quote: s(data.story.quote, data.story.quote_en),
quote_author: s(data.story.quote_author, data.story.quote_author_en),
stat1_value: s(data.story.stat1_value, data.story.stat1_value_en),
stat1_label: s(data.story.stat1_label, data.story.stat1_label_en),
stat2_value: s(data.story.stat2_value, data.story.stat2_value_en),
stat2_label: s(data.story.stat2_label, data.story.stat2_label_en),
link_label: s(data.story.link_label, data.story.link_label_en),
bento: list(data.story.bento, data.story.bento_en),
},
section_headings: data.section_headings.map((h) => ({
...h,
label: s(h.label, h.label_en),
title: s(h.title, h.title_en),
lead: s(h.lead, h.lead_en),
cta_label: s(h.cta_label, h.cta_label_en),
})),
page_heroes: data.page_heroes.map((p) => ({
...p,
title: s(p.title, p.title_en),
lead: s(p.lead, p.lead_en),
})),
services: data.services.map((sv) => ({
...sv,
title: s(sv.title, sv.title_en),
desc: s(sv.desc, sv.desc_en),
})),
projects: data.projects.map((p) => ({
...p,
title: s(p.title, p.title_en),
location: s(p.location, p.location_en),
year: s(p.year, p.year_en),
area: s(p.area, p.area_en),
floors: s(p.floors, p.floors_en),
units: s(p.units, p.units_en),
client: s(p.client, p.client_en),
summary: s(p.summary, p.summary_en),
body: list(p.body, p.body_en),
})),
articles: data.articles.map((a: Article) => ({
...a,
title: s(a.title, a.title_en),
date: s(a.date, a.date_en),
category: s(a.category, a.category_en),
read: s(a.read, a.read_en),
excerpt: s(a.excerpt, a.excerpt_en),
body: docHasText(a.body_en) ? a.body_en! : a.body,
})),
testimonials: data.testimonials.map((tm) => ({
...tm,
name: s(tm.name, tm.name_en),
role: s(tm.role, tm.role_en),
quote: s(tm.quote, tm.quote_en),
})),
faqs: data.faqs.map((f) => ({
...f,
question: s(f.question, f.question_en),
answer: s(f.answer, f.answer_en),
})),
team: data.team.map((m) => ({
...m,
name: s(m.name, m.name_en),
role: s(m.role, m.role_en),
bio: s(m.bio, m.bio_en),
})),
timeline: data.timeline.map((tm) => ({
...tm,
year: s(tm.year, tm.year_en),
title: s(tm.title, tm.title_en),
desc: s(tm.desc, tm.desc_en),
})),
stats: data.stats.map((st) => ({
...st,
suffix: s(st.suffix, st.suffix_en),
label: s(st.label, st.label_en),
})),
why_us: data.why_us.map((w) => ({
...w,
title: s(w.title, w.title_en),
desc: s(w.desc, w.desc_en),
})),
before_after: {
...data.before_after,
title: s(data.before_after.title, data.before_after.title_en),
lead: s(data.before_after.lead, data.before_after.lead_en),
before_label: s(data.before_after.before_label, data.before_after.before_label_en),
after_label: s(data.before_after.after_label, data.before_after.after_label_en),
},
investment_models: data.investment_models.map((im) => ({
...im,
title: s(im.title, im.title_en),
desc: s(im.desc, im.desc_en),
points: list(im.points, im.points_en),
})),
process_steps: data.process_steps.map((ps) => ({
...ps,
step: s(ps.step, ps.step_en),
title: s(ps.title, ps.title_en),
desc: s(ps.desc, ps.desc_en),
})),
offices: data.offices.map((o) => ({
...o,
city: s(o.city, o.city_en),
address: s(o.address, o.address_en),
hours: s(o.hours, o.hours_en),
})),
gallery: data.gallery.map((g) => ({ ...g, alt: s(g.alt, g.alt_en) })),
status_labels: data.status_labels.map((sl) => ({ ...sl, label: s(sl.label, sl.label_en) })),
contact_section: {
...data.contact_section,
label: s(data.contact_section.label, data.contact_section.label_en),
title: s(data.contact_section.title, data.contact_section.title_en),
lead: s(data.contact_section.lead, data.contact_section.lead_en),
address_label: s(data.contact_section.address_label, data.contact_section.address_label_en),
phone_label: s(data.contact_section.phone_label, data.contact_section.phone_label_en),
email_label: s(data.contact_section.email_label, data.contact_section.email_label_en),
name_label: s(data.contact_section.name_label, data.contact_section.name_label_en),
name_placeholder: s(data.contact_section.name_placeholder, data.contact_section.name_placeholder_en),
phone_field_label: s(data.contact_section.phone_field_label, data.contact_section.phone_field_label_en),
phone_placeholder: s(data.contact_section.phone_placeholder, data.contact_section.phone_placeholder_en),
note_label: s(data.contact_section.note_label, data.contact_section.note_label_en),
note_optional_label: s(data.contact_section.note_optional_label, data.contact_section.note_optional_label_en),
note_placeholder: s(data.contact_section.note_placeholder, data.contact_section.note_placeholder_en),
submit_label: s(data.contact_section.submit_label, data.contact_section.submit_label_en),
sending_label: s(data.contact_section.sending_label, data.contact_section.sending_label_en),
success_title: s(data.contact_section.success_title, data.contact_section.success_title_en),
success_text: s(data.contact_section.success_text, data.contact_section.success_text_en),
success_button_label: s(data.contact_section.success_button_label, data.contact_section.success_button_label_en),
},
newsletter: {
...data.newsletter,
title: s(data.newsletter.title, data.newsletter.title_en),
lead: s(data.newsletter.lead, data.newsletter.lead_en),
placeholder: s(data.newsletter.placeholder, data.newsletter.placeholder_en),
button_label: s(data.newsletter.button_label, data.newsletter.button_label_en),
sending_label: s(data.newsletter.sending_label, data.newsletter.sending_label_en),
success_text: s(data.newsletter.success_text, data.newsletter.success_text_en),
},
footer: {
...data.footer,
about_text: s(data.footer.about_text, data.footer.about_text_en),
pages_heading: s(data.footer.pages_heading, data.footer.pages_heading_en),
services_heading: s(data.footer.services_heading, data.footer.services_heading_en),
contact_heading: s(data.footer.contact_heading, data.footer.contact_heading_en),
copyright: s(data.footer.copyright, data.footer.copyright_en),
tagline: s(data.footer.tagline, data.footer.tagline_en),
socials: list(data.footer.socials, data.footer.socials_en),
},
seo_meta: data.seo_meta.map((m) => ({
...m,
title: s(m.title, m.title_en),
description: s(m.description, m.description_en),
})),
}
}
+182 -6
View File
@@ -7,6 +7,9 @@
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
/* فونت سایت سلف‌هاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
Pinar فونت اصلی است (۵ وزن) و Vazirmatn به‌عنوان جایگزین پشتیبان می‌ماند.
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده می‌کند (Inter در صورت نصب بودن). */
@font-face {
font-family: 'Pinar';
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
@@ -69,6 +72,21 @@ body {
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* نسخه انگلیسی — فونت لاتین و تنوع‌سنجی (tracking) مناسب خط لاتین */
html[lang='en'] body {
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
letter-spacing: -0.01em;
}
html[lang='en'] .article-body {
line-height: 1.8;
}
html[lang='en'] .article-body p {
margin-top: 1.25rem;
}
html[lang='en'] .article-body h2,
html[lang='en'] .article-body h3 {
line-height: 1.4;
}
::selection {
background-color: #d5874c;
color: #1e1a16;
@@ -92,6 +110,10 @@ body {
.article-body > :first-child {
margin-top: 0;
}
/* ---------- تایپوگرافی مقاله ----------
راست‌چین (فارسی): پایه ۱۴ / تیتر۱ ۱۸ / تیتر۲ ۱۶ / تیتر۳ ۱۴
چپ‌چین (انگلیسی): پایه ۱۲ / تیتر۱ ۱۶ / تیتر۲ ۱۴ / تیتر۳ ۱۲
جهت با data-dir روی پوسته ویرایشگر یا مقاله تعیین می‌شود. */
.article-body h1,
.article-body h2,
.article-body h3 {
@@ -104,13 +126,64 @@ body {
scroll-margin-top: 7rem;
}
.article-body h1 {
font-size: 18px; /* تیتر ۱ سایز ۱۸ */
font-size: 18px;
}
.article-body h2 {
font-size: 16px; /* تیتر ۲ سایز ۱۶ */
font-size: 16px;
padding-inline-start: 0.85rem;
border-inline-start: 3px solid #d5874c;
}
.article-body h3 {
font-size: 14px; /* تیتر ۳ سایز ۱۴ */
font-size: 14px;
}
[data-dir='rtl'] .article-body {
direction: rtl;
text-align: right;
}
[data-dir='ltr'] .article-body {
direction: ltr;
text-align: left;
font-size: 12px;
}
[data-dir='ltr'] .article-body h1 {
font-size: 16px;
}
[data-dir='ltr'] .article-body h2 {
font-size: 14px;
}
[data-dir='ltr'] .article-body h3 {
font-size: 12px;
}
/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ----------
متن لاتین همیشه ۲ پیکسل کوچک‌تر از متن فارسیِ همان بلوک است:
تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲.
calc(1em - 2px) نسبت به اندازه همان بلوک حساب می‌شود، پس قاعده در همه سطح‌ها درست
می‌ماند و خط پایه هم جابه‌جا نمی‌شود (line-height نسبی است).
در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچک‌تر است و واژه‌های فارسی
۲ پیکسل بزرگ‌تر می‌شوند تا تفاضل همیشه ۲ پیکسل بماند. */
.article-body .latin,
.article-body .persian,
.article-body .english-text {
unicode-bidi: isolate;
}
.article-body .latin,
.article-body .english-text {
font-size: calc(1em - 2px);
}
.article-body .persian {
font-size: 1em;
}
[data-dir='ltr'] .article-body .latin,
[data-dir='ltr'] .article-body .english-text {
font-size: 1em;
}
[data-dir='ltr'] .article-body .persian {
font-size: calc(1em + 2px);
}
.article-body span[data-small="true"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
@@ -119,12 +192,12 @@ body {
.english-text {
direction: ltr;
unicode-bidi: isolate;
font-size: 12px;
/* اندازه از قواعد دوزبانه بالا می‌آید: ۲ پیکسل کوچک‌تر از متن فارسی همان بلوک */
}
.article-body ul,
.article-body ol {
margin-top: 1.5rem;
padding-right: 1.5rem;
padding-inline-start: 1.5rem;
}
.article-body ul {
list-style: disc;
@@ -142,7 +215,7 @@ body {
}
.article-body blockquote {
margin-top: 2rem;
border-right: 3px solid #d5874c;
border-inline-start: 3px solid #d5874c;
background: rgba(30, 26, 22, 0.5);
padding: 1rem 1.25rem;
font-style: normal;
@@ -163,6 +236,35 @@ body {
border: 1px solid rgba(245, 243, 239, 0.1);
object-fit: cover;
}
/* ---------- تصویر کپشن‌دار: <figure><img><figcaption> ---------- */
.article-body figure {
margin-top: 2rem;
}
.article-body figure img {
margin-top: 0;
}
.article-body figcaption {
margin-top: 0.7rem;
color: rgba(245, 243, 239, 0.5);
font-size: 0.85em;
line-height: 1.9;
text-align: center;
}
/* ویرایشگر: کپشن ورودی و دکمه حذف زیر تصویر */
[data-wysiwyg] .article-figure-node {
margin: 1.25rem 0;
}
[data-wysiwyg] .article-figure__caption {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
text-align: right;
}
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
outline: 1px dashed rgba(213, 135, 76, 0.8);
outline-offset: 3px;
}
.article-body strong {
color: #f5f3ef;
font-weight: 700;
@@ -264,4 +366,78 @@ body {
}
.aramesh-datepicker .rmdp-action-button {
color: #d5874c;
}
/* ---------- نقشه مپ‌لیبر — کنترل‌ها و پاپ‌آپ هم‌رنگ تم تیره ---------- */
.lux-map-popup .maplibregl-popup-content {
background: transparent;
padding: 0;
border-radius: 1rem;
box-shadow: none;
}
/* دم پاپ‌آپ — هم‌رنگ کارت شیشه‌ای */
.lux-map-popup .maplibregl-popup-tip {
border-top-color: #1e1a16;
}
.maplibregl-ctrl-group {
overflow: hidden;
border: 1px solid rgba(245, 243, 239, 0.14);
border-radius: 0.75rem !important;
background: rgba(30, 26, 22, 0.85) !important;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45) !important;
backdrop-filter: blur(10px);
}
.maplibregl-ctrl-group button {
width: 34px;
height: 34px;
background-color: transparent !important;
}
.maplibregl-ctrl-group button:hover {
background-color: rgba(213, 135, 76, 0.16) !important;
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid rgba(245, 243, 239, 0.12) !important;
}
/* آیکون‌های پیش‌فرض کنترل‌ها تیره‌اند — با اینورتر روشن می‌شوند */
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
filter: invert(0.92);
}
.maplibregl-ctrl-attrib.maplibregl-compact {
border-radius: 0.75rem;
background: rgba(30, 26, 22, 0.85);
color: rgba(245, 243, 239, 0.55);
font-size: 10px;
}
.maplibregl-ctrl-attrib a {
color: rgba(213, 135, 76, 0.9);
}
.maplibregl-ctrl-attrib-button {
filter: invert(0.92);
}
.maplibregl-ctrl-scale {
border-color: rgba(245, 243, 239, 0.35);
border-radius: 2px;
background: rgba(30, 26, 22, 0.7);
color: rgba(245, 243, 239, 0.6);
font-size: 10px;
}
/* ---------- ورود نرم کارت پاپ‌آپ و حالت تمام‌صفحه ---------- */
@keyframes luxPopupIn {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes luxFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
import { lazy, Suspense } from 'react'
import type { ProjectsMapAllProps } from './ProjectsMapAll'
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
/**
* بارگیری تنبل نقشه‌ها — بسته‌ی سنگین مپ‌لیبر فقط وقتی دانلود می‌شود که
* واقعاً نقشه‌ای روی صفحه باشد؛ صفحه‌های بدون نقشه (مثل خانه) جریمه‌ی حجم
* نمی‌پردازند. Import زیر فقط نوع است و باندل را سنگین نمی‌کند.
*/
const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({ default: module.ProjectsMapAll })))
const ProjectsMapSingle = lazy(() =>
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
)
/** جای‌نگهدار هم‌اندازه‌ی نقشه تا چیدمان هنگام بارگیری نلرزد */
function MapSkeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
}
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
return (
<Suspense fallback={<MapSkeleton className={props.className} />}>
<ProjectsMapAll {...props} />
</Suspense>
)
}
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
return (
<Suspense fallback={<MapSkeleton className={props.className} />}>
<ProjectsMapSingle {...props} />
</Suspense>
)
}
+133
View File
@@ -0,0 +1,133 @@
import * as maplibregl from 'maplibre-gl'
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
import { Loader2Icon } from 'lucide-react'
import { MdClose, MdFullscreen } from 'react-icons/md'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useLang } from '../i18n/lang'
import type { MapStatus } from './types'
type MapViewportProps = {
containerRef: MutableRefObject<HTMLDivElement | null>
/** نمونه‌ی نقشه — فقط برای هم‌گام‌سازی اندازه؛ مالکیتش با useMapLibre است */
map: maplibregl.Map | null
status: MapStatus
onRetry: () => void
/** برچسب دسترس‌پذیری ناحیه‌ی نقشه */
label: string
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string
/** محتوای شناور روی نقشه (پورتال پاپ‌آپ) */
children?: ReactNode
}
/**
* پوسته‌ی نقشه — قاب شیشه‌ای، حالت تمام‌صفحه، کنترل بزرگ‌نمایی و لایه‌های
* بارگذاری/خطا.
*
* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد می‌نشیند (نه absolute)؛
* استایل خود مپ‌لیبر بعد از تیلویند بار می‌شود و position: relative می‌دهد،
* پس کلاس absolute بی‌اثر می‌شد و ظرف صفر می‌ماند.
*
* در تمام‌صفحه فقط شفافیت و گوشه‌ها انیمیت می‌شوند تا بافر بوم هرگز کشیده
* و تار نشود؛ تغییر اندازه در همان فریم اعمال می‌شود.
*/
export function MapViewport({
containerRef,
map,
status,
onRetry,
label,
className = '',
children,
}: MapViewportProps) {
const { t } = useLang()
const [isFullscreen, setIsFullscreen] = useState(false)
/** تمام‌صفحه: Esc برای خروج و قفل اسکرول پس‌زمینه */
useEffect(() => {
if (!isFullscreen) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsFullscreen(false)
}
window.addEventListener('keydown', onKeyDown)
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
}
}, [isFullscreen])
/** پس از پایان انتقال، بوم را هم‌گام کن (مپ‌لیبر خودش هم ResizeObserver دارد) */
useEffect(() => {
if (!isFullscreen) return
const timer = window.setTimeout(() => map?.resize(), 340)
return () => window.clearTimeout(timer)
}, [isFullscreen, map])
const shellClass = isFullscreen
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
return (
<div
dir="rtl"
aria-label={label}
className={`overflow-hidden bg-char font-pinar transition-all duration-300 ease-lux ${shellClass}`}
>
{/* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد است؛ کلاس relative صریح
است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار
«container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول
رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */}
<div ref={containerRef} className="relative h-full w-full" />
{isFullscreen ? (
<button
type="button"
onClick={() => setIsFullscreen(false)}
aria-label={t('mapExitFullscreen')}
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
>
<MdClose className="h-4 w-4" aria-hidden="true" />
{t('mapExitFullscreen')}
</button>
) : (
<button
type="button"
onClick={() => setIsFullscreen(true)}
aria-label={t('mapFullscreen')}
title={t('mapFullscreen')}
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
>
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
</button>
)}
{status !== 'ready' && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
{status === 'loading' ? (
<>
<Loader2Icon className="h-7 w-7 animate-spin text-copper" aria-hidden="true" />
<p className="text-xs font-light tracking-wide text-ivory/60">{t('mapLoading')}</p>
</>
) : (
<>
<p className="max-w-xs text-center text-xs font-light leading-6 text-ivory/60">
{t('mapErrorText')}
</p>
<button
type="button"
onClick={onRetry}
className="border border-copper/50 px-6 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
{t('retry')}
</button>
</>
)}
</div>
)}
{children}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useState, type MouseEvent, type PointerEvent } from 'react'
import { MdChevronLeft, MdChevronRight } from 'react-icons/md'
import { ProjectImagePlaceholder } from '../components/ProjectImagePlaceholder'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
type ProjectImageSliderProps = {
/** همان آرایهٔ تصاویر صفحهٔ جزئیات پروژه — در نبودش جای‌نگهدار می‌آید */
images: string[]
alt: string
className?: string
}
/**
* اسلایدر تصویر لبه‌تا‌لبهٔ بالای کارت پاپ‌آپ (سبک Zillow): فلش‌های چپ/راست،
* شمارندهٔ اسلاید و گوشه‌های گرد هم‌اندازهٔ کارت. همهٔ رویدادهای اشاره روی
* اسلایدر متوقف می‌شوند تا کلیک/کشیدن آن، نقشه را جابه‌جا یا پاپ‌آپ را نبندد.
*/
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
const { t } = useLang()
const [index, setIndex] = useState(0)
/** هیچ رویدادی از اسلایدر به نقشهٔ زیرین نمی‌رسد */
const stop = (event: MouseEvent | PointerEvent) => event.stopPropagation()
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
if (!images.length) {
return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<ProjectImagePlaceholder />
</div>
)
}
const current = Math.min(index, images.length - 1)
const step = (delta: number) => setIndex((value) => (value + delta + images.length) % images.length)
const arrow =
'absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/85 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char'
return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
{images.length > 1 && (
<>
<button
type="button"
aria-label={t('prevImage')}
title={t('prevImage')}
onClick={(event) => {
event.stopPropagation()
step(-1)
}}
className={`${arrow} left-2`}
>
<MdChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label={t('nextImage')}
title={t('nextImage')}
onClick={(event) => {
event.stopPropagation()
step(1)
}}
className={`${arrow} right-2`}
>
<MdChevronRight className="h-5 w-5" />
</button>
<span className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-ink/75 px-2.5 py-0.5 text-[10px] font-light text-ivory/85 backdrop-blur-md">
{toPersianDigits(current + 1)} / {toPersianDigits(images.length)}
</span>
</>
)}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { MdLocationOn } from 'react-icons/md'
type ProjectPinProps = {
label: string
/** پروژه‌ی انتخاب‌شده — هاله‌ی مسی پایدار */
active?: boolean
onClick: () => void
}
/**
* سنجاق پروژه روی نقشه — آیکون MdLocationOn در رنگ مسی برند با هاله‌ی
* نورانی در هاور، کلیک و حالت انتخاب‌شده. این کامپوننت داخل المان نشانگر
* مپ‌لیبر رندر می‌شود، پس خودش هیچ چیدمان بیرونی ندارد.
*/
export function ProjectPin({ label, active = false, onClick }: ProjectPinProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
className="group flex cursor-pointer items-end justify-center bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<MdLocationOn
className={`h-9 w-9 text-[#d5874c] transition-all duration-300 ease-lux group-hover:scale-125 group-hover:drop-shadow-[0_0_14px_rgba(213,135,76,0.95)] group-active:scale-110 group-active:drop-shadow-[0_0_18px_rgba(213,135,76,1)] ${
active
? 'scale-110 drop-shadow-[0_0_16px_rgba(213,135,76,0.95)]'
: 'drop-shadow-[0_3px_6px_rgba(0,0,0,0.65)]'
}`}
/>
</button>
)
}
+66
View File
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { MdClose } from 'react-icons/md'
import type { MapProject } from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { ProjectImageSlider } from './ProjectImageSlider'
/** دکمه‌های کارت — تمام‌عرض، هم‌خانوادهٔ دکمه‌های مسی سایت (در ستون هم درست می‌نشینند) */
export const POPUP_PRIMARY_CLASS =
'flex w-full items-center justify-center gap-1.5 rounded-xl bg-copper px-3 py-2.5 text-[12px] font-bold text-char transition-all duration-300 ease-lux hover:scale-[1.03] hover:shadow-[0_8px_22px_rgba(213,135,76,0.35)] active:scale-95'
export const POPUP_SECONDARY_CLASS =
'flex w-full items-center justify-center gap-1.5 rounded-xl border border-ivory/20 px-3 py-2.5 text-[12px] font-light text-ivory/80 transition-colors duration-300 ease-lux hover:border-copper hover:text-copper'
type ProjectPopupCardProps = {
project: MapProject
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جای‌نگهدار می‌آید */
images?: string[]
onClose: () => void
/** دکمه‌های پایهٔ کارت — هر نما اکشن‌های خودش را می‌دهد (عمودی روی هم) */
children: ReactNode
}
/**
* کارت پاپ‌آپ پروژه به سبک Zillow — اسلایدر لبه‌تا‌لبه در بالاترین بخش، سپس
* عنوان و توضیح، و در پایه دکمه‌های عمودی. راست‌به‌چپ، شیشه‌ای تیره و
* هم‌اندازهٔ کارت‌های سایت. سال پروژه عمداً نمایش داده نمی‌شود.
*/
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
const { t } = useLang()
const description = project.description.trim() || t('mapProjectDescription')
return (
<article
dir="rtl"
className="w-[17rem] max-w-[calc(100vw-2.5rem)] animate-[luxPopupIn_0.22s_ease-out] overflow-hidden rounded-2xl border border-ivory/10 bg-[#27221c]/95 font-pinar text-ivory shadow-[0_24px_60px_-15px_rgba(0,0,0,0.9)] backdrop-blur-xl sm:w-72"
>
{/* اسلایدر تصویر: لبه‌تا‌لبه، بدون حاشیه، با فلش‌های چپ/راست */}
<div className="relative">
<ProjectImageSlider images={images} alt={project.name} />
<button
type="button"
onClick={onClose}
aria-label={t('close')}
title={t('close')}
className="absolute left-2.5 top-2.5 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char"
>
<MdClose className="h-4 w-4" />
</button>
{project.isNew && (
<span className="absolute right-2.5 top-2.5 z-20 rounded-full bg-copper px-2.5 py-1 text-[10px] font-bold text-char shadow-[0_4px_14px_rgba(213,135,76,0.4)]">
{t('mapNewBadge')}
</span>
)}
</div>
<div className="px-4 pb-1 pt-3.5">
<h3 className="min-w-0 truncate text-base font-bold text-ivory">{project.name}</h3>
<p className="mt-1.5 line-clamp-2 text-[11px] font-light leading-6 text-ivory/60">{description}</p>
</div>
<div className="flex flex-col gap-2 p-3 pt-2.5">{children}</div>
</article>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { createPortal } from 'react-dom'
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
import {
ISFAHAN_CENTER,
MAP_PROJECTS,
boundsForProjects,
buildNeshanUrl,
type MapProject,
} from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { MapViewport } from './MapViewport'
import { POPUP_PRIMARY_CLASS, POPUP_SECONDARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
import { useMapLibre } from './useMapLibre'
import { useProjectMarkers } from './useProjectMarkers'
export type ProjectsMapAllProps = {
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-[520px]» */
className?: string
/** فهرست پروژه‌ها — پیش‌فرض همه‌ی ردیف‌های شیت */
projects?: MapProject[]
/**
* نشانی صفحه‌ی جزئیات هر پروژه. اگر تطبیقی پیدا نشود، دکمه‌ی «مشاهده پروژه»
* به فهرست پروژه‌ها می‌رود تا هیچ لینکی بی‌مقصد نماند.
*/
resolveProjectHref?: (project: MapProject) => string | undefined
/** آرایهٔ تصاویر هر پروژه (همان آرایهٔ صفحهٔ جزئیات) — در نبودش جای‌نگهدار */
imagesFor?: (project: MapProject) => string[]
}
/**
* نمای «همه‌ی پروژه‌ها» — همه‌ی مختصات شیت روی یک نقشه‌ی تمام‌عرض با
* سنجاق‌های مسی، پاپ‌آپ کارتی و دو اکشن عمودی: صفحه‌ی پروژه و آدرس نشان.
* پاپ‌آپ فقط با کلیک کاربر باز می‌شود و جابه‌جایی نقشه به محدودهٔ پروژه‌ها
* بسته است.
*/
export function ProjectsMapAll({
className = '',
projects = MAP_PROJECTS,
resolveProjectHref,
imagesFor,
}: ProjectsMapAllProps) {
const { t, href } = useLang()
const bounds = useMemo(() => boundsForProjects(projects), [projects])
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
return (
<MapViewport
containerRef={containerRef}
map={map}
status={status}
onRetry={retry}
label={t('mapAria')}
className={className}
>
{popupHost &&
activeProject &&
createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
{t('viewProject')}
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
</Link>
{/* منطق دکمهٔ آدرس دست‌نخورده — فقط در ستون عمودی نشسته است */}
<a
href={buildNeshanUrl(activeProject)}
target="_blank"
rel="noopener noreferrer"
className={POPUP_SECONDARY_CLASS}
>
{t('viewAddress')}
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
</a>
</ProjectPopupCard>,
popupHost,
)}
</MapViewport>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { createPortal } from 'react-dom'
import { MdOpenInNew } from 'react-icons/md'
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
import { useLang } from '../i18n/lang'
import { MapViewport } from './MapViewport'
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
import { useMapLibre } from './useMapLibre'
import { useProjectMarkers } from './useProjectMarkers'
export type ProjectsMapSingleProps = {
/** همان پروژه‌ی شیت — یا محل جایگزین ساخته‌شده با createPlaceholderProject */
location: MapProject
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات (تصویر شاخص + گالری) */
images?: string[]
/** ارتفاع/فاصله‌ها از بیرون — مثل «h-80» */
className?: string
}
/**
* نمای «تک‌پروژه» — یک سنجاق دقیق روی مختصات همان پروژه و پاپ‌آپی که فقط با
* کلیک کاربر باز می‌شود. تنها اکشن کارت، مشاهدهٔ آدرس پروژه در نشان است.
* جابه‌جایی نقشه به محدودهٔ همین پروژه بسته شده تا به بیرون نرود.
*/
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
const { t } = useLang()
const projects = useMemo(() => [location], [location])
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
const { containerRef, map, status, retry } = useMapLibre({
center: [location.lng, location.lat],
zoom: 15,
maxBounds: bounds,
})
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
return (
<MapViewport
containerRef={containerRef}
map={map}
status={status}
onRetry={retry}
label={t('mapAria')}
className={className}
>
{popupHost &&
activeProject &&
createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
{/* منطق دکمهٔ آدرس دست‌نخورده است — تنها اکشن این کارت */}
<a
href={buildNeshanUrl(activeProject)}
target="_blank"
rel="noopener noreferrer"
className={POPUP_PRIMARY_CLASS}
>
{t('viewAddress')}
<MdOpenInNew className="h-4 w-4" aria-hidden="true" />
</a>
</ProjectPopupCard>,
popupHost,
)}
</MapViewport>
)
}
+35
View File
@@ -0,0 +1,35 @@
import * as maplibregl from 'maplibre-gl'
import { Protocol } from 'pmtiles'
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
let setup: Promise<void> | null = null
/**
* آماده‌سازی وب‌ورکر نقشه — یک‌بار برای همه نقشه‌ها.
*
* ۱) نشانی وب‌ورکر مپ‌لیبر صریح تعیین می‌شود: مپ‌لیبر به‌صورت پیش‌فرض
* «./maplibre-gl-worker.mjs» را کنار باندل جست‌وجو می‌کند که در خروجی ویت
* وجود ندارد و ورکر ۴۰۴ می‌شود؛ نسخه‌ی ساخته‌شده‌ی ویت را با setWorkerUrl
* می‌دهیم (نشانی مطلق، چون پایه‌ی ورکر می‌تواند Blob باشد).
* ۲) پروتکل pmtiles در نخ اصلی ثبت می‌شود.
* ۳) همان پروتکل با importScriptInWorkers داخل وب‌ورکر هم ثبت می‌شود —
* کاشی‌های برداری آنجا بارگیری می‌شوند و رجیستری پروتکل در هر کانتکست
* جداست. اسکریپت وب‌ورکر، خروجی خودبسنده‌ی public/map/pmtiles-worker.mjs
* است (ساخته‌شده از src/map/pmtiles.worker.ts با npm run build:map-worker).
*
* تا پایان بارگیری اسکریپت صبر می‌کنیم تا نخستین درخواست‌های کاشی در وب‌ورکر
* بی‌پاسخ نمانند.
*/
export function ensureMapWorker(): Promise<void> {
if (!setup) {
maplibregl.setWorkerUrl(new URL(maplibreWorkerUrl, window.location.href).href)
maplibregl.addProtocol('pmtiles', new Protocol().tile)
const protocolScript = new URL(
`${import.meta.env.BASE_URL}map/pmtiles-worker.mjs`,
window.location.href,
).href
setup = maplibregl.importScriptInWorkers(protocolScript)
}
return setup
}
+15
View File
@@ -0,0 +1,15 @@
import { Protocol } from 'pmtiles'
/**
* اسکریپت سمت وبورکر مپ‌لیبر — با importScriptInWorkers داخل وبورکر اجرا
* می‌شود و مپ‌لیبر تابع addProtocol را روی self وبورکر گذاشته است.
*
* کاشی‌های برداری در وبورکر بارگیری می‌شوند و رجیستری پروتکل در هر کانتکست
* جداست؛ بدون این ثبت، درخواست‌های pmtiles در وبورکر بی‌پاسخ می‌مانند و
* نقشه فقط پس‌زمینه را نشان می‌دهد.
*/
const workerScope = self as unknown as {
addProtocol: (name: string, loadFn: unknown) => void
}
workerScope.addProtocol('pmtiles', new Protocol().tile)
+141
View File
@@ -0,0 +1,141 @@
import type { SourceSpecification, StyleSpecification } from 'maplibre-gl'
import { MAP_BASE_URL } from '../config'
const PMTILES_SCHEME = 'pmtiles://'
/** نشانی‌های مطلق — دست‌نخورده می‌مانند */
const EXTERNAL = /^(https?:|data:|blob:)/i
/** تنها آرشیو برداری باکت — منبع برداری سبک همیشه به همین جا می‌رسد */
const PMTILES_ARCHIVE = 'iran.pmtiles'
/** نام منبع برداری در سبک‌های استاندارد OpenMapTiles */
const OPENMAPTILES_KEY = 'openmaptiles'
/** پوشه فونت‌های گلیف در باکت MinIO — سبک خام فقط «{fontstack}/{range}.pbf» دارد */
const GLYPHS_FOLDER = 'v2.0'
function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
}
/** نشانی آرشیو برداری روی MinIO — تنها منبع کاشی این پروژه */
function archiveUrl(base: string): string {
return `${PMTILES_SCHEME}${joinUrl(base, PMTILES_ARCHIVE)}`
}
/**
* جای‌گذاری جانگهدارهای سبک — «{styleJsonFolder}» به آدرس پایه MinIO تبدیل
* می‌شود. جانگهدارهای {fontstack} و {range} قانونی خود مپ‌لیبرند و دست‌نخورده
* می‌مانند. جانگهدار تازه‌ای در سبک‌های بعدی دیده شد، به همین نگاشت اضافه شود.
*/
function resolvePlaceholders(url: string, base: string): string {
return url.replace(/\{styleJsonFolder\}/g, base)
}
/** مطلق‌سازی نشانی‌های عمومی (گلیف، اسپرایت، منابع غیربرداری) */
function absolutize(resource: string, base: string): string {
const url = resolvePlaceholders(resource.trim(), base)
if (EXTERNAL.test(url)) return url
if (url.startsWith(PMTILES_SCHEME)) {
const target = url.slice(PMTILES_SCHEME.length).trim()
return EXTERNAL.test(target) ? url : PMTILES_SCHEME + joinUrl(base, target)
}
return joinUrl(base, url)
}
/**
* نشانی گلیف‌ها — سبک خام فقط الگوی «{fontstack}/{range}.pbf» را دارد و پوشه
* فونت‌ها را نمی‌آورد، اما فونت‌های باکت زیر پوشه v2.0 هستند؛ پس الگو همیشه
* به همان پوشه می‌رسد و جانگهدارهای {fontstack} و {range} دست‌نخورده می‌مانند.
*/
function resolveGlyphsUrl(url: string, base: string): string {
const resolved = resolvePlaceholders(url.trim(), base)
if (EXTERNAL.test(resolved)) return resolved
if (resolved.includes('{fontstack}')) {
const template = resolved.slice(resolved.indexOf('{fontstack}'))
return joinUrl(base, joinUrl(GLYPHS_FOLDER, template))
}
return joinUrl(base, resolved)
}
/** منبع برداری سبک — با نوع vector یا نام شناخته‌شده openmaptiles */
function isVectorSource(key: string, source: SourceSpecification): boolean {
return source.type === 'vector' || key === OPENMAPTILES_KEY
}
/**
* بازنویسی نشانی منبع برداری. سبک‌های ساخته‌شده برای سرور MBTiles (مثل
* «mbtiles://{v3}» در tileserver-gl) کاشی برداری را با طرح mbtiles آدرس
* می‌دهند که مپ‌لیبر نمی‌شناسد؛ هر شکلی از نشانی اینجا به آرشیو iran.pmtiles
* روی MinIO هدایت می‌شود تا با پروتکل pmtiles رندر شود.
*/
function resolveVectorUrl(url: string, base: string): string {
const resolved = resolvePlaceholders(url.trim(), base)
if (resolved.startsWith(PMTILES_SCHEME)) {
const target = resolved.slice(PMTILES_SCHEME.length).trim()
return EXTERNAL.test(target) ? resolved : PMTILES_SCHEME + joinUrl(base, target)
}
return archiveUrl(base)
}
/**
* پالایش سبک نقشه پیش از تحویل به مپ‌لیبر — style.json خام MinIO با
* جانگهدار ({styleJsonFolder}) و نشانی MBTiles نوشته شده است؛ این تابع با یک
* پاس، گلیف/اسپرایت را به آدرس مطلق MinIO و منبع برداری را به آرشیو
* iran.pmtiles تبدیل می‌کند تا مستقیم به «new Map({ style })» داده شود.
*/
export function rewriteStyleUrls(
style: StyleSpecification,
base: string = MAP_BASE_URL,
): StyleSpecification {
const next: StyleSpecification = { ...style }
if (typeof next.glyphs === 'string') {
next.glyphs = resolveGlyphsUrl(next.glyphs, base)
}
// اسپرایت در نسخه ۸ مشخصات سبک، رشته یا فهرست {id, url} است
if (typeof next.sprite === 'string') {
next.sprite = absolutize(next.sprite, base)
} else if (Array.isArray(next.sprite)) {
next.sprite = next.sprite.map((sprite) => ({ ...sprite, url: absolutize(sprite.url, base) }))
}
const sources: StyleSpecification['sources'] = {}
for (const [key, source] of Object.entries(next.sources ?? {})) {
const rewritten = { ...source } as SourceSpecification & { url?: unknown; tiles?: unknown }
if (typeof rewritten.url === 'string') {
const raw = rewritten.url.trim()
if (isVectorSource(key, source)) {
rewritten.url = resolveVectorUrl(raw, base)
} else {
// رندر سخت‌گیرانه‌ی برداری: منابع غیربرداری (مثل raster) هرگز به آرشیو
// برداری هدایت نمی‌شوند — کاشی MVT زیر منبع raster یعنی خروجی خراب.
if (import.meta.env.DEV && source.type === 'raster') {
console.warn(`[map] منبع غیربرداری «${key}» در سبک هست؛ افزایش رزولوشن برداری برای آن اعمال نمی‌شود`)
}
rewritten.url = absolutize(raw, base)
}
}
if (Array.isArray(rewritten.tiles)) {
rewritten.tiles = rewritten.tiles.map((tile) =>
typeof tile === 'string' ? absolutize(tile, base) : tile,
)
}
sources[key] = rewritten
}
next.sources = sources
return next
}
/** بارگیری style.json از MinIO و بازگرداندن سبک پالایش‌شده برای «new Map({ style })» */
export async function loadMapStyle(signal?: AbortSignal): Promise<StyleSpecification> {
const res = await fetch(joinUrl(MAP_BASE_URL, 'style.json'), { signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return rewriteStyleUrls((await res.json()) as StyleSpecification)
}
+41
View File
@@ -0,0 +1,41 @@
import type { RequestTransformFunction } from 'maplibre-gl'
/** دو بخش آخر مسیر گلیف: پوشه‌ی پشته‌ی فونت و فایل بازه */
const GLYPH_PATH = /\/([^/?#]+)\/([^/?#]+\.pbf)$/
/**
* نشانی گلیف با تنها فونت نخستِ پشته.
*
* مپ‌لیبر برای هر لایه، تمام `text-font` را با کاما در {fontstack} می‌گذارد
* (مثل «Metropolis Regular,Noto Sans Regular»)؛ سرورهای عمومی فونت، پوشه‌ی
* همین پشته‌ی ترکیبی را دارند اما MinIO این پروژه فونت‌ها را در پوشه‌های
* تک‌فونتی نگه می‌دارد. نتیجه‌ی آن درخواست‌ها ۴۰۴ بود، متن‌ها رندر نمی‌شدند و
* سیل خطا اپ را قفل می‌کرد. با این بازنویسی فقط فونت نخست — همان ترجیح اول
* سبک — درخواست می‌شود و بقیه‌ی مسیر (پوشه‌ی v2.0 و فایل بازه) دست‌نخورده
* می‌ماند. نشانی بدون پشته‌ی ترکیبی یا ناشناس، بدون تغییر برمی‌گردد.
*/
export function singleFontGlyphUrl(url: string): string {
const queryIndex = url.search(/[?#]/)
const path = queryIndex === -1 ? url : url.slice(0, queryIndex)
const suffix = queryIndex === -1 ? '' : url.slice(queryIndex)
const match = path.match(GLYPH_PATH)
if (!match) return url
let fonts: string[]
try {
fonts = match[1].split(',').map((font) => decodeURIComponent(font).trim())
} catch {
return url
}
if (fonts.length < 2 || !fonts[0]) return url
return `${path.slice(0, match.index)}/${encodeURIComponent(fonts[0])}/${match[2]}${suffix}`
}
/**
* قلاب تبدیل درخواست‌های نقشه — فقط گلیف‌ها بازنویسی می‌شوند و هر درخواست
* دیگری دست‌نخورده می‌ماند تا رفتار پیش‌فرض مپ‌لیبر حفظ شود.
*/
export const mapTransformRequest: RequestTransformFunction = (url, resourceType) =>
resourceType === 'Glyphs' ? { url: singleFontGlyphUrl(url) } : { url }
+2
View File
@@ -0,0 +1,2 @@
/** وضعیت چرخه‌ی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
export type MapStatus = 'loading' | 'ready' | 'error'
+192
View File
@@ -0,0 +1,192 @@
import * as maplibregl from 'maplibre-gl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ensureMapWorker } from './mapWorker'
import { loadMapStyle } from './styleLoader'
import { mapTransformRequest } from './transformRequest'
import type { MapStatus } from './types'
type UseMapLibreOptions = {
center: [number, number]
zoom: number
/** بزرگ‌نمایی با چرخ ماوس و پینچ لمسی — پیش‌فرض فعال */
scrollZoom?: boolean
/** محدودهٔ مجاز جابه‌جایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژه‌ها */
maxBounds?: maplibregl.LngLatBoundsLike
}
/** سقف انتظار سبک نقشه — سرور کاشی بی‌پاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بی‌پایان */
const STYLE_TIMEOUT_MS = 10_000
/** اگر رویداد style.load دیر برسد، وضعیت آماده با این مهلت نمایش داده می‌شود */
const READY_FALLBACK_MS = 2_000
/**
* سقف بزرگ‌نمایی = maxzoom آرشیو + همین مقدار سطح overzoom.
*
* سنجش روی تایل‌های واقعی z14 اصفهان (۲۶٫۸۷۴ پاره‌خط جاده): میانه‌ی وتر ۲۰
* متر و صدک ۷۵ برابر ۳۶ متر است. با سه سطح overzoom (زوم ۱۷، ۰٫۵ متر بر
* پیکسل) میانه ۴۰ و صدک ۷۵ حدود ۷۲ پیکسل می‌شود که خم‌ها را هنوز صاف نشان
* می‌دهد؛ از ۱۷٫۵ به بعد وترها به ۱۰۰ پیکسل و بیشتر می‌رسند و چندضلعی‌شدن
* خم‌ها دیده می‌شود. متن و آیکون هرگز تار نمی‌شوند (متن SDF و آیکون اسپرایت
* است)؛ افت کیفیت فقط از هندسه‌ی ساده‌شده می‌آید. با بازتولید آرشیو در z16
* این سقف خودکار به ۱۹ می‌رسد.
*/
const OVERZOOM_LEVELS = 3
/** سقف مطلق زوم — بالاتر از آن حتی با داده‌ی دقیق‌تر هم نمایش مرورگر بی‌معناست */
const ABSOLUTE_MAX_ZOOM = 22
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
const FALLBACK_MAX_ZOOM = 17
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<maplibregl.Map | null>(null)
const [status, setStatus] = useState<MapStatus>('loading')
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
const container = containerRef.current
if (!container) return
let disposed = false
let instance: maplibregl.Map | null = null
let readyFallback: number | undefined
let dprQuery: MediaQueryList | null = null
let handleDprChange: (() => void) | null = null
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
setStatus('loading')
setMap(null)
// وب‌ورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
// وگرنه نخستین درخواست‌های کاشی در وب‌ورکر بی‌پاسخ می‌مانند
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
.then(([style]) => {
if (disposed) return
instance = new maplibregl.Map({
container,
style,
center,
zoom,
maxBounds,
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق می‌کند
maxZoom: FALLBACK_MAX_ZOOM,
renderWorldCopies: false,
// لبه‌های نرم‌تر خطوط و چندضلعی‌ها — حس لوکس رندر برداری
canvasContextAttributes: { antialias: true },
// کیفیت حداکثری در بزرگ‌نمایی‌های عمیق: هیچ سطحی با مقیاس‌کردن سطح
// بالاتر رندر نمی‌شود و همه‌ی سطح‌ها تا maxZoom از تقسیم هندسه
// می‌آیند (پیش‌فرض ۴ سطح آخر را overscale می‌کند). مبادله: کار
// بیشتر پردازنده در زوم‌های عمیق.
zoomLevelsToOverscale: 0,
// چرخ ماوس، پینچ لمسی و دکمه‌های کنترل — مطابق درخواست کاربر
scrollZoom,
attributionControl: { compact: true },
// MinIO فونت‌ها را در پوشه‌های تک‌فونتی دارد، پس پشته‌ی ترکیبی
// text-font («Metropolis Regular,Noto Sans Regular») به فونت نخست
// بازنویسی می‌شود — توضیح کامل در transformRequest.ts
transformRequest: mapTransformRequest,
})
/**
* سقف بزرگ‌نمایی را از maxzoom واقعی آرشیو می‌سازد؛ با داده‌ی دقیق‌تر
* (بازتولید آرشیو در z بالاتر) خودکار بالاتر می‌رود و کیفیت روی صفحه
* ثابت می‌ماند. زوم جاری هم اگر از سقف بیشتر باشد به آن محدود می‌شود.
*/
const applyZoomLimit = () => {
if (!instance) return
const source = instance.getSource('openmaptiles') as { maxzoom?: number } | undefined
const sourceMaxZoom = typeof source?.maxzoom === 'number' ? source.maxzoom : null
instance.setMaxZoom(
sourceMaxZoom === null
? FALLBACK_MAX_ZOOM
: Math.min(sourceMaxZoom + OVERZOOM_LEVELS, ABSOLUTE_MAX_ZOOM),
)
}
instance.on('style.load', () => {
if (disposed) return
setStatus('ready')
window.setTimeout(() => instance?.resize(), 150)
})
/**
* کنترل بزرگ‌نمایی همراه خود نقشه ساخته می‌شود و با map.remove() پایین
* می‌آید — map.remove به‌تنهایی onRemove همه‌ی کنترل‌ها را صدا می‌زند،
* پس removeControl دستی پس از آن، onRemove را دوباره اجرا می‌کند و با
* «reading 'off' of undefined» کرش می‌کند. مالکیت کنترل همین‌جا، کنار
* مالکیت نقشه می‌ماند تا ترتیب پاک‌سازی هیچ‌وقت به React وابسته نباشد.
*/
instance.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right')
// maxzoom منبع همراه متادیتای TileJSON می‌رسد — همان لحظه سقف را قطعی کن
instance.on('sourcedata', (e) => {
if (e.sourceDataType === 'metadata' && e.sourceId === 'openmaptiles') applyZoomLimit()
})
instance.on('load', applyZoomLimit)
applyZoomLimit()
// تایمر فالبک امنیتی
readyFallback = window.setTimeout(() => {
if (disposed) return
setStatus('ready')
instance?.resize()
}, READY_FALLBACK_MS)
instance.on('error', (e) => {
console.warn('MapLibre minor error:', e)
})
/**
* نسبت پیکسل دستگاه منبع حقیقت بافر بوم است؛ با زوم مرورگر
* (Ctrl+zoom) یا جابه‌جایی پنجره روی مانیتور با چگالی دیگر عوض
* می‌شود. اگر بافر با نسبت تازه هم‌گام نشود، نقشه تا رفرش بعدی تار
* می‌ماند. matchMedia هر تغییر را دقیقاً همان لحظه گزارش می‌دهد و
* پس از هر تغییر دوباره مسلح می‌شود.
*/
function syncPixelRatio() {
if (!instance) return
if (instance.getPixelRatio() !== window.devicePixelRatio) {
instance.setPixelRatio(window.devicePixelRatio)
}
instance.resize()
}
function watchPixelRatio() {
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
handleDprChange = () => {
syncPixelRatio()
watchPixelRatio()
}
dprQuery.addEventListener('change', handleDprChange)
}
watchPixelRatio()
// دسترسی اشکال‌زدایی در محیط توسعه — در بیلد تولیدی وجود ندارد
if (import.meta.env.DEV) {
(window as unknown as { __map?: maplibregl.Map }).__map = instance
}
setMap(instance)
})
.catch(() => {
if (!disposed) setStatus('error')
})
return () => {
disposed = true
window.clearTimeout(timeoutId)
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
controller.abort()
instance?.remove()
}
// مرکز و بزرگ‌نمایی فقط موقعیت اولیه نقشه‌اند؛ فهرست وابستگی عمداً فقط reloadKey است
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey])
const retry = useCallback(() => setReloadKey((key) => key + 1), [])
return { containerRef, map, status, retry }
}
+156
View File
@@ -0,0 +1,156 @@
import * as maplibregl from 'maplibre-gl'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { MapProject } from '../data/mapProjects'
import { ProjectPin } from './ProjectPin'
type PinHandle = { project: MapProject; marker: maplibregl.Marker; root: Root }
/** حاشیهٔ امن پاپ‌آپ از لبه‌های نقشه — بالای صفحه بازتر، برای نوار برچسب و کنترل‌ها */
const AUTO_PAN_PADDING = { top: 150, bottom: 20, left: 20, right: 20 }
/**
* نگه‌داشتن پاپ‌آپ داخل کادر دید — معادل رفتار autoPan که مپ‌لیبر (برخلاف
* مپ‌باکس) گزینه‌اش را ندارد: اگر کارت از حاشیهٔ امن بیرون بزند، نقشه نرم
* جابه‌جا می‌شود تا کارت کامل دیده شود.
*/
function ensurePopupVisible(instance: maplibregl.Map, popup: maplibregl.Popup): void {
const element = popup.getElement()
const container = instance.getContainer()
if (!element || !container) return
// چیدمان پاپ‌آپ در همان فریم addTo کامل می‌شود، پس اندازه‌گیری به فریم بعد موکول است
window.requestAnimationFrame(() => {
const popupRect = element.getBoundingClientRect()
const viewRect = container.getBoundingClientRect()
const excessTop = viewRect.top + AUTO_PAN_PADDING.top - popupRect.top
const excessBottom = popupRect.bottom - (viewRect.bottom - AUTO_PAN_PADDING.bottom)
const excessLeft = viewRect.left + AUTO_PAN_PADDING.left - popupRect.left
const excessRight = popupRect.right - (viewRect.right - AUTO_PAN_PADDING.right)
// کمبود افقی و عمودی؛ دوربین به اندازهٔ بیرون‌زدگی جابه‌جا می‌شود
const dx = Math.max(0, excessLeft) - Math.max(0, excessRight)
const dy = Math.max(0, excessTop) - Math.max(0, excessBottom)
if (!dx && !dy) return
instance.panBy([-dx, -dy], { duration: 320 })
})
}
/**
* لایه‌ی نشانگرهای پروژه — ساخت نشانگرها با React (هر نشانگر یک ریشه‌ی
* کوچک)، یک پاپ‌آپ مشترک برای همه، و وضعیت پروژه‌ی فعال.
*
* پاپ‌آپ هیچ‌وقت خودکار باز نمی‌شود؛ فقط با کلیک کاربر روی سنجاق. پاپ‌آپ یک
* المان میزبان دارد که بیرون از React ساخته می‌شود؛ مصرف‌کننده محتوای React را
* با createPortal داخل همان میزبان می‌ریزد تا Link روتر، حالت هاور و
* انیمیشن‌ها همه در درخت React بمانند.
*/
export function useProjectMarkers(map: maplibregl.Map | null, projects: MapProject[]) {
const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null)
const [activeProject, setActiveProject] = useState<MapProject | null>(null)
const popupRef = useRef<maplibregl.Popup | null>(null)
const pinsRef = useRef<PinHandle[]>([])
const activeRef = useRef<MapProject | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
mapRef.current = map
const openProject = useCallback((project: MapProject) => {
const popup = popupRef.current
const instance = mapRef.current
if (!popup || !instance) return
activeRef.current = project
popup.setLngLat([project.lng, project.lat]).addTo(instance)
ensurePopupVisible(instance, popup)
setActiveProject(project)
}, [])
const closePopup = useCallback(() => {
activeRef.current = null
const popup = popupRef.current
// remove() فقط روی پاپ‌آپ باز صدا زده می‌شود؛ پاپ‌آپ بسته مالک مرورگر است
if (popup?.isOpen()) popup.remove()
setActiveProject(null)
}, [])
/** پاپ‌آپ مشترک + المان میزبان محتوا — یک‌بار برای هر نمونه نقشه */
useEffect(() => {
if (!map) return
const host = document.createElement('div')
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: true,
offset: 46,
maxWidth: 'none',
className: 'lux-map-popup',
}).setDOMContent(host)
// بستن با کلیک روی نقشه یا دکمه‌ی کارت، وضعیت فعال را هم پاک می‌کند
popup.on('close', () => {
activeRef.current = null
setActiveProject(null)
})
popupRef.current = popup
setPopupHost(host)
return () => {
// فقط پاپ‌آپِ باز پایین آورده می‌شود؛ popup.remove خودش هم به رویداد
// remove نقشه گوش می‌دهد، پس این پاک‌سازی تکراری و بی‌خطر است
if (popup.isOpen()) popup.remove()
popupRef.current = null
setPopupHost(null)
}
}, [map])
/** نشانگرها — با تغییر فهرست پروژه‌ها یا نمونه‌ی نقشه بازساخته می‌شوند */
useEffect(() => {
if (!map) return
const pins: PinHandle[] = []
for (const project of projects) {
const element = document.createElement('div')
const marker = new maplibregl.Marker({ element, anchor: 'bottom' })
.setLngLat([project.lng, project.lat])
.addTo(map)
const root = createRoot(element)
root.render(<ProjectPin label={project.name} active={false} onClick={() => openProject(project)} />)
pins.push({ project, marker, root })
}
pinsRef.current = pins
return () => {
for (const pin of pins) {
// نشانگر خودش پاک‌سازی امن دارد (حذف شنونده‌ها + برداشتن المان)
pin.marker.remove()
/**
* unmount ریشه‌ی React هنگام جدا شدن والد، وسط چرخه‌ی رندر رخ می‌دهد و
* React 18 برای unmount همگام ریشه‌ی تودرتو هشدار می‌دهد
* («Attempted to synchronously unmount a root while React was already
* rendering»). پس به میکروتسک بعد از پایان کامیت موکول می‌شود؛ المان
* نشانگر تا آن لحظه از DOM جدا شده و unmount روی ظرف جدا امن است.
*/
queueMicrotask(() => pin.root.unmount())
}
pinsRef.current = []
}
}, [map, projects, openProject])
/** با تغییر پروژه‌ی فعال، فقط هاله‌ی سنجاق‌ها به‌روز می‌شود (بازسازی نمی‌شوند) */
useEffect(() => {
for (const pin of pinsRef.current) {
pin.root.render(
<ProjectPin
label={pin.project.name}
active={activeProject?.id === pin.project.id}
onClick={() => openProject(pin.project)}
/>,
)
}
}, [activeProject, openProject])
return { popupHost, activeProject, openProject, closePopup }
}
+61 -34
View File
@@ -1,28 +1,35 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button';
import { PremiumButton } from '../components/PremiumButton';
import { ArticleCard } from '../components/ArticleCard';
import { ArticleMeta } from '../components/ArticleMeta';
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo';
import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import { docToText } from '../utils/tiptapDoc';
import { detectDirection } from '../utils/direction';
import { wrapScriptRuns } from '../utils/bidiText';
import type { Article } from '../data/site';
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } {
function renderBody(
article: Article,
dir: 'rtl' | 'ltr',
): { html: string | null; toc: TocItem[]; legacy: string[] } {
const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try {
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
// تیترهای H2/H3 شناسه لنگر می‌گیرند تا فهرست مطالب به آن‌ها بپرد
const { items, html: htmlWithIds } = extractToc(html);
// تیترها شناسه لنگر می‌گیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچک‌تر» بسته‌بندی می‌شود
const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
return { html: htmlWithIds, toc: items, legacy: [] };
} catch {
return { html: null, toc: [], legacy: [] };
@@ -34,54 +41,67 @@ function renderBody(article: Article): { html: string | null; toc: TocItem[]; le
export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>();
const { articles, brand } = useSite();
const { t, href, lang } = useLang();
const article = articles.find((a) => a.slug === slug);
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
useEffect(() => {
if (!article) return;
applySeo({
title: `${article.title} | ${brand}`,
title: `${article.title} | ${brand.name}`,
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description: article.meta_description || article.excerpt,
path: `/blog/${article.slug}`,
path: href(`/blog/${article.slug}`),
image: article.image,
type: 'article',
lang,
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: 'fa',
author: { '@type': 'Organization', name: article.author?.trim() || brand },
inLanguage: lang,
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
},
});
}, [article?.id]);
}, [article?.id, lang, href]);
if (!article) {
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">این مقاله پیدا نشد</h1>
<p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p>
<ButtonLink to="/blog" variant="copper" className="mt-8">
بازگشت به وبلاگ
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
<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>
<PremiumButton to={href('/blog')} className="mt-8">
{t('backToBlog')}
</PremiumButton>
</section>);
}
const { html, toc, legacy } = renderBody(article);
const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
const { html, toc, legacy } = renderBody(article, dir);
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
/**
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
* از همین پیروی می‌کنند: فارسی راست‌به‌چپ (فهرست سمت راست)، انگلیسی چپ‌به‌راست (فهرست سمت چپ).
*/
const hasToc = toc.length > 0;
// در چیدمان راست‌به‌چپ ستون شماره ۱ سمت راست است؛ پس ستون‌ها صریح شماره می‌خورند
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
const bodyColumns = 'lg:col-start-2';
const tocColumns = 'lg:col-start-1';
return (
<>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img
src={article.image}
alt={article.image_alt || article.title}
title={article.image_title || undefined}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
@@ -91,15 +111,15 @@ export function ArticleDetail() {
aria-hidden="true" />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to="/" className="transition-colors hover:text-copper">
صفحه اصلی
<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">
{t('home')}
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
<Link to="/blog" className="transition-colors hover:text-copper">
وبلاگ
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
<Link to={href('/blog')} className="transition-colors hover:text-copper">
{t('blog')}
</Link>
<ChevronLeftIcon className="h-3.5 w-3.5" />
{dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
<span className="text-copper">{article.category}</span>
</nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
@@ -111,10 +131,14 @@ export function ArticleDetail() {
<article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14">
<div className="min-w-0">
<div
dir={dir}
data-dir={dir}
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
>
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
<Reveal>
<p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80">
<p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
{article.excerpt}
</p>
</Reveal>
@@ -133,9 +157,12 @@ export function ArticleDetail() {
)}
</div>
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان می‌ماند */}
{toc.length > 0 && (
<aside className="order-first lg:order-none">
{/* فهرست مطالب — در دسکتاپ کنار متن می‌چسبد و همراه مقاله پایین می‌آید */}
{hasToc && (
<aside
dir={dir}
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
>
<TableOfContents items={toc} className="lg:sticky lg:top-28" />
</aside>
)}
@@ -143,9 +170,9 @@ export function ArticleDetail() {
</div>
</article>
<section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط">
<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">
<h2 className="text-section font-bold text-ivory">مقالات مرتبط</h2>
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) =>
+19 -15
View File
@@ -6,23 +6,27 @@ import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter'
import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) {
const { t } = useLang()
return (
<span
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
>
پیش‌نویس
{t('draft')}
</span>
)
}
export function Blog() {
const { articles } = useSite()
const { t, href } = useLang()
const { isEditMode } = useAdmin()
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود
@@ -33,7 +37,7 @@ export function Blog() {
className="inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
>
<PlusIcon className="h-4 w-4" />
نوشتن مقاله جدید
{t('writeNewArticle')}
</Link>
) : null
@@ -42,11 +46,11 @@ export function Blog() {
return (
<>
<PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}>
<div className="mx-auto max-w-site px-6 text-center lg:px-10">
<h2 className="text-3xl font-bold text-ivory">هنوز مقاله‌ای منتشر نشده است</h2>
<h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2>
<p className="mt-4 text-sm font-light text-ivory/60">
به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.
{t('blogEmptyText')}
</p>
<div className="mt-8 flex justify-center">{writeButton}</div>
</div>
@@ -62,28 +66,28 @@ export function Blog() {
<>
<PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mb-10 flex justify-start">{writeButton}</div>
<Reveal>
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
<Link
to={`/blog/${featured.slug}`}
to={href(`/blog/${featured.slug}`)}
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
aria-label={`خواندن مقاله ${featured.title}`}
aria-label={`${t('readArticlePrefix')} ${featured.title}`}
>
<div className="overflow-hidden">
<img
src={featured.image}
alt={`تصویر مقاله ${featured.title}`}
alt={`${t('articleImagePrefix')} ${featured.title}`}
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</div>
<div className="flex flex-col justify-center p-8 lg:p-12">
<p className="text-xs font-light text-copper">
مقاله ویژه — {featured.category}
{t('featuredArticle')} — {featured.category}
{featured.status === 'draft' && (
<DraftBadge className="mr-3 inline-block align-middle" />
<DraftBadge className="inline-block align-middle ltr:ml-3 rtl:mr-3" />
)}
</p>
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
@@ -95,12 +99,12 @@ export function Blog() {
<span>{formatJalali(featured.date)}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{featured.read}
{toPersianDigits(featured.read)}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" />
{t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-200 ease-lux rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</span>
</div>
</div>
@@ -114,7 +118,7 @@ export function Blog() {
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<div className="relative h-full">
{article.status === 'draft' && (
<DraftBadge className="absolute left-3 top-3 z-10" />
<DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" />
)}
<ArticleCard article={article} />
</div>
+4 -19
View File
@@ -3,34 +3,19 @@ import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal'
import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { AddItemButton, EditableItem } from '../admin/Editable'
export function ContactPage() {
const { offices } = useSite()
const { t } = useLang()
return (
<>
<PageHero pageKey="contact" />
<section className="bg-ink py-24" aria-label="دفاتر ما">
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full">
<EditableItem resource="offices" item={office} className="h-full">
<article className="flex h-full flex-col border border-ivory/10 bg-char px-8 py-12 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
<h2 className="text-2xl font-bold text-copper">{office.city}</h2>
<p className="mt-6 text-sm font-light leading-9 text-ivory/70">{office.address}</p>
<div className="mt-auto pt-10">
<p className="border-t border-ivory/10 pt-6 text-xs font-light text-ivory/50">
{office.hours}
</p>
</div>
</article>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="offices" />
</div>
<section className="bg-ink py-2" aria-label={t('officesAria')}>
</section>
<ContactSection />

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