Author SHA1 Message Date
fatemeh sane 853ad67db5 feat(ui): update UI v1 2026-09-25 22:49:30 +03:30
MMD 805f4594d4 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/pages/ArticleDetail.tsx
2026-09-17 12:31:04 +03:30
fatemeh sane 7a0defc3d4 fix(admin): resolve Tiptap editor bugs, dynamic RTL/LTR layout, sticky TOC, and equalize Bidi typography 2026-09-17 10:10:50 +03:30
MMD fe6957bf83 Merge remote-tracking branch 'origin/dashboard/blog-changes'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
2026-09-15 14:41:46 +03:30
fatemeh sane 049291c109 feat(admin): enhance editor UI (links, image captions) & fix categories
Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
2026-09-15 11:33:04 +03:30
MMD 53673d0f9b Merge branch 'bilingual-en'
# Conflicts:
#	frontend/src/admin/ArticleWritePage.tsx
#	frontend/src/admin/EntityDrawer.tsx
#	frontend/src/admin/entities.ts
#	frontend/src/components/ArticleCard.tsx
#	frontend/src/index.css
#	frontend/src/pages/ArticleDetail.tsx
2026-09-13 18:45:06 +03:30
MMD 9d55f521a0 Merge remote-tracking branch 'origin/dashboard/blog-changes' 2026-09-13 18:29:51 +03:30
MMD 35c03ef35e اصلاح عنوان تب صفحات جزئیات — brand به‌جای brand.name 2026-09-13 18:29:34 +03:30
fatemeh sane bb6c586d99 Update blog editor features 2026-09-13 10:39:25 +03:30
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
fatemeh sane 20c95e73b3 Merge branch 'site-v2' of https://git.bennu-api-bahrestan.ir/Behrad/aramesh-backend 2026-09-12 19:19:45 +03:30
fatemeh sane 5837e4b0d9 Update README file 2026-09-12 10:56:03 +03:30
MMD 1fb5d57da1 مستندات: راه‌اندازی روشن برای تازه‌واردها در README
- مرحله npm run build فرانت‌اند (لازم برای اولین اجرا) اضافه شد
- اشاره به seed شدن خودکار تصاویر از seed-media در بیلد تازه
- یادآوری اینکه هیچ فایل رمزدار در گیت نیست و فقط .env.example کپی شود
2026-09-12 10:52:34 +03:30
Behrad 8abc70dcf1 Add new updated site 2026-09-12 10:35:17 +03:30
MMD f2e5a9e58b فونت: آوردن فونت اصلی Pinar (۵ وزن) به‌جای جانشین Vazirmatn
فایل‌های اصلی Pinar از شاخه feature/frontend-updates گرفته شد (fatemeh sane آن‌ها را
سلف‌هاست کرده بود) — همان فونتی که پیش‌تر CDN از کار افتاده‌اش را سرو می‌کرد:
- ۵ وزن (Light 300، Regular 400، Bold 700، ExtraBold 800، Black 900) در
  frontend/public/fonts/pinar به‌صورت woff2 + ttf
- index.css: Pinar فونت اصلی و Vazirmatn جایگزین پشتیبان
- tailwind: کلاس pinar دوباره Pinar را اول می‌گذارد
2026-09-12 10:25:49 +03:30
MMD 5ae5421c5c رسانه: پیش‌بارگذاری تصاویر در بیلد تازه — seed-media + سینک خودکار MinIO
تا وقتی کسی کلون/بیلد تازه می‌گیرد، همان تصاویر را داشته باشد:
- seed-media شامل هر ۱۹ فایل تصویری است که در دیتابیس با پیشوند /media ثبت‌اند
- minio-setup بعد از ساخت باکت، محتوای seed-media را با mc mirror در bucket
  می‌گذارد — روی حجم موجود idempotent است و داده تازه را نمی‌خورد
2026-09-08 14:46:06 +03:30
MMD c169703a17 رسانه: اسکریپت انتقال تصاویر CDN به MinIO (migrate-media.py)
ابزار یک‌باره برای وقتی میزبان عکس (مثل i.postimg.cc) در دسترس است:
- از /api/site فهرست همه تصاویرِ URL خارجی را می‌گیرد، هرکدام را دانلود و از
  انتهای ادمین در MinIO آپلود می‌کند و مقدار فیلد را به /media/... بازنویسی می‌کند
- اعتبارسنجی فایل با هدر (تا HTML/خطا ذخیره نشود) + فرمت واقعی برای آپلود
- خروجی گزارش ردیف‌های ناتمام برای اجرای دوباره
دیتابیسِ همین سایت هم با آن منتقل شد: ۱۹ تصویر در باکت aramesh-media و دیگر
هیچ URL خارجی (postimg/housedesign) در محتوای سایت نیست.
2026-09-08 14:24:58 +03:30
MMD 07da45467b وبلاگ: دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود
قبلاً برای بازدیدکننده عادی هم نمایش داده می‌شد
2026-09-08 13:27:40 +03:30
MMD 4b9cc13163 فونت: سلف‌هاست Vazirmatn — بدون دانلود هر بار از CDN
- فونت قبلی (Pinar) از fontcdn.ir دانلود می‌شد که از کار افتاده و گوگل‌فونت هم در ایران مسدود است؛ در نتیجه هر بار فونت دانلود می‌شد و در نهایت Tahoma نمایش داده می‌شد
- فایل‌های woff2 (Light/Regular/Bold) یک بار در public/fonts ذخیره شده و از همان سرور سرو می‌شوند — مرورگر کش می‌کند و هیچ وابستگی خارجی ندارد
- کلاس font-pinar برای سازگاری با کامپوننت‌ها نگه داشته شد
2026-09-08 13:27:40 +03:30
MMD f1046a6598 رفع: مقدار پیش‌فرض CORS_ORIGINS در compose بدون کوتیشن — بک‌اند بالا می‌آمد
پیش‌فرض داخل interpolate با کوتیشن تک، به‌صورت حرفی وارد env می‌شد و pydantic
JSON را پارس نمی‌کرد (SettingsError) — باعث restart-loop بک‌اند می‌شد
2026-09-08 12:22:40 +03:30
MMD 5731eda438 توسعه: برگرداندن پروکسی Vite و پورت‌های compose به مسیر Traefik
- vite.config.ts دوباره /api و /media را به Traefik (پورت 5173) پروکسی می‌کند
- compose: بدون پورت مستقیم 8000/9000 — بک‌اند لوکال اینجا روی 8001 است و پورت 8000 متعلق به استک دیگر
2026-09-08 12:14:56 +03:30
Behrad 17bddd3adf feat: update UI to glassmorphic design and copper theme 2026-09-08 12:07:34 +03:30
MMD 2ad3ad7367 زیرساخت: رمزهای اجباری از .env + لایه تولید با TLS + healthcheck و مسیر سئوی Traefik + پشتیبان‌گیری
- docker-compose.yml همه مقادیر محرمانه را با ${VAR:?} از .env می‌خواند — بدون .env استک بالا نمی‌آید
- docker-compose.prod.yml: ورودی 443 + ریدایرکت HTTP به HTTPS + گواهی Let's Encrypt و قواعد Host برای همه روترها
- healthcheck بک‌اند (api/health) و فرانت (wget) + سوار کردن frontend/dist در بک‌اند برای تزریق متا + روتر سئو (robots/sitemap/blog/projects) با اولویت 50
- سرویس mc با پروفایل tools + scripts/backup.sh (pg_dump + آینه MinIO با نگهداری ۱۴ روزه)
- .env.example ریشه + بروزرسانی READMEها
2026-09-07 04:26:18 +03:30
MMD b31124e1e4 وبلاگ و سئو: پیش‌نویس/انتشار مقاله + کلمه کلیدی سئو در دیتابیس + OG/canonical/JSON-LD و متای سمت سرور + تغییر رمز و محدودسازی ورود + تست‌ها
- مقاله‌ها ستون status (draft/published) گرفتند — GET /api/site و همه انتهای عمومی فقط منتشرشده را برمی‌گردانند؛ autosave دیگر پیش‌نویس نیمه‌کاره را زنده نمی‌کند
- کلمه کلیدی هدف چک‌لیست سئو فیلد seo_keyphrase شد (قبلاً localStorage بود)
- robots.txt و sitemap.xml از بک‌اند + سرو /blog و /projects با تزریق title/description/OG/JSON-LD از دیتابیس برای خزنده‌های بدون JS (app/seo_pages.py)
- فرانت: applySeo در seo.ts (OG/توییتر/canonical/JSON-LD)، ادغام پیش‌نویس برای ادمین، برچسب پیش‌نویس در وبلاگ، حالت خالی وبلاگ، دکمه انتشار در صفحه نوشتن
- تغییر رمز ادمین POST /api/auth/change-password + دیالوگ «رمز» در نوار مدیریت
- محدودسازی ورود: ۵ تلاش ناموفق در ۱۵ دقیقه از هر IP → 429
- ساخت دکمه + برای ردیف سئوی صفحه‌های بدون ردیف (SingletonPill)
- تست‌های pytest (۱۵ مورد) + پین‌کردن requirements + حذف package.json سرگردان فرانت
2026-09-07 04:26:18 +03:30
MMD a29deb8bb1 سئو: بررسی سئو در صفحه نوشتن + عنوان و توضیح متای هر صفحه
- پنل «بررسی سئو» در صفحه نوشتن مقاله — سبز/نارنجی/قرمز سازگار با فارسی:
  طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیتر H2)، طول چکیده،
  تعداد واژه، تیتربندی، متن جایگزین تصاویر، پیوندها و slug.
  کلمه کلیدی در localStorage نگه داشته می‌شود (راهنمای ویرایشگر است، نه محتوا)
- جدول seo_meta: عنوان و توضیح متای هر صفحه (page_key برای هر ۸ صفحه،
  seed خودکار، CRUD در /api/admin/seo-meta، خواندن در GET /api/site)
- تزریق عنوان/توضیح متا در فرانت‌اند: صفحه‌های فهرست از دیتابیس؛ صفحه
  مقاله و پروژه از محتوای خودشان (بدون تداخل با اثر سطح والد)
- دکمه سئو روی هیرو هر صفحه و کنار هیرو صفحه اصلی (فقط حالت ویرایش)
2026-09-07 03:12:12 +03:30
MMD 17345daa81 وبلاگ: صفحه تمام‌عرض «نوشتن مقاله» — محیط نوشتن متمرکز برای ادمین
- /admin/write (مقاله جدید) و /admin/write/:id (ویرایش) — صفحه کامل
  بدون چرخه سایت: نوار بالا (بازگشت، وضعیت ذخیره، ذخیره، دیدن در سایت،
  حذف)، عنوان بزرگ، تصویر شاخص، ویرایشگر TipTap تمام‌ارتفاع و بخش
  جزئیات انتشار (چکیده، دسته، تاریخ، زمان مطالعه، slug)
- ذخیره خودکار با debounce + flush هنگام خروج؛ اولین ذخیره مقاله را
  منتشر می‌کند و مسیر به /admin/write/:id تغییر می‌کند
- قلم‌ویژه مقاله‌ها (مداد روی کارت) ادمین را به همین صفحه می‌برد
  (editHref روی EditableItem)؛ لینک‌های data-admin-link در حالت ویرایش
  کار می‌کنند
- رفع: create-on-unmount حذف شد (با draft قدیمی مقاله سرگردانی
  می‌ساخت) — unmount فقط برای مقاله ساخته‌شده PATCH می‌زند
2026-09-07 02:35:41 +03:30
MMD 2c96562b66 وبلاگ: دکمه «نوشتن مقاله جدید» بالای صفحه + ویرایشگر متن در ابتدای کشو
- دکمه پررنگ «نوشتن مقاله جدید» بالای صفحه وبلاگ (فقط در حالت ویرایش) —
  ساخت مقاله جدید و باز شدن مستقیم کشوی نوشتن
- ترتیب فیلدهای کشوی مقاله: عنوان، سپس ویرایشگر WYSIWYG — نوشتن اول،
  متادیتا (دسته/تاریخ/اسلاگ و...) بعد
- رفع باگ: ساخت رکورد قبلاً کل درخت را reload می‌کرد و کشوی
  تازه‌ساخته‌شده بی‌درنگ بسته می‌شد؛ حالا آیتم ساخته‌شده به‌صورت
  خوش‌بینانه به لیست اضافه می‌شود و کشو باز می‌ماند
2026-09-07 02:09:17 +03:30
MMD 70cf9616c1 بیلد: انتقال بیلد فرانت‌اند به هاست + پیش‌فرض هم‌مبدأ API
- Dockerfile فرانت‌اند: npm ci داخل کانتینر بیلد به‌دلیل محدودیت شبکه
  (registry.npmjs.org از داخل VM داکر در دسترس نیست) غیرقابل‌اعتماد بود؛
  ایمیج حالا فقط Caddy است و خروجی بیلد هاست (npm run build → dist/) را
  سرو می‌کند — بیلد ایمیج از چند دقیقه آویزان به چند ثانیه رسید
- docker-compose.yml: حذف build args از سرویس frontend
- config.ts: پیش‌فرض VITE_API_URL خالی است (درخواست هم‌مبدأ از طریق
  Traefik) — مقدار قبلی http://localhost:8000 میراث معماری قبل بود و
  بیلد هاست را خراب می‌کرد
- README ریشه: بخش وبلاگ (ویرایشگر WYSIWYG، فرمت سند JSON، مهاجرت
  خودکار مقالات قدیمی) + یادداشت بیلد هاست برای فرانت‌اند
2026-09-06 23:33:28 +03:30
MMD cfe5512ec7 فرانت‌اند: ویرایشگر WYSIWYG وبلاگ (TipTap) با آپلود تصویر در متن
- ArticleEditor: ویرایشگر TipTap v3 راست‌به‌چسب با نوار ابزار فارسی —
  بولد/ایتالیک/زیرخط/خط‌خورده، تیتر H2/H3، لیست نقطه‌ای/شماره‌ای،
  نقل‌قول، لینک (مسدودسازی javascript: با isAllowedUri)، واگرد/ازنو
- درج تصویر داخل متن: دکمه آپلود، چسباندن یا کشیدن فایل — ذخیره در
  MinIO از طریق /api/admin/upload و درج URL نسبی /media/...
- ذخیره خودکار: debounce ۸۰۰ms + flush هنگام بستن کشو
- کشوی مقاله عریض‌تر (max-w-3xl)؛ فیلد body مقالات به‌جای لیست متنی،
  ویرایشگر WYSIWYG رندر می‌کند
- رندر صفحه مقاله: تبدیل سند به HTML با @tiptap/static-renderer
  (فقط تگ‌های شناخته‌شده — بدون ریسک XSS) + استایل‌های .article-body
  (تیتر با خط مسی، لیست، نقل‌قول، تصویر) + پشتیبانی از ردیف‌های قدیمی
- وابستگی‌های جدید: @tiptap/react، @tiptap/pm، @tiptap/starter-kit،
  @tiptap/extension-image، @tiptap/extension-link، @tiptap/static-renderer
2026-09-06 23:33:08 +03:30
MMD 8f11df99ea بک‌اند: بدنه مقاله به سند ساختاریافته TipTap (JSON) + مهاجرت داده
- body مقاله از لیست پاراگراف به سند ProseMirror تغییر کرد:
  {"type": "doc", "content": [...]} — در In/Out/Patch schemas
- اعتبارسنجی ساختاری (type=doc + content لیست) با field_validator —
  امنیت رندر بر عهده رندر استاتیک TipTap در فرانت‌اند است
- app/migrations.py: مهاجرت‌های DDL و داده در یک‌جا؛ migrate_article_bodies
  ردیف‌های قدیمی (لیست پاراگراف) را idempotent به سند تبدیل می‌کند —
  در startup و seed اجرا می‌شود، دیتابیس موجود بدون از دست رفتن داده ارتقا می‌یابد
- seed بدنه‌های لیستی را قبل از درج به سند تبدیل می‌کند
- README بک‌اند: فرمت بدنه و رفتار مهاجرت مستند شد
2026-09-06 23:32:52 +03:30
MMD b7d29d1028 مستندسازی ویرایش زنده و انتهای‌نقطه‌های جدید در README
- README ریشه: بخش «ویرایش زنده سایت» — ورود از /admin/login، حالت
  ویرایش، ذخیره خودکار هر فیلد، ویرایش/حذف/ترتیب مجموعه‌ها، آپلود تصویر
  و نامرئی بودن لایه برای بازدیدکنندگان
- README بک‌اند: افزودن PATCH (ویرایش جزئی) و reorder به جدول
  انتهای‌نقطه‌ها + فهرست منابع دارای ستون position و نحوه مهاجرت سبک
2026-09-06 22:30:17 +03:30
MMD 5ed082631e فرانت‌اند: ویرایش زنده درجا — دوقلوی دیجیتال سایت برای ادمین
لایه ویرایش بصری روی سایت واقعی (بدون داشبورد جدا):

- ورود ادمین از مسیر پنهان /admin/login با توکن JWT (localStorage) و
  AdminContext شامل isEditMode و وضعیت ذخیره (همگام/در حال ذخیره/خطا)
- AdminBar شناور پایین صفحه (فقط برای کاربر واردشده): کلید حالت ویرایش،
  وضعیت ذخیره، خروج — حالت ویرایش در sessionStorage می‌ماند
- InlineEdit: در حالت عادی دقیقاً همان HTML قبلی (بدون تغییر چیدمان)؛
  در حالت ویرایش کادرخط‌چین + contentEditable با ذخیره خودکار در blur
  (Enter ذخیره، Esc لغو) — وایرایش‌شده روی همه متن‌های سایت: هیرو،
  تیتر بخش‌ها، هیرو صفحات، داستان، فرم تماس، خبرنامه، فوتر، منو،
  برچسب‌های وضعیت پروژه و...
- کشوی عمومی EntityDrawer با پیکربندی متمرکز برای ۱۴ مجموعه (خدمات،
  پروژه‌ها، مقالات، نظرات، سوالات، تیم، تایم‌لاین، آمار، چرا آرامش،
  مدل‌های سرمایه‌گذاری، مراحل کار، دفاتر، گالری، منو): ویرایش چندفیلدی،
  افزودن، حذف و جابه‌جایی ترتیب (▲▼) با ذخیره خودکار هر فیلد
- دکمه‌های شناور ویرایش/حذف/ترتیب فقط در حالت ویرایش روی آیتم‌ها
- کشوهای یگانه: برند و لوگو، تنظیمات هیرو، فرم تماس، خبرنامه، فوتر،
  قبل/بعد و نقل‌قول داستان — آپلود تصویر به MinIO از همان‌جا
- به‌روزرسانی خوش‌بینانه SiteContext (updateSite) پس از هر PATCH؛
  مسیرها در حالت ویرایش کلیک نمی‌شوند؛ breadcrumb محافظ beforeunload
2026-09-06 21:57:46 +03:30
MMD 6e422ec3dc بک‌اند: ویرایش جزئی (PATCH) و ترتیب‌بندی مجموعه‌ها برای ویرایش زنده
- PATCH /api/admin/{resource}/{id} — ویرایش جزئی؛ فقط فیلدهای ارسال‌شده
  به‌روزرسانی می‌شوند (PUT قبلاً کل رکورد را جایگزین می‌کرد و برای
  ویرایش زنده قابل استفاده نبود). اسکیماهای Patch به‌صورت خودکار از
  اسکیماهای In با create_model ساخته می‌شوند (هر ۲۴ منبع).
- ستون position برای ۱۴ جدول مجموعه‌ای (خدمات، پروژه‌ها، مقالات، نظرات،
  سوالات، تیم، تایم‌لاین، آمار، چرا آرامش، مدل‌های سرمایه‌گذاری، مراحل کار،
  دفاتر، گالری، منو) — مهاجرت سبک idempotent در startup و seed
  (ALTER TABLE ADD COLUMN IF NOT EXISTS) تا دیتابیس‌های موجود هم ارتقا یابند
- POST /api/admin/{resource}/reorder — ذخیره ترتیب جدید بر اساس لیست id
- خواندن عمومی بر اساس (position, id) مرتب می‌شود؛ رکورد جدید در انتهای لیست
- seed فقط جدول‌های خالی را با position صحیح پر می‌کند
2026-09-06 21:57:07 +03:30
157 changed files with 21375 additions and 1188 deletions
+23
View File
@@ -0,0 +1,23 @@
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
# docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید.
# --- رمزهای اصلی (اجباری) ---
# تولید کلید تصادفی: python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=change-me-to-a-long-random-string
POSTGRES_PASSWORD=change-me-db-password
MINIO_ROOT_USER=aramesh
MINIO_ROOT_PASSWORD=change-me-minio-password
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password
# --- اختیاری ---
# پورت ورود Traefik در توسعه (پیش‌فرض 5173)
TRAEFIK_HTTP_PORT=5173
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
SITE_URL=
# آدرس‌های مجاز CORS (لیست JSON) — پیش‌فرض فقط لوکال‌هاست
# CORS_ORIGINS=["http://localhost:5173"]
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
# DOMAIN=aramesh.example
# ACME_EMAIL=admin@aramesh.example
+6
View File
@@ -9,6 +9,9 @@ env/
# Environment
.env
# پشتیبان‌گیری محلی
backups/
# Node / Vite
node_modules/
dist/
@@ -23,3 +26,6 @@ Thumbs.db
# Database
*.sqlite3
*.db
# تصاویر محلی — از پنل/کنسول MinIO مدیریت می‌شوند
frontend/public/images/
+115 -10
View File
@@ -28,26 +28,52 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
│ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است)
├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py)
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
│ ├── tests/ # تست‌های API (pytest)
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
└── docker-compose.yml # اجرای کامل با یک دستور
├── scripts/backup.sh # پشتیبان‌گیری دیتابیس و تصاویر
├── docker-compose.yml # اجرای کامل با یک دستور
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
```
## اجرا با Docker (ساده‌ترین راه)
پیش‌نیاز: Docker Desktop. بیلد فرانت‌اند روی هاست انجام می‌شود (کانتینر فقط فایل‌های
`dist/` را سرو می‌کند)، پس اولین بار باید یک‌بار `npm run build` بزنید:
```bash
docker compose up --build
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمی‌آید
cd frontend && npm install && npm run build && cd ..
docker compose up -d --build
```
- سایت: http://localhost:5173 (از طریق Traefik)
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
- دیتابیس PostgreSQL و آبجکت‌استور MinIO هر دو در کانتینر جدا با volume دائمی
- بک‌اند هنگام شروع، خودش جداول را می‌سازد و **فقط جدول‌های خالی** را seed می‌کند —
ارتقای نسخه داده‌های فعلی را از بین نمی‌برد
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد و خواندن عمومی را فعال می‌کند
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد، خواندن عمومی را
فعال می‌کند و تصاویر داخل `seed-media/` را در باکت می‌ریزد — پس یک کلون تازه هم
همان تصاویر سایت را دارد
> نکته برای تازه‌واردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
> فایل‌های `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
## انتشار واقعی (TLS)
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
```bash
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
- Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرس‌های OG مطلق شوند
## اجرای محلی (توسعه)
@@ -83,15 +109,94 @@ npm run dev
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`)
و سپس CRUD کامل همه محتواها در `/api/admin/...`
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
ذخیره و URL نسبی `/media/...` برمی‌گردد
- همه محتوا در یک درخواست: `GET /api/site`
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند
- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
- همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند)
- **تست‌ها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید.
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
## ویرایش زنده سایت (دوقلوی دیجیتال)
ادمین‌ها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است:
1. به `/admin/login` بروید و وارد شوید
2. نوار شناور پایین صفحه ظاهر می‌شود؛ **حالت ویرایش** را روشن کنید
3. روی هر متن کلیک کنید و ویرایش کنید — **خروج از کادر، بلافاصله ذخیره می‌شود**
(Enter ذخیره، Esc لغو)؛ وضعیت ذخیره در نوار پایین دیده می‌شود
4. آیتم‌های مجموعه‌ها (خدمات، پروژه‌ها، مقالات، نظرات، تیم و...) با هاور، دکمه‌های
ویرایش / حذف / جابه‌جایی ترتیب نشان می‌دهند؛ دکمه «افزودن» هم انتهای هر لیست هست
5. تصویرها از همان کشوها به MinIO آپلود می‌شوند
- در حالت ویرایش، لینک‌ها کلیک نمی‌شوند تا ویرایش متن ممکن باشد
- بازدیدکننده‌های عادی هیچ اثری از این لایه نمی‌بینند (بدون توکن، همه درخواست‌های
مدیریتی `401` برمی‌گردند)
- تمام تغییرها بلافاصله در PostgreSQL ثبت می‌شوند — با Ctrl+F5 هم می‌مانند
## وبلاگ — نوشتن با ویرایشگر WYSIWYG
مقالات با ویرایشگر غنی **TipTap** نوشته می‌شوند (همان تجربه Medium):
- در حالت ویرایش، روی هر مقاله در صفحه وبلاگ دکمه ویرایش را بزنید — کشوی نوشتن باز می‌شود
- تیتر (H2/H3)، بولد/ایتالیک/زیرخط، لیست نقطه‌ای و شماره‌ای، نقل‌قول، لینک و واگرد
- **درج تصویر داخل متن**: دکمه تصویر، یا چسباندن/کشیدن تصویر داخل نوشته — فایل مستقیم
در MinIO ذخیره و در متن قرار می‌گیرد
- راست‌به‌چپ کامل با فونت و تم سایت
- ذخیره خودکار: هر مکث کوتاه در تایپ، تغییرها را ذخیره می‌کند؛ بستن کشو هم ذخیره می‌کند
**پیش‌نویس و انتشار**: مقاله تازه همیشه **پیش‌نویس** شروع می‌شود — در سایت عمومی دیده
نمی‌شود و تا انتشار، تغییرهای خودکار آن چیزی را خراب نمی‌کند. با دکمه «انتشار در سایت»
عمومی می‌شود؛ دوباره می‌توان آن را به پیش‌نویس برگرداند. پیش‌نویس‌ها فقط برای ادمین
(در صفحه وبلاگ، با برچسب «پیش‌نویس») دیده می‌شوند و در `GET /api/site` هرگز نمی‌آیند.
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده
(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف)
در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند.
## سئو
- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug) — کلمه کلیدی هدف در دیتابیس
ذخیره می‌شود (فیلد `seo_keyphrase` مقاله)
- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) —
عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال می‌شود؛ اگر ردیف سئوی صفحه‌ای
هنوز ساخته نشده باشد، دکمه + همان‌جا آن را می‌سازد
- صفحه مقاله و پروژه به‌طور خودکار عنوان و توضیح متا را از محتوای خودشان می‌گیرند
- **تگ‌های OG و توییتر و canonical** برای همه صفحات از `src/seo.ts` ست می‌شود و
مقاله‌ها اسکیمای JSON-LD نوع Article می‌گیرند
- **robots.txt و sitemap.xml** از بک‌اند سرو می‌شوند و مقاله/پروژه‌های منتشرشده را
شامل می‌شوند (پیش‌نویس‌ها نمی‌آیند) — برای آدرس مطلق، `SITE_URL` را در `.env` بگذارید
- **متا برای خزنده‌های بدون جاوااسکریپت** (تلگرام، واتس‌اپ و...): بک‌اند صفحات
`/blog`، `/blog/{slug}`، `/projects` و `/projects/{slug}` را با همان `index.html`
فرانت‌اند سرو می‌کند و پیش از ارسال، title / description / OG / JSON-LD را از
دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA
همیشگی را می‌بینند
## پشتیبان‌گیری
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
```bash
bash scripts/backup.sh # در backups/ — نگهداری پیش‌فرض ۱۴ روز
```
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
- زمان‌بندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
## نکات امنیتی
- فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است)
- رمز عبور ادمین با bcrypt هش می‌شود
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
با سینتکس `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید
- رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
- ورود با محدودیت تلاش محافظت می‌شود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
- توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
- مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند
+30 -4
View File
@@ -86,6 +86,13 @@ Authorization: Bearer <access_token>
> نام کاربری و رمز عبور پیش‌فرض از تنظیمات `.env` خوانده می‌شود — حتماً در محیط تولید تغییرشان دهید.
تغییر رمز (با توکن):
```
POST /api/auth/change-password
{ "current_password": "...", "new_password": "..." }
```
## فهرست انتهاپوینت‌ها
### عمومی (بدون نیاز به توکن)
@@ -97,8 +104,8 @@ Authorization: Bearer <access_token>
| GET | `/api/services` | خدمات |
| GET | `/api/projects` | همه پروژه‌ها |
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
| GET | `/api/articles` | همه مقالات |
| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
| GET | `/api/articles` | مقاله‌های منتشرشده (پیش‌نویس نمی‌آید) |
| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
| GET | `/api/testimonials` | نظرات مشتریان |
| GET | `/api/faqs` | سوالات متداول |
| GET | `/api/team` | اعضای تیم |
@@ -109,7 +116,11 @@ Authorization: Bearer <access_token>
| GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس |
| POST | `/api/newsletter` | عضویت در خبرنامه |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
| POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 |
| GET | `/robots.txt` | قوانین خزنده‌ها |
| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژه‌های منتشرشده |
| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریق‌شده |
| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریق‌شده |
### مدیریت (نیاز به توکن Bearer)
@@ -119,7 +130,9 @@ Authorization: Bearer <access_token>
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف |
| PATCH | `/api/admin/{resource}/{id}` | **ویرایش جزئی** — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند |
| POST | `/api/admin/{resource}/reorder` | ذخیره ترتیب: `{"ids": [3, 1, 2, ...]}` |
| GET | `/api/admin/messages` | پیام‌های تماس (با `?unread_only=true` فقط خوانده‌نشده‌ها) |
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خوانده‌شدن |
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
@@ -127,6 +140,19 @@ Authorization: Bearer <access_token>
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` |
### بدنه مقاله — سند TipTap
`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`.
- فقط ساختار اعتبارسنجی می‌شود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده
رندر استاتیک TipTap در فرانت‌اند است که فقط تگ‌های شناخته‌شده تولید می‌کند
- ردیف‌های قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies`
در startup/seed خودکار به سند تبدیل می‌شوند
- seed هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند
| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact |
منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود.
### تصاویر و MinIO
فایل‌های آپلودشده در باکت `aramesh-media` روی MinIO ذخیره می‌شوند و با مسیر نسبی `/media/<سال>/<ماه>/<فایل>` برگردانده می‌شوند. Traefik مسیر `/media` را به MinIO می‌برد، پس URLها مستقل از بک‌اند ذخیره‌سازی هستند — بعداً برای مهاجرت به R2/S3 فقط تنظیمات را عوض کنید، نه محتوای دیتابیس را.
+121 -30
View File
@@ -4,68 +4,100 @@ import uuid
from datetime import datetime, timezone
import boto3
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import models, schemas
from ..auth import create_access_token, get_current_admin, verify_password
from ..auth import create_access_token, get_current_admin, hash_password, verify_password
from ..config import settings
from ..database import get_db
from ..rate_limit import clear as clear_rate_limit
from ..rate_limit import ensure_allowed, record_failure
from ..schemas import (
ArticleIn,
ArticleOut,
ArticlePatch,
BeforeAfterIn,
BeforeAfterOut,
BeforeAfterPatch,
BrandIn,
BrandOut,
BrandPatch,
ChangePasswordIn,
ContactSectionIn,
ContactSectionOut,
ContactSectionPatch,
ContactMessageOut,
FaqIn,
FaqOut,
FaqPatch,
FooterIn,
FooterOut,
FooterPatch,
GalleryImageIn,
GalleryImageOut,
GalleryImagePatch,
HeroIn,
HeroOut,
HeroPatch,
InvestmentModelIn,
InvestmentModelOut,
InvestmentModelPatch,
LoginIn,
MessageReadIn,
NavItemIn,
NavItemOut,
NavItemPatch,
NewsletterSectionIn,
NewsletterOut,
NewsletterSectionPatch,
OfficeIn,
OfficeOut,
OfficePatch,
PageHeroIn,
PageHeroOut,
PageHeroPatch,
ProcessStepIn,
ProcessStepOut,
ProcessStepPatch,
ProjectIn,
ProjectOut,
ProjectPatch,
ReorderIn,
SeoMetaIn,
SeoMetaOut,
SeoMetaPatch,
SectionHeadingIn,
SectionHeadingOut,
SectionHeadingPatch,
ServiceIn,
ServiceOut,
ServicePatch,
StatIn,
StatOut,
StatPatch,
StatusLabelIn,
StatusLabelOut,
StatusLabelPatch,
StoryIn,
StoryOut,
StoryPatch,
SubscriberOut,
TeamMemberIn,
TeamMemberOut,
TeamMemberPatch,
TestimonialIn,
TestimonialOut,
TestimonialPatch,
TimelineItemIn,
TimelineItemOut,
TimelineItemPatch,
TokenOut,
WhyUsItemIn,
WhyUsItemOut,
WhyUsItemPatch,
)
# روتر عمومی برای ورود — بدون نیاز به توکن
@@ -76,26 +108,52 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
@auth_router.post("/auth/login", response_model=TokenOut)
def login(payload: LoginIn, db: Session = Depends(get_db)):
def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
ensure_allowed(request)
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
if user is None or not verify_password(payload.password, user.password_hash):
record_failure(request)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="نام کاربری یا رمز عبور اشتباه است",
)
clear_rate_limit(request)
return TokenOut(access_token=create_access_token(user.username))
def register_crud(path: str, model, out_schema, create_schema) -> None:
"""ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
@auth_router.post("/auth/change-password")
def change_password(
payload: ChangePasswordIn,
admin: models.AdminUser = Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
if not verify_password(payload.current_password, admin.password_hash):
raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
admin.password_hash = hash_password(payload.new_password)
db.commit()
return {"ok": True}
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت."""
@router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db)):
return db.query(model).order_by(model.id).all()
query = db.query(model)
if hasattr(model, "position"):
query = query.order_by(model.position, model.id)
else:
query = query.order_by(model.id)
return query.all()
@router.post(f"/{path}", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)):
item = model(**payload.model_dump())
if hasattr(model, "position"):
# آیتم جدید در انتهای لیست قرار می‌گیرد
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
db.add(item)
try:
db.commit()
@@ -127,6 +185,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
db.refresh(item)
return item
if patch_schema is not None:
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
"""ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده)."""
item = db.get(model, item_id)
if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(item, key, value)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
db.refresh(item)
return item
@router.post(f"/{path}/reorder", response_model=list[out_schema])
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
"""ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی."""
if not hasattr(model, "position"):
raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند")
items = db.query(model).filter(model.id.in_(payload.ids)).all()
if len(items) != len(payload.ids):
raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
by_id = {item.id: item for item in items}
for pos, item_id in enumerate(payload.ids):
by_id[item_id].position = pos
db.commit()
return db.query(model).order_by(model.position, model.id).all()
@router.delete(f"/{path}/{{item_id}}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)):
item = db.get(model, item_id)
@@ -137,30 +227,31 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
# CRUD محتوا
register_crud("brand", models.Brand, BrandOut, BrandIn)
register_crud("nav", models.NavItem, NavItemOut, NavItemIn)
register_crud("hero", models.Hero, HeroOut, HeroIn)
register_crud("story", models.StorySection, StoryOut, StoryIn)
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn)
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn)
register_crud("services", models.Service, ServiceOut, ServiceIn)
register_crud("projects", models.Project, ProjectOut, ProjectIn)
register_crud("articles", models.Article, ArticleOut, ArticleIn)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
register_crud("faqs", models.Faq, FaqOut, FaqIn)
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
register_crud("stats", models.Stat, StatOut, StatIn)
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn)
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn)
register_crud("offices", models.Office, OfficeOut, OfficeIn)
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn)
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn)
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn)
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn)
register_crud("footer", models.FooterContent, FooterOut, FooterIn)
register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد
+38 -24
View File
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
@router.get("/services", response_model=list[ServiceOut])
def get_services(db: Session = Depends(get_db)):
return db.query(models.Service).all()
return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
@router.get("/projects", response_model=list[ProjectOut])
def get_projects(db: Session = Depends(get_db)):
return db.query(models.Project).order_by(models.Project.id).all()
return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
@router.get("/projects/{slug}", response_model=ProjectOut)
@@ -55,12 +55,21 @@ def get_project(slug: str, db: Session = Depends(get_db)):
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)):
return db.query(models.Article).order_by(models.Article.id).all()
return (
db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all()
)
@router.get("/articles/{slug}", response_model=ArticleOut)
def get_article(slug: str, db: Session = Depends(get_db)):
article = db.query(models.Article).filter(models.Article.slug == slug).first()
article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return article
@@ -68,32 +77,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
@router.get("/testimonials", response_model=list[TestimonialOut])
def get_testimonials(db: Session = Depends(get_db)):
return db.query(models.Testimonial).all()
return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
@router.get("/faqs", response_model=list[FaqOut])
def get_faqs(db: Session = Depends(get_db)):
return db.query(models.Faq).all()
return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
@router.get("/team", response_model=list[TeamMemberOut])
def get_team(db: Session = Depends(get_db)):
return db.query(models.TeamMember).all()
return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
@router.get("/timeline", response_model=list[TimelineItemOut])
def get_timeline(db: Session = Depends(get_db)):
return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
@router.get("/stats", response_model=list[StatOut])
def get_stats(db: Session = Depends(get_db)):
return db.query(models.Stat).all()
return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
@router.get("/why-us", response_model=list[WhyUsItemOut])
def get_why_us(db: Session = Depends(get_db)):
return db.query(models.WhyUsItem).all()
return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
@router.get("/before-after", response_model=BeforeAfterOut)
@@ -109,27 +118,32 @@ def get_site(db: Session = Depends(get_db)):
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانت‌اند."""
return {
"brand": db.query(models.Brand).first(),
"nav": db.query(models.NavItem).order_by(models.NavItem.id).all(),
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
"hero": db.query(models.Hero).first(),
"story": db.query(models.StorySection).first(),
"section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
"page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
"services": db.query(models.Service).all(),
"projects": db.query(models.Project).order_by(models.Project.id).all(),
"articles": db.query(models.Article).order_by(models.Article.id).all(),
"testimonials": db.query(models.Testimonial).all(),
"faqs": db.query(models.Faq).all(),
"team": db.query(models.TeamMember).all(),
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
"stats": db.query(models.Stat).all(),
"why_us": db.query(models.WhyUsItem).all(),
"services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
"projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
# فقط مقاله‌های منتشرشده — پیش‌نویس‌ها از انتهای مدیریتی خوانده می‌شوند
"articles": db.query(models.Article)
.filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id)
.all(),
"testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
"faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
"team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
"timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
"stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
"why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
"before_after": db.query(models.BeforeAfter).first(),
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.id).all(),
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.id).all(),
"offices": db.query(models.Office).order_by(models.Office.id).all(),
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.id).all(),
"investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
"process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
"offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
"gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
"status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
"contact_section": db.query(models.ContactSectionContent).first(),
"newsletter": db.query(models.NewsletterSectionContent).first(),
"footer": db.query(models.FooterContent).first(),
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
}
+6 -1
View File
@@ -23,9 +23,14 @@ class Settings(BaseSettings):
s3_access_key: str = "aramesh"
s3_secret_key: str = "aramesh-minio-secret"
s3_bucket: str = "aramesh-media"
# پیشوند عمومی مسیر تصاویر — nginx آن را به باکت پروکسی می‌کند
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند
media_public_base: str = "/media"
media_max_upload_mb: int = 10
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
site_url: str = ""
# مسیر بیلد فرانت‌اند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
frontend_dist: str = "../frontend/dist"
settings = Settings()
+5
View File
@@ -5,8 +5,11 @@ from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت
from .api import admin, forms, public
from .config import settings
from .database import Base, engine
from .migrations import run_all as run_migrations
from .seo_pages import router as seo_router
Base.metadata.create_all(bind=engine)
run_migrations()
app = FastAPI(
title="Aramesh Building Group API",
@@ -31,6 +34,8 @@ app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"])
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
app.include_router(seo_router, tags=["سئو"])
@app.get("/")
+308
View File
@@ -0,0 +1,308 @@
"""مهاجرت‌های سبک دیتابیس — DDL و تبدیل داده‌های قدیمی؛ همه idempotent.
هم main.py (startup) و هم seed.py این‌ها را صدا می‌زنند تا هر مسیر اجرایی
دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد.
"""
from sqlalchemy import text
from .database import SessionLocal, engine
# جدول‌های مجموعه‌ای که ستون position دارند
POSITION_TABLES = [
"services", "projects", "articles", "testimonials", "faqs", "team",
"timeline", "stats", "why_us", "investment_models", "process_steps",
"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."""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
for table in POSITION_TABLES:
conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
)
# مقالات موجود منتشر شده‌اند؛ فقط مقاله‌های تازه می‌توانند پیش‌نویس باشند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'")
)
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:
"""تبدیل لیست پاراگراف‌های متنی (فرمت قدیمی بدنه مقاله) به سند TipTap."""
content = []
for para in paragraphs:
if isinstance(para, str) and para.strip():
content.append({
"type": "paragraph",
"content": [{"type": "text", "text": para}],
})
if not content:
content = [{"type": "paragraph"}]
return {"type": "doc", "content": content}
def migrate_article_bodies() -> None:
"""تبدیل بدنه مقالات از لیست پاراگراف به سند JSON — idempotent."""
from . import models # lazy — جلوگیری از import حلقه‌ای
db = SessionLocal()
try:
changed = 0
for article in db.query(models.Article).all():
if isinstance(article.body, list):
article.body = paragraphs_to_doc(article.body)
changed += 1
db.commit()
if changed:
print(f"مقالات تبدیل‌شده به فرمت سند: {changed}")
finally:
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()
+156 -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,101 +35,156 @@ 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):
__tablename__ = "services"
position: Mapped[int] = mapped_column(Integer, default=0)
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")
class Project(Base):
__tablename__ = "projects"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "articles"
position: Mapped[int] = mapped_column(Integer, default=0)
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")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
class Testimonial(Base):
__tablename__ = "testimonials"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "faqs"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "team"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "timeline"
position: Mapped[int] = mapped_column(Integer, default=0)
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="")
class Stat(Base):
__tablename__ = "stats"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "why_us"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
@@ -132,18 +192,25 @@ 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):
__tablename__ = "nav_items"
position: Mapped[int] = mapped_column(Integer, default=0)
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))
@@ -154,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):
@@ -173,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):
@@ -193,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="")
@@ -208,43 +298,63 @@ 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="")
class InvestmentModel(Base):
__tablename__ = "investment_models"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "process_steps"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "offices"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
__tablename__ = "gallery_images"
position: Mapped[int] = mapped_column(Integer, default=0)
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):
@@ -255,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):
@@ -264,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):
@@ -290,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):
@@ -304,12 +439,32 @@ 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):
"""عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ..."""
__tablename__ = "seo_meta"
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):
+49
View File
@@ -0,0 +1,49 @@
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
پیش‌فرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک می‌کند.
شمارنده در حافظه همین پروسه است — با ری‌استارت بک‌اند پاک می‌شود و همین کافی است.
"""
import time
from fastapi import HTTPException, Request, status
MAX_FAILED_ATTEMPTS = 5
WINDOW_SECONDS = 15 * 60
_failed: dict[str, list[float]] = {}
def client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _prune(key: str, now: float) -> list[float]:
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
_failed[key] = recent
return recent
def ensure_allowed(request: Request) -> None:
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد می‌شود."""
key = client_ip(request)
recent = _prune(key, time.monotonic())
if len(recent) >= MAX_FAILED_ATTEMPTS:
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید",
headers={"Retry-After": str(retry_after)},
)
def record_failure(request: Request) -> None:
_failed.setdefault(client_ip(request), []).append(time.monotonic())
def clear(request: Request) -> None:
"""با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود."""
_failed.pop(client_ip(request), None)
+326 -4
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Literal
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
# ---------- پایه ----------
@@ -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,56 +43,84 @@ 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
body: list[str]
excerpt_en: str = ""
body: dict
body_en: Optional[dict] = None
status: str
seo_keyphrase: str
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
@@ -93,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
@@ -163,84 +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):
@@ -285,8 +405,14 @@ class TokenOut(BaseModel):
token_type: str = "bearer"
class ChangePasswordIn(BaseModel):
current_password: str
new_password: str = Field(min_length=8, max_length=128)
class BrandIn(BaseModel):
name: str = ""
name_en: str = ""
logo: str = ""
hero: str = ""
contact_bg: str = ""
@@ -294,202 +420,398 @@ 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"}]}
def validate_doc_body(value: object) -> dict:
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
if not value:
return dict(EMPTY_DOC)
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
return value
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 = ""
body: list[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):
is_read: bool = True
# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ----------
def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
"""از اسکیمای ورودی، اسکیمای PATCH می‌سازد؛ فیلدهای ارسال‌نشده دست‌نخورده می‌مانند."""
fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()}
return create_model(name, **fields)
BrandPatch = _make_patch("BrandPatch", BrandIn)
ServicePatch = _make_patch("ServicePatch", ServiceIn)
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)
TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn)
StatPatch = _make_patch("StatPatch", StatIn)
WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn)
BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn)
NavItemPatch = _make_patch("NavItemPatch", NavItemIn)
HeroPatch = _make_patch("HeroPatch", HeroIn)
StoryPatch = _make_patch("StoryPatch", StoryIn)
SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn)
PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn)
InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn)
ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn)
OfficePatch = _make_patch("OfficePatch", OfficeIn)
GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn)
StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn)
ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn)
NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn)
FooterPatch = _make_patch("FooterPatch", FooterIn)
class ReorderIn(BaseModel):
"""ترتیب جدید آیتم‌های یک مجموعه بر اساس id."""
ids: list[int]
+45 -4
View File
@@ -11,10 +11,11 @@ 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
from .migrations import paragraphs_to_doc, run_all as run_migrations
# (مدل، داده‌های اولیه، برچسب گزارش)
CONTENT: list[tuple[Any, list[dict], str]] = [
@@ -42,6 +43,36 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
(models.TimelineItem, seed_data.TIMELINE, "تایم‌لاین"),
(models.WhyUsItem, seed_data.WHY, "چرا آرامش"),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"),
(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,
]
@@ -53,12 +84,22 @@ def seed(force: bool = False) -> None:
print("جداول قبلی حذف شدند.")
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 row in rows:
db.add(model(**row))
for idx, row in enumerate(rows):
# فایل فارسی مرجع است؛ فقط فیلدهای *_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:
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:
db.add(
+5
View File
@@ -451,3 +451,8 @@ FOOTER = {
{"label": "تلگرام", "href": "#", "icon": "telegram"},
],
}
SEO_META = [
{"page_key": key, "title": "", "description": ""}
for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
]
+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."},
]
+436
View File
@@ -0,0 +1,436 @@
"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریق‌شده.
فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند.
این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند —
بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند.
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو می‌شود؛
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی می‌شوند.
"""
import html
import json
import re
from pathlib import Path
from fastapi import APIRouter, Depends, Response
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from . import models
from .config import settings
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:
if not path:
return ""
if path.startswith(("http://", "https://")):
return path
site = settings.site_url.rstrip("/")
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")
except OSError:
return None
def _doc_text(doc: object, limit: int = 300) -> str:
"""متن ساده سند TipTap — برای noscript و پیش‌نمایش."""
words: list[str] = []
def walk(node: object) -> None:
if not isinstance(node, dict):
return
if node.get("type") == "text" and isinstance(node.get("text"), str):
words.append(node["text"])
for child in node.get("content") or []:
walk(child)
if isinstance(doc, dict):
for block in doc.get("content") or []:
walk(block)
text = " ".join(words).strip()
return text[:limit] + ("…" if len(text) > limit else "")
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 (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 = [
f'<meta name="description" content="{esc(description)}" />',
f'<meta property="og:title" content="{esc(title)}" />',
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:
tags.append(f'<meta property="og:image" content="{esc(image)}" />')
if jsonld:
payload = json.dumps(jsonld, ensure_ascii=False).replace("</", "<\\/")
tags.append(f'<script type="application/ld+json">{payload}</script>')
block = "\n ".join(tags)
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_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" {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, lang: str = "fa") -> str:
brand = db.query(models.Brand).first()
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 ----------
@router.get("/robots.txt", response_class=Response)
def robots(db: Session = Depends(get_db)):
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
if settings.site_url:
lines.append(f"Sitemap: {_absolute('/sitemap.xml')}")
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)):
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()
]
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(_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" '
'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")
# ---------- صفحات وبلاگ ----------
@router.get("/blog", response_class=HTMLResponse)
def blog_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "blog")
return _shell(
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
description=meta.description if meta else "",
path="/blog",
)
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 = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
# مقاله ناموجود یا پیش‌نویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان می‌دهد
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": 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(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{title} | {brand}",
description=excerpt,
path=f"/blog/{article.slug}",
og_image=article.image,
og_type="article",
jsonld=jsonld,
noscript=noscript,
)
# ---------- صفحات پروژه ----------
@router.get("/projects", response_class=HTMLResponse)
def projects_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "projects")
return _shell(
title=meta.title if meta and meta.title else "پروژه‌ها | " + _brand_name(db),
description=meta.description if meta else "",
path="/projects",
)
@router.get("/projects/{slug}", response_class=HTMLResponse)
def 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), 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=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")
+4
View File
@@ -0,0 +1,4 @@
# وابستگی‌های توسعه و تست — pip install -r requirements-dev.txt
-r requirements.txt
pytest==8.3.4
httpx==0.27.2
+13 -11
View File
@@ -1,11 +1,13 @@
fastapi>=0.115.8
uvicorn[standard]>=0.34.0
sqlalchemy>=2.0.36
psycopg[binary]>=3.2.3
pydantic>=2.10.4
pydantic-settings>=2.6.1
PyJWT>=2.10.1
bcrypt>=4.2.1
email-validator>=2.2.0
boto3>=1.35.0
python-multipart>=0.0.18
# نسخه‌ها دقیق پین شده‌اند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
# ارتقا: نسخه را دستی بالا ببرید و روی محیط آماده‌سازی تست کنید.
fastapi==0.115.8
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
psycopg[binary]==3.2.3
pydantic==2.10.4
pydantic-settings==2.6.1
PyJWT==2.10.1
bcrypt==4.2.1
email-validator==2.2.0
boto3==1.35.0
python-multipart==0.0.18
+93
View File
@@ -0,0 +1,93 @@
"""پیکربندی تست‌ها — دیتابیس SQLite فایل‌محور و داده‌های حداقلی.
متغیرهای محیطی پیش از ایمپورت app تنظیم می‌شوند تا settings همان‌ها را بخواند.
"""
import os
import pathlib
os.environ["DATABASE_URL"] = "sqlite:///./test.db?check_same_thread=false"
os.environ["SECRET_KEY"] = "test-secret-key"
os.environ["ADMIN_USERNAME"] = "admin"
os.environ["ADMIN_PASSWORD"] = "test-admin-pass-1"
os.environ["SITE_URL"] = "https://test.example"
import pytest
from fastapi.testclient import TestClient
from app import models
from app.auth import hash_password
from app.config import settings
from app.database import Base, SessionLocal, engine
from app.main import app
TEST_DB = pathlib.Path("test.db")
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
@pytest.fixture(scope="session", autouse=True)
def seed_db(tmp_path_factory):
# اتصالات ساخت جدول در ایمپورت ماژول باید بسته شوند تا فایل قدیمی حذف شود
engine.dispose()
if TEST_DB.exists():
TEST_DB.unlink()
Base.metadata.create_all(bind=engine)
# dist ساختگی فرانت‌اند برای تست تزریق متا در صفحات جزئیات
dist = tmp_path_factory.mktemp("dist")
(dist / "index.html").write_text(
"<!doctype html><html><head><title>Shell</title></head>"
"<body><div id=\"root\"></div></body></html>",
encoding="utf-8",
)
settings.frontend_dist = str(dist)
settings.site_url = "https://test.example"
db = SessionLocal()
try:
db.add(models.AdminUser(username="admin", password_hash=hash_password("test-admin-pass-1")))
db.add(models.Brand(name="گروه ساختمانی آرامش"))
db.add(
models.Article(
slug="published-post", title="مقاله منتشرشده", status="published",
excerpt="چکیده مقاله", body=DOC, position=1,
)
)
db.add(
models.Article(
slug="draft-post", title="پیش‌نویس", status="draft",
excerpt="چکیده پیش‌نویس", body=DOC, position=2,
)
)
db.commit()
finally:
db.close()
yield
db.close()
engine.dispose()
if TEST_DB.exists():
TEST_DB.unlink()
@pytest.fixture(autouse=True)
def reset_rate_limiter():
from app import rate_limit
rate_limit._failed.clear()
yield
rate_limit._failed.clear()
@pytest.fixture()
def client():
return TestClient(app)
@pytest.fixture()
def auth_client(client):
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200, res.text
token = res.json()["access_token"]
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
client.headers.pop("Authorization", None)
+270
View File
@@ -0,0 +1,270 @@
"""تست‌های API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیش‌نویس مقاله و سئو."""
import re
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
# ---------- عمومی ----------
def test_health(client):
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["status"] == "ok"
def test_site_hides_draft_articles(client):
res = client.get("/api/site")
assert res.status_code == 200
slugs = [a["slug"] for a in res.json()["articles"]]
assert "published-post" in slugs
assert "draft-post" not in slugs
def test_public_articles_hide_drafts(client):
res = client.get("/api/articles")
assert all(a["status"] == "published" for a in res.json())
assert client.get("/api/articles/draft-post").status_code == 404
def test_admin_endpoints_reject_anonymous(client):
assert client.get("/api/admin/articles").status_code == 401
# ---------- احراز هویت ----------
def test_login_success_and_failure(client):
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}).status_code
== 401
)
res = client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"})
assert res.status_code == 200
assert res.json()["access_token"]
def test_change_password(auth_client, client):
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "wrong", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 400
res = auth_client.post(
"/api/auth/change-password",
json={"current_password": "test-admin-pass-1", "new_password": "brand-new-pass-9"},
)
assert res.status_code == 200
# رمز قدیمی رد و رمز جدید قبول می‌شود
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "test-admin-pass-1"}).status_code
== 401
)
assert (
client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"}).status_code
== 200
)
# بازگرداندن رمز برای تست‌های بعدی
res = client.post("/api/auth/login", json={"username": "admin", "password": "brand-new-pass-9"})
auth = {"Authorization": f"Bearer {res.json()['access_token']}"}
res = client.post(
"/api/auth/change-password",
json={"current_password": "brand-new-pass-9", "new_password": "test-admin-pass-1"},
headers=auth,
)
assert res.status_code == 200
def test_login_rate_limited(client):
for _ in range(5):
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
res = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 429
assert "Retry-After" in res.headers
# ---------- CRUD مدیریت ----------
def test_article_crud_and_draft_flow(auth_client, client):
created = auth_client.post(
"/api/admin/articles",
json={"slug": "flow-post", "title": "مقاله جریان", "body": DOC},
)
assert created.status_code == 201, created.text
article = created.json()
# مقاله تازه پیش‌نویس است و در سایت عمومی دیده نمی‌شود
assert article["status"] == "draft"
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"seo_keyphrase": "ساختمان"})
assert res.json()["seo_keyphrase"] == "ساختمان"
res = auth_client.patch(f"/api/admin/articles/{article['id']}", json={"status": "published"})
assert res.status_code == 200
assert "flow-post" in [a["slug"] for a in client.get("/api/articles").json()]
assert auth_client.delete(f"/api/admin/articles/{article['id']}").status_code == 204
assert "flow-post" not in [a["slug"] for a in client.get("/api/articles").json()]
def test_service_roundtrip_and_reorder(auth_client):
ids = []
for name in ("خدمت الف", "خدمت ب"):
res = auth_client.post("/api/admin/services", json={"title": name})
assert res.status_code == 201
ids.append(res.json()["id"])
res = auth_client.post("/api/admin/services/reorder", json={"ids": ids[::-1]})
assert res.status_code == 200
ordered = [s["id"] for s in res.json() if s["id"] in ids]
assert ordered == ids[::-1]
res = auth_client.patch(f"/api/admin/services/{ids[0]}", json={"desc": "توضیح تازه"})
assert res.json()["desc"] == "توضیح تازه"
for i in ids:
assert auth_client.delete(f"/api/admin/services/{i}").status_code == 204
def test_seo_meta_create_and_patch(auth_client):
res = auth_client.post(
"/api/admin/seo-meta", json={"page_key": "test-page", "title": "عنوان", "description": "توضیح"}
)
assert res.status_code == 201
meta_id = res.json()["id"]
res = auth_client.patch(f"/api/admin/seo-meta/{meta_id}", json={"title": "عنوان تازه"})
assert res.json()["title"] == "عنوان تازه"
assert auth_client.delete(f"/api/admin/seo-meta/{meta_id}").status_code == 204
# ---------- سئو ----------
def test_robots_txt(client):
res = client.get("/robots.txt")
assert res.status_code == 200
assert "Disallow: /admin" in res.text
assert "Sitemap: https://test.example/sitemap.xml" in res.text
def test_sitemap_includes_published_only(client):
res = client.get("/sitemap.xml")
assert res.status_code == 200
assert "/blog/published-post" in res.text
assert "/blog/draft-post" not in res.text
assert res.text.startswith("<?xml")
def test_head_injection_for_article(client):
res = client.get("/blog/published-post")
assert res.status_code == 200
assert "مقاله منتشرشده" in res.text
assert 'property="og:title"' in res.text
assert "application/ld+json" in res.text
assert "https://test.example/blog/published-post" in res.text
def test_head_not_injected_for_draft(client):
res = client.get("/blog/draft-post")
assert res.status_code == 200
assert "پیش‌نویس" not in res.text # عنوان پیش‌نویس افشا نمی‌شود
assert "application/ld+json" not in res.text
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"
+57
View File
@@ -0,0 +1,57 @@
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۴۳.
# پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور.
#
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند.
services:
traefik:
command: !override
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL را در .env تنظیم کنید}
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
ports: !override
- "80:80"
- "443:443"
volumes: !override
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt
backend:
environment:
# سایت هم‌مبدأ است — CORS لازم نیست
CORS_ORIGINS: '[]'
SITE_URL: https://${DOMAIN:?DOMAIN را در .env تنظیم کنید}
labels:
- traefik.enable=true
- 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`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le
minio:
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=websecure
- traefik.http.routers.media.tls.certresolver=le
frontend:
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=le
volumes:
letsencrypt_data:
+66 -21
View File
@@ -1,3 +1,7 @@
# مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید:
# cp .env.example .env
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
services:
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO)
traefik:
@@ -6,9 +10,11 @@ services:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
# فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80
ports:
- "5173:80"
- "${TRAEFIK_HTTP_PORT:-5173}:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
@@ -17,7 +23,7 @@ services:
restart: unless-stopped
environment:
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_DB: arameshplaza
volumes:
- db_data:/var/lib/postgresql/data
@@ -32,8 +38,8 @@ services:
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: aramesh
MINIO_ROOT_PASSWORD: aramesh-minio-secret
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes:
- minio_data:/data
healthcheck:
@@ -42,7 +48,8 @@ services:
timeout: 5s
retries: 12
ports:
- "9001:9001" # کنسول MinIO
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو می‌شوند)
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=PathPrefix(`/media`)
@@ -53,61 +60,88 @@ services:
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
- traefik.http.services.media.loadbalancer.server.port=9000
# یک‌بار اجرا می‌شود: ساخت باکت و بازگذاشتن خواندن عمومی برای سرو تصاویر
# یک‌بار اجرا می‌شود: ساخت باکت، بازگذاشتن خواندن عمومی و پیش‌بارگذاری تصویرهای seed.
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبت‌اند) روی یک حجم تازه
# وارد باکت می‌شوند تا هر نسخه تازه‌کلون/بیلد، همان عکس‌ها را داشته باشد؛
# روی حجم موجود، mc cp فایل‌های تکراری را رد می‌کند و داده‌تازه را نمی‌خورد.
minio-setup:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: aramesh
MINIO_ROOT_PASSWORD: aramesh-minio-secret
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
MINIO_BUCKET: aramesh-media
volumes:
- ./seed-media:/seed-media:ro
restart: "no"
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/$$MINIO_BUCKET &&
mc anonymous set download local/$$MINIO_BUCKET &&
echo 'minio-setup: bucket ready'
echo 'minio-setup: bucket ready' &&
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
echo 'minio-setup: seed media mirrored'
"
backend:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+psycopg://aramesh:aramesh@db:5432/arameshplaza
SECRET_KEY: change-me-to-a-long-random-string
CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]'
ADMIN_USERNAME: admin
ADMIN_PASSWORD: aramesh123
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:5173","http://127.0.0.1:5173"]}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD را در .env تنظیم کنید}
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: aramesh
S3_SECRET_KEY: aramesh-minio-secret
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-aramesh}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
S3_BUCKET: aramesh-media
MEDIA_PUBLIC_BASE: /media
# نشانی عمومی سایت — برای sitemap و آدرس مطلق OG (مثل https://aramesh.example)
SITE_URL: ${SITE_URL:-}
# بیلد فرانت‌اند برای تزریق متای سئو در صفحات /blog و /projects
FRONTEND_DIST: /srv/frontend
volumes:
- ./frontend/dist:/srv/frontend:ro
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=web
- traefik.http.routers.api.priority=100
- traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
# مسیرهای /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
# فقط سرو فایل‌های استاتیک SPA — همه مسیریابی‌ها روی Traefik است
# فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ""
build: ./frontend
restart: unless-stopped
depends_on:
- backend
- minio
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 15s
timeout: 5s
retries: 5
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`)
@@ -115,6 +149,17 @@ services:
- traefik.http.routers.web.priority=1
- traefik.http.services.web.loadbalancer.server.port=80
# ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند)
mc:
image: minio/mc:latest
profiles: ["tools"]
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes:
db_data:
minio_data:
File diff suppressed because it is too large Load Diff
+4 -18
View File
@@ -1,24 +1,10 @@
# ---------- مرحله ساخت ----------
FROM node:22-alpine AS build
# ---------- فرانت‌اند فقط سرو می‌کند ----------
# بیلد روی هاست انجام می‌شود (npm install && npm run build) و خروجی dist/
# داخل ایمیج کپی می‌شود — شبکه کانتینر بیلد برای npm قابل‌اعتماد نیست.
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# آدرس API در زمان بیلد؛ پیش‌فرض خالی یعنی درخواست‌های نسبی (/api) که Traefik مسیریابی می‌کند
ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# ---------- مرحله سرو ----------
# Caddy فقط فایل‌های استاتیک SPA را سرو می‌کند؛ مسیریابی /api و /media روی Traefik است
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
COPY dist /srv
EXPOSE 80
+73 -5
View File
@@ -1,8 +1,76 @@
# Magic Patterns - Vite Template
This code was generated by [Magic Patterns](https://magicpatterns.com) for this design: [Source Design](https://www.magicpatterns.com/c/ka4i8ikygtakmdobhws6nu)
# Aramesh Construction Group — Frontend
## Getting Started
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 & Smart Bidi Text
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
## Recent UI/UX Improvements
* **Projects Map Restoration**: Successfully re-integrated and mounted the interactive project overview map directly at the top of the Projects page to display all project markers.
* **Project Details Update**: Revamped and updated the presentation of project details for better clarity and structure.
* **Stats Section Relocation**: Moved the statistics (metrics) section higher up in the page layout for better visibility and impact.
* **FAQ Section Redesign**: Refactored the FAQ layout into a centered vertical stack with full RTL/LTR responsiveness, featuring an integrated centered header block (title, description, and accent line) and direction-aware (`text-start`) accordion content.
* **Newsletter Section Polish**: Updated the newsletter container to a clean solid `bg-copper` background while introducing high-contrast button styling (`bg-ink` default transitioning to white on hover) preserving site-wide kinetic animation logic.
* **Article Cards Spacing**: Refined vertical layout spacing and responsive gaps between title, excerpt, and footer blocks without impacting typography scales.
* **Tiptap Editor Fixes**:
* Resolved the initialization bug where the editor body remained empty during edit mode.
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
* **Dynamic TOC & Layout**:
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
## CMS, SEO & Editor Features
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
* **Advanced SEO & Featured Media:** Dynamic `meta_description` fields (150-160 characters). The Featured Image component features a compact layout with smart data-binding: the `Alt` text auto-fills based on the title (e.g., `[Title] - تصویر شاخص`) as a smart fallback to ensure SEO compliance, while still allowing **full manual overrides** for precise custom keyword targeting.
* **Precise Category Management:** A flawlessly integrated, single-select category dropdown using `react-select` (Creatable) and `react-hook-form`. It allows creating new categories on the fly while retaining them in the global list, seamlessly matching the strict single-category backend schema.
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
* **Custom Tiptap WYSIWYG Editor:**
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
* **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
* Frontend and text displays rely on native `Intl.DateTimeFormat` to render dates and numerals perfectly in Persian.
## Development Setup
```bash
npm install
npm run dev
```
In development mode, Vite proxies `/api` and `/media` requests to Traefik (port 5173). Therefore, the Docker stack must be running.
The backend API URL is read from the `VITE_API_URL` environment variable in `src/config.ts`; an empty default implies same-origin requests.
## Build & Production
The build process runs on the host machine. The Docker container only serves the generated static files in the `dist/` directory using Caddy:
```bash
npm run build
```
> **Note:** The backend reads the `dist/` directory (mounted at `/srv/frontend` in Docker Compose) to inject SEO metadata into the `/blog` and `/projects` pages. After making any frontend changes, running `npm run build` and restarting the backend is sufficient.
## Key Modules & Architecture
* `src/api.tsx` — Handles site data loading and draft article merging for the admin view.
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
* `src/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"
1. Run `npm install`
2. Run `npm run dev`
+16
View File
@@ -5,9 +5,25 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>گروه ساختمانی آرامش</title>
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژه‌های ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایه‌گذاری ساخت‌وساز." />
<meta property="og:site_name" content="گروه ساختمانی آرامش" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Organization","name":"گروه ساختمانی آرامش","inLanguage":"fa"}
</script>
</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>
+1077 -4
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -6,16 +6,30 @@
"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"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/static-renderer": "^3.31.3",
"framer-motion": "^11.5.4",
"lucide-react": "0.522.0",
"maplibre-gl": "^6.10.0",
"pmtiles": "^4.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.88.0",
"react-icons": "^5.7.0",
"react-multi-date-picker": "^4.5.2",
"react-router-dom": "^6.26.2",
"react-select": "^5.10.2",
"slugify": "^1.6.9",
"sweetalert2": "^11.26.25"
},
"devDependencies": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

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
})
+86 -32
View File
@@ -15,7 +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();
@@ -35,71 +63,97 @@ function AnimatedRoutes() {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}>
<Routes location={location}>
<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 />} />
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
{FA_ROUTES}
<Route path="*" element={<Home />} />
{EN_ROUTES}
</Routes>
</motion.main>
</AnimatePresence>);
}
function AppShell() {
const { data, error, reload } = useSiteData();
function AppChrome() {
const { pathname } = useLocation();
const { isAuthenticated } = useAdmin();
const data = useSite();
const { lang } = useLang();
const isAdminPage = pathname.startsWith('/admin');
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند
useEffect(() => {
if (data?.brand.name) document.title = data.brand.name;
}, [data?.brand.name]);
if (isAdminPage) return;
const key = seoKeyForPath(pathname);
if (!key) return;
const meta = data.seo_meta?.find((s) => s.page_key === key);
const brand = data.brand.name;
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
applySeo({
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '',
path: pathname,
lang,
});
}, [pathname, data, isAdminPage, lang]);
return (
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
<>
{!isAdminPage && <Header />}
<AnimatedRoutes />
{!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />}
{isAuthenticated && !isAdminPage && <AdminBar />}
</>
);
}
function AppShell() {
const { data, error, reload } = useSiteData();
const { t, dir, lang } = useLang();
// overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky
// (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود.
return (
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-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 />
<Header />
<AnimatedRoutes />
<Footer />
<FloatingContact />
</BrowserRouter>
<AppChrome />
</>
) : null}
</div>
);
}
export function App() {
useEffect(() => {
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'fa');
}, []);
return (
<SiteProvider>
<AppShell />
<AdminProvider>
<BrowserRouter>
<LangProvider>
<AppShell />
</LangProvider>
</BrowserRouter>
</AdminProvider>
</SiteProvider>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react'
import Swal from 'sweetalert2'
import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
const STATUS_TEXT: Record<string, string> = {
idle: 'همگام‌سازی شد',
saving: 'در حال ذخیره…',
error: 'خطا در ذخیره',
}
const STATUS_COLOR: Record<string, string> = {
idle: 'bg-emerald-400',
saving: 'bg-copper animate-pulse',
error: 'bg-red-400',
}
const SWAL_BASE = {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
}
function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
if (next.length < 8) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'رمز جدید کوتاه است', text: 'رمز جدید باید دست‌کم ۸ نویسه باشد.', confirmButtonText: 'باشه' })
return
}
if (next !== confirm) {
void Swal.fire({ ...SWAL_BASE, icon: 'warning', title: 'تکرار رمز مطابق نیست', text: 'رمز جدید و تکرار آن یکسان نیستند.', confirmButtonText: 'باشه' })
return
}
setBusy(true)
try {
await adminApi.changePassword(current, next)
void Swal.fire({ ...SWAL_BASE, icon: 'success', title: 'رمز عبور عوض شد', text: 'از این به بعد با رمز جدید وارد شوید.', iconColor: '#34d399', confirmButtonText: 'باشه' })
onClose()
} catch (error) {
void Swal.fire({
...SWAL_BASE,
icon: 'error',
title: 'تغییر رمز ناموفق بود',
text: error instanceof Error ? error.message : 'دوباره تلاش کنید',
confirmButtonText: 'باشه',
})
} finally {
setBusy(false)
}
}
const field =
'w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none'
return (
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="تغییر رمز عبور">
<div dir="rtl" className="w-full max-w-sm border border-ivory/15 bg-ink p-8 shadow-2xl">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
<KeyRoundIcon className="h-4 w-4" />
تغییر رمز عبور
</h2>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="text-ivory/50 transition-colors hover:text-ivory"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-6 grid gap-4">
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder="رمز عبور فعلی"
aria-label="رمز عبور فعلی"
autoComplete="current-password"
className={field}
/>
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder="رمز جدید (دست‌کم ۸ نویسه)"
aria-label="رمز جدید"
autoComplete="new-password"
className={field}
/>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="تکرار رمز جدید"
aria-label="تکرار رمز جدید"
autoComplete="new-password"
className={field}
/>
</div>
<button
type="button"
onClick={() => void submit()}
disabled={busy || !current || !next}
className="mt-6 w-full bg-copper py-2.5 text-sm font-bold text-char transition-opacity disabled:opacity-50"
>
{busy ? 'در حال ذخیره…' : 'ذخیره رمز جدید'}
</button>
</div>
</div>
)
}
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
const [pwOpen, setPwOpen] = useState(false)
return (
<>
<div
dir="rtl"
className="fixed bottom-4 right-1/2 z-[90] flex translate-x-1/2 items-center gap-5 border border-ivory/15 bg-ink/95 py-2.5 pl-6 pr-5 shadow-2xl backdrop-blur-md"
>
<span className="flex items-center gap-2 text-xs font-bold text-copper">
<span className="flex h-2 w-2 rounded-full bg-copper" />
حالت مدیریت
</span>
<button
type="button"
role="switch"
aria-checked={isEditMode}
onClick={toggleEditMode}
className="group flex items-center gap-3 text-sm font-light text-ivory/80"
>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition-colors duration-200 ${
isEditMode ? 'border-copper bg-copper/30' : 'border-ivory/25 bg-char'
}`}
>
<span
className={`absolute h-4 w-4 rounded-full transition-all duration-200 ${
isEditMode ? 'right-1 bg-copper' : 'right-6 bg-ivory/50'
}`}
/>
</span>
{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]}
</span>
<button
type="button"
onClick={() => setPwOpen(true)}
className="flex items-center gap-1.5 border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
<KeyRoundIcon className="h-3.5 w-3.5" />
رمز
</button>
<button
type="button"
onClick={logout}
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
خروج
</button>
</div>
{pwOpen && <ChangePasswordModal onClose={() => setPwOpen(false)} />}
</>
)
}
+239
View File
@@ -0,0 +1,239 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import Swal from 'sweetalert2'
import type { SiteData } from '../api'
import { useSiteData } from '../api'
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
isEditMode: boolean
toggleEditMode: () => void
login: (username: string, password: string) => Promise<void>
logout: () => void
saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
runSave: (action: () => Promise<void>) => Promise<boolean>
/** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */
editLang: EditLang
toggleEditLang: () => void
}
const AdminContext = createContext<AdminContextValue | null>(null)
export function useAdmin(): AdminContextValue {
const ctx = useContext(AdminContext)
if (!ctx) throw new Error('useAdmin باید داخل AdminProvider استفاده شود')
return ctx
}
function showSaveError(error: unknown): void {
const message =
error instanceof HttpError ? error.message : 'ارتباط با سرور برقرار نشد — اتصال اینترنت را بررسی کنید'
Swal.fire({
icon: 'error',
title: 'ذخیره نشد',
text: message,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'باشه',
timer: 4000,
timerProgressBar: true,
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10 rounded-none',
title: 'font-pinar font-bold text-lg text-ivory',
htmlContainer: 'font-pinar font-light text-sm text-ivory/70',
confirmButton: 'bg-copper text-char font-bold px-8 py-2.5',
},
})
}
export function AdminProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
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()
const toggleEditMode = useCallback(() => {
setIsEditMode((v) => {
sessionStorage.setItem('aramesh_edit_mode', v ? '0' : '1')
return !v
})
}, [])
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)
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند
reload()
}, [reload])
const logout = useCallback(() => {
setToken(null)
setIsAuthenticated(false)
setIsEditMode(false)
sessionStorage.removeItem('aramesh_edit_mode')
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند
reload()
}, [reload])
const runSave = useCallback(
async (action: () => Promise<void>): Promise<boolean> => {
setSaveStatus('saving')
try {
await action()
setSaveStatus('idle')
return true
} catch (error) {
setSaveStatus('error')
showSaveError(error)
if (error instanceof HttpError && error.status === 401) {
logout()
reload()
}
return false
}
},
[logout, reload],
)
// در حالت ویرایش، کلیک روی لینک‌ها مسیریابی نمی‌کند تا ویرایش متن ممکن باشد
useEffect(() => {
if (!isEditMode) return
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement | null)?.closest('a')
// لینک‌های خود لایه مدیریت (مثل «نوشتن مقاله جدید») کار می‌کنند
if (anchor && !anchor.dataset.adminLink) e.preventDefault()
}
document.addEventListener('click', onClick, true)
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
const guard = (e: BeforeUnloadEvent) => {
e.preventDefault()
}
window.addEventListener('beforeunload', guard)
return () => window.removeEventListener('beforeunload', guard)
}, [saveStatus])
const value = useMemo<AdminContextValue>(
() => ({
isAuthenticated,
isEditMode,
toggleEditMode,
login,
logout,
saveStatus,
runSave,
editLang,
toggleEditLang,
}),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
)
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
}
/**
* پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت
* همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند.
*
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت می‌شوند؛ چون
* برخی mutatorهای کامپوننت‌ها نام فیلد را جداگانه می‌نویسند، مقدار قبلی فیلدهای
* فارسی اسنپ‌شات می‌شود و پس از اجرای mutator بازگردانده می‌شود تا متن انگلیسی
* هرگز جای متن فارسی در درخت خام ننشیند.
*/
export function useFieldSaver() {
const { runSave, editLang } = useAdmin()
const { updateSite } = useSiteData()
return useCallback(
(
resource: string,
id: number,
changes: Record<string, unknown>,
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((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, editLang],
)
}
/**
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
* پسوند _en می‌گیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره می‌شود.
*/
export function useEditField() {
const { editLang } = useAdmin()
return useCallback(
(resource: string, field: string) =>
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
[editLang],
)
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useAdmin } from './AdminContext'
/** صفحه ورود ادمین — مسیر پنهان /admin/login */
export function AdminLoginPage() {
const { isAuthenticated, login } = useAdmin()
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
if (isAuthenticated) return <Navigate to="/" replace />
const submit = async (e: FormEvent) => {
e.preventDefault()
if (submitting) return
setError('')
setSubmitting(true)
try {
await login(username.trim(), password)
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
} finally {
setSubmitting(false)
}
}
return (
<section className="flex min-h-screen flex-col items-center justify-center bg-ink px-6" dir="rtl">
<div className="w-full max-w-sm border border-ivory/10 bg-char p-10">
<h1 className="text-xl font-bold text-ivory">ورود مدیریت</h1>
<p className="mt-3 text-sm font-light leading-7 text-ivory/55">
برای فعال‌سازی حالت ویرایش زنده سایت وارد شوید.
</p>
<form onSubmit={submit} className="mt-8 grid gap-5" noValidate>
<div>
<label htmlFor="admin-username" className="block text-sm font-bold text-ivory/90">
نام کاربری
</label>
<input
id="admin-username"
type="text"
dir="ltr"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-bold text-ivory/90">
رمز عبور
</label>
<input
id="admin-password"
type="password"
dir="ltr"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full border border-ivory/20 bg-ink/50 px-4 py-3 text-sm text-ivory transition-colors duration-200 focus:border-copper focus:outline-none"
/>
</div>
{error && (
<p className="border-r-2 border-red-400/70 pr-3 text-xs font-light leading-6 text-red-300">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="mt-2 bg-copper px-8 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-[1.02] disabled:opacity-50"
>
{submitting ? 'در حال ورود…' : 'ورود'}
</button>
</form>
</div>
</section>
)
}
+579
View File
@@ -0,0 +1,579 @@
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'
import { useSiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
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 { 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; 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[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean)
if (typeof value === 'string') return value.split(',').map((t) => t.trim()).filter(Boolean)
return []
}
const STATUS_TEXT: Record<string, string> = {
idle: 'ذخیره شد',
saving: 'در حال ذخیره…',
error: 'خطا در ذخیره',
}
/**
* صفحه تمام‌عرض نوشتن/ویرایش مقاله — محیط نوشتن متمرکز بدون چرخه سایت.
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/
export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data, updateSite } = useSiteData()
const existing = id ? data?.articles.find((a) => a.id === Number(id)) : undefined
const [fetched, setFetched] = useState<Draft | null>(null)
// مقاله تازه همیشه پیش‌نویس شروع می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود
const [draft, setDraft] = useState<Draft>({
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
const createdRef = useRef(createdId)
createdRef.current = createdId
const pendingRef = useRef<Record<string, unknown> | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// اگر مقاله در داده سایت نبود (مثلاً پیش از ادغام پیش‌نویس‌ها) مستقیم از انتهای مدیریتی خوانده می‌شود
useEffect(() => {
if (!id || existing) return
let cancelled = false
adminApi
.get<Draft>('articles', Number(id))
.then((a) => {
if (!cancelled) setFetched(a)
})
.catch(() => {
/* مقاله وجود ندارد — فرم «مقاله جدید» می‌ماند */
})
return () => {
cancelled = true
}
}, [id, existing])
const article = existing ?? fetched
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => {
if (!article || createdRef.current) return
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({
...(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])
const doSave = async (changes: Record<string, unknown>) => {
if (createdRef.current) {
await adminApi.patch('articles', createdRef.current, changes)
updateSite((d) => {
const a = d.articles.find((x) => x.id === createdRef.current)
if (a) Object.assign(a, changes)
})
} else {
// اولین ذخیره = ساخت پیش‌نویس: مقاله ساخته می‌شود اما تا «انتشار» در سایت عمومی دیده نمی‌شود
const payload = { ...draftRef.current, ...changes }
if (!payload.title) payload.title = 'بدون عنوان'
const created = await adminApi.create<Draft & { id: number }>('articles', payload)
setCreatedId(created.id)
updateSite((d) => {
d.articles.unshift(created as never)
})
navigate(`/admin/write/${created.id}`, { replace: true })
}
}
const flush = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
const changes = pendingRef.current
if (!changes) return
pendingRef.current = null
await doSave(changes)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const queueSave = (changes: Record<string, unknown>) => {
pendingRef.current = { ...(pendingRef.current ?? {}), ...changes }
setDraft((d) => ({ ...d, ...changes }) as Draft)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => void flush(), 800)
}
// ذخیره تغییرات در انتظار هنگام ترک صفحه — فقط برای مقاله ساخته‌شده
// (create در unmount ممنوع: با draft قدیمی، مقاله سرگردانی می‌سازد)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
const changes = pendingRef.current
if (!changes || !createdRef.current) return
void adminApi.patch('articles', createdRef.current, changes).catch(() => {
/* صفحه در حال بستن است */
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const forceSave = () => void runSave(flush)
// محافظ مسیر — پس از همه هوک‌ها تا ترتیب فراخوانی هوک‌ها همیشه یکسان بماند
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
const remove = async () => {
if (!createdId) return
const result = await Swal.fire({
icon: 'warning',
title: 'حذف مقاله',
text: 'این مقاله برای همیشه حذف شود؟',
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white 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',
},
})
if (!result.isConfirmed) return
setDeleting(true)
await runSave(async () => {
await adminApi.remove('articles', createdId)
navigate('/blog')
})
setDeleting(false)
}
const textField = (name: keyof Draft) => ({
value: String(draft[name] ?? ''),
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
queueSave({ [name]: e.target.value }),
})
const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
// متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
.filter(Boolean)
.join(' - ')
return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory">
{/* نوار بالا */}
<header className="sticky top-0 z-40 border-b border-ivory/10 bg-ink/95 backdrop-blur-md">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
<Link
to="/blog"
data-admin-link
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
<ArrowRightIcon className="h-4 w-4" />
بازگشت به وبلاگ
</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]}
</span>
<button
type="button"
onClick={forceSave}
className="border border-copper/50 px-5 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
ذخیره
</button>
<span
className={`border px-3 py-2 text-xs font-light ${
draft.status === 'draft'
? 'border-amber-400/50 text-amber-300'
: 'border-emerald-400/50 text-emerald-300'
}`}
>
{draft.status === 'draft' ? 'پیش‌نویس' : 'منتشر شده'}
</span>
{createdId && (
<>
<button
type="button"
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
draft.status === 'draft'
? 'bg-copper text-char hover:scale-105'
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button>
<Link
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"
>
<EyeIcon className="h-3.5 w-3.5" />
دیدن در سایت
</Link>
<button
type="button"
onClick={remove}
disabled={deleting}
aria-label="حذف مقاله"
className="flex h-8 w-8 items-center justify-center border border-red-400/40 text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 pb-32 pt-10">
<p className="text-xs font-light text-copper">
{!createdId
? 'نوشتن مقاله جدید — به‌صورت پیش‌نویس ذخیره می‌شود؛ با دکمه «انتشار در سایت» عمومی می‌شود'
: draft.status === 'draft'
? 'پیش‌نویس — تا انتشار در سایت عمومی دیده نمی‌شود؛ تغییرها ذخیره می‌شوند'
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p>
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره می‌شود */}
<Controller
name="title"
control={control}
render={({ field }) => (
<input
type="text"
{...field}
onChange={(event) => {
field.onChange(event.target.value)
queueSave({ [suf('title')]: event.target.value })
}}
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
/>
)}
/>
<div className="mt-4 h-px w-28 bg-copper" />
{/* تصویر شاخص + متن جایگزین و عنوان (هر دو به فرم وصل‌اند) */}
<div className="mt-8">
<ImageField
label="تصویر شاخص مقاله"
value={draft.image}
alt={imageAltField.value}
altFallback={imageAltFallback}
title={imageTitleField.value}
onSave={async (url) => {
queueSave({ image: url })
return true
}}
onSaveAlt={async (alt) => {
imageAltField.onChange(alt)
queueSave({ image_alt: alt })
return true
}}
onSaveTitle={async (imageTitle) => {
imageTitleField.onChange(imageTitle)
queueSave({ image_title: imageTitle })
return true
}}
/>
</div>
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */}
<div className="mt-8">
<ArticleEditor
contentKey={loadedContentKey}
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json)
if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
queueSave({ body: json, read: `${minutes} دقیقه` })
}
}}
/>
</div>
{/* توضیحات متای سئو */}
<section className="mt-8 border border-ivory/15 bg-char p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-copper">توضیحات سئو (Meta Description)</h2>
<span
className={`text-[11px] font-light ${
metaLength >= 150 && metaLength <= 160 ? 'text-emerald-400' : 'text-ivory/40'
}`}
>
{metaLength.toLocaleString('fa-IR')} از ۱۶۰ نویسه
</span>
</div>
<p className="mt-1 text-xs font-light text-ivory/50">
این همان متنی است که در صفحه نتایج گوگل زیر لینک مقاله به کاربران نمایش داده می‌شود (۱۵۰ تا ۱۶۰ نویسه).
</p>
<textarea
id="meta-description"
rows={3}
value={draft.meta_description ?? ''}
onChange={(e) => queueSave({ meta_description: e.target.value })}
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…"
className="mt-3 w-full resize-y border border-ivory/15 bg-ink px-4 py-3 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</section>
{/* چکیده و متادیتا */}
<SeoChecklist
input={{
title: draft.title,
slug: draft.slug,
excerpt: draft.meta_description || draft.excerpt,
body: draft.body,
keyphrase: draft.seo_keyphrase,
}}
/>
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
<h2 className="text-sm font-bold text-copper">جزئیات انتشار، دسته‌بندی و هشتگ‌ها</h2>
<div className="mt-5 grid gap-5">
<div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
<input
id="seo-keyphrase"
type="text"
value={draft.seo_keyphrase}
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea
rows={2}
{...textField(suf('excerpt'))}
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارت‌های وبلاگ…'}
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div>
<label htmlFor="article-author" className="text-xs font-light text-ivory/50">نویسنده</label>
<input
id="article-author"
type="text"
{...textField('author')}
placeholder="نام و نام خانوادگی نویسنده"
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div>
<label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label>
<div className="mt-2">
{/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */}
<Controller
name="category"
control={control}
render={({ field }) => (
<CategorySelect
value={field.value}
options={ARTICLE_CATEGORIES}
onChange={(option) => {
field.onChange(option)
queueSave({ category: categoryValue(option) })
}}
/>
)}
/>
</div>
</div>
<div>
<label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label>
<div className="mt-2">
<LuxeTagsSelect
value={normalizeTags(draft.tags)}
onChange={(tags) => queueSave({ tags })}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div>
<label htmlFor="article-date" className="text-xs font-light text-ivory/50">تاریخ انتشار (شمسی)</label>
<div className="mt-2">
<JalaliDatePicker
id="article-date"
value={draft.date ?? ''}
onChange={(isoDate) => queueSave({ date: isoDate })}
/>
</div>
</div>
<div>
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input
type="text"
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>
<label className="text-xs font-light text-ivory/50">نشانی (slug)</label>
<input
type="text"
dir="ltr"
{...textField('slug')}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
</div>
</div>
</section>
</main>
</div>
)
}
+290
View File
@@ -0,0 +1,290 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { Link } from 'react-router-dom'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import type { SiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
import { ENTITIES } from './entities'
import { EntityDrawer } from './EntityDrawer'
type ItemWithId = Record<string, unknown> & { id: number }
function useEntityList(resource: string): ItemWithId[] {
const { data } = useSiteData()
const config = ENTITIES[resource]
if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
}
function PillButton({
onClick,
label,
children,
danger = false,
}: {
onClick: () => void
label: string
children: ReactNode
danger?: boolean
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClick()
}}
className={`flex h-7 w-7 items-center justify-center border backdrop-blur-sm transition-colors duration-150 ${
danger
? 'border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20'
: 'border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char'
}`}
>
{children}
</button>
)
}
/**
* بسته‌بندی آیتم‌های مجموعه‌ها — فقط در حالت ویرایش ادمین، دکمه‌های شناور
* (ویرایش / بالا / پایین / حذف) روی آیتم نشان می‌دهد؛ در حالت عادی خروجی یکسان است.
*/
export function EditableItem({
resource,
item,
children,
className = '',
editHref,
}: {
resource: string
item: ItemWithId
children: ReactNode
className?: string
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */
editHref?: string
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { reload } = useSiteData()
const config = ENTITIES[resource]
const list = useEntityList(resource)
const [drawerOpen, setDrawerOpen] = useState(false)
if (!config) return <>{children}</>
if (!isAuthenticated || !isEditMode) return <>{children}</>
const index = list.findIndex((x) => x.id === item.id)
const move = (delta: number) => {
const target = index + delta
if (target < 0 || target >= list.length) return
const ids = list.map((x) => x.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
void runSave(async () => {
await adminApi.reorder(config.resource, ids)
reload()
})
}
const remove = async () => {
const result = await Swal.fire({
icon: 'warning',
title: 'حذف رکورد',
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white 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',
},
})
if (!result.isConfirmed) return
void runSave(async () => {
await adminApi.remove(config.resource, item.id)
reload()
})
}
return (
<div className={`relative group/editable ${className}`}>
<div className="absolute left-2 top-2 z-30 flex flex-col gap-1.5 opacity-0 transition-opacity duration-150 group-hover/editable:opacity-100">
{editHref ? (
<Link
to={editHref}
data-admin-link
title="ویرایش"
aria-label="ویرایش"
className="flex h-7 w-7 items-center justify-center border border-copper/50 bg-ink/90 text-copper backdrop-blur-sm transition-colors duration-150 hover:bg-copper hover:text-char"
>
<PencilIcon className="h-3.5 w-3.5" />
</Link>
) : (
<PillButton onClick={() => setDrawerOpen(true)} label="ویرایش">
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
)}
{index > 0 && (
<PillButton onClick={() => move(-1)} label="انتقال به قبل">
<ArrowUpIcon className="h-3.5 w-3.5" />
</PillButton>
)}
{index < list.length - 1 && (
<PillButton onClick={() => move(1)} label="انتقال به بعد">
<ArrowDownIcon className="h-3.5 w-3.5" />
</PillButton>
)}
<PillButton onClick={remove} label="حذف" danger>
<Trash2Icon className="h-3.5 w-3.5" />
</PillButton>
</div>
{children}
{drawerOpen && (
<EntityDrawer config={config} item={item} deletable onClose={() => setDrawerOpen(false)} />
)}
</div>
)
}
/** دکمه افزودن آیتم جدید به یک مجموعه — فقط در حالت ویرایش */
export function AddItemButton({
resource,
label,
variant = 'cell',
}: {
resource: string
label?: string
variant?: 'cell' | 'button'
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const { updateSite } = useSiteData()
const config = ENTITIES[resource]
const [newItem, setNewItem] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null
const create = () => {
void runSave(async () => {
const created = await adminApi.create<ItemWithId>(config.resource, config.defaults)
// درج خوش‌بینانه — reload کل درخت را بازسازی می‌کند و کشوی تازه‌ساخته‌شده می‌میرد
updateSite((d) => {
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(created)
})
setNewItem(created)
})
}
const drawer = newItem ? (
<EntityDrawer config={config} item={newItem} deletable onClose={() => setNewItem(null)} />
) : null
if (variant === 'button') {
return (
<>
<button
type="button"
onClick={create}
className="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" />
{label ?? `افزودن ${config.title}`}
</button>
{drawer}
</>
)
}
return (
<>
<button
type="button"
onClick={create}
className="flex min-h-40 w-full flex-col items-center justify-center gap-2 border border-dashed border-copper/40 bg-copper/5 text-copper transition-colors duration-200 hover:bg-copper/10"
>
<PlusIcon className="h-6 w-6" />
<span className="text-sm font-light">افزودن {config.title}</span>
</button>
{drawer}
</>
)
}
/** دکمه شناور بازکردن کشوی یک رکورد یگانه (برند، هیرو، فرم تماس و...) — فقط در حالت ویرایش.
* اگر رکورد هنوز در دیتابیس ساخته نشده باشد و createDefaults داده شود، دکمه + آن را می‌سازد. */
export function SingletonPill({
resource,
label,
className = '',
select,
createDefaults,
}: {
resource: string
label: string
className?: string
/** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
}) {
const { isAuthenticated, isEditMode, runSave } = useAdmin()
const config = ENTITIES[resource]
const { data, updateSite } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false)
const [created, setCreated] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null
const item =
created ??
(select
? select(data!)
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined))
if (!item && !createDefaults) return null
const create = () => {
const defaults =
typeof createDefaults === 'function' ? createDefaults() : (createDefaults ?? {})
void runSave(async () => {
const rec = await adminApi.create<ItemWithId>(config.resource, defaults)
// درج خوش‌بینانه در درخت داده — دفعه بعد از داده انتخاب می‌شود
updateSite((d) => {
const list = d[config.dataKey as keyof NonNullable<typeof d>] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(rec)
})
setCreated(rec)
setDrawerOpen(true)
})
}
return (
<>
<span className={className}>
{item ? (
<PillButton onClick={() => setDrawerOpen(true)} label={label}>
<PencilIcon className="h-3.5 w-3.5" />
</PillButton>
) : (
<PillButton onClick={create} label={`ساخت — ${label}`}>
<PlusIcon className="h-3.5 w-3.5" />
</PillButton>
)}
</span>
{drawerOpen && item && (
<EntityDrawer config={config} item={item} onClose={() => setDrawerOpen(false)} />
)}
</>
)
}
+494
View File
@@ -0,0 +1,494 @@
import { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon, Trash2Icon, XIcon } from 'lucide-react'
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 { LuxeCreatableSelect, LuxeSelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import { ArticleEditor } from '../components/admin/ArticleEditor'
type EntityDrawerProps = {
config: EntityConfig
/** رکورد یگانه یا آیتم مجموعه — باید id داشته باشد */
item: Record<string, unknown> & { id: number }
onClose: () => void
/** فقط برای مجموعه‌ها: حذف آیتم */
deletable?: boolean
}
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
const save = useFieldSaver()
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()
}
const saveField = (name: string, value: unknown) =>
save(config.resource, item.id, { [name]: value }, (d) => {
const target = d[config.dataKey as keyof typeof d] as unknown
const node =
config.kind === 'singleton'
? (target as Record<string, unknown>)
: (target as { id: number }[]).find((x) => x.id === item.id)
if (node) (node as Record<string, unknown>)[name] = value
})
const remove = async () => {
const result = await Swal.fire({
icon: 'warning',
title: 'حذف رکورد',
text: `«${String(item[config.titleKey] ?? '')}» برای همیشه حذف شود؟`,
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'انصراف',
buttonsStyling: false,
customClass: {
popup: 'font-pinar border border-ivory/10',
confirmButton: 'bg-red-500 text-white 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',
},
})
if (!result.isConfirmed) return
setDeleting(true)
await runSave(async () => {
await adminApi.remove(config.resource, item.id)
reload()
onClose()
})
setDeleting(false)
}
return (
<AnimatePresence>
<motion.div
key="backdrop"
className="fixed inset-0 z-[95] bg-char/70 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={closeWithFlush}
aria-hidden="true"
/>
<motion.aside
key="drawer"
dir="rtl"
role="dialog"
aria-label={`ویرایش ${config.title}`}
className={`fixed inset-y-0 left-0 z-[96] flex w-full flex-col border-e border-ivory/10 bg-ink shadow-2xl ${
config.resource === 'articles' ? 'sm:max-w-3xl' : 'sm:max-w-md'
}`}
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
>
<header className="flex items-center justify-between border-b border-ivory/10 px-6 py-4">
<div>
<p className="text-xs font-light text-copper">
{config.kind === 'singleton' ? 'تنظیمات بخش' : 'ویرایش رکورد'}
</p>
<h2 className="mt-1 text-lg font-bold text-ivory">{config.title}</h2>
</div>
<button
type="button"
onClick={closeWithFlush}
aria-label="بستن"
className="flex h-9 w-9 items-center justify-center border border-ivory/20 text-ivory/70 transition-colors hover:border-copper hover:text-copper"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6">
{fields.map((field) => {
const isImage = field.type === 'image'
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
const altKey = isImage
? field.name === 'src' && 'alt' in item
? 'alt'
: `${field.name}_alt`
: ''
const imageTitleKey = isImage ? `${field.name}_title` : ''
// پیشنهاد Alt از عنوان رکورد ساخته می‌شود (هرگز از URL)
const imageAltFallback = [
String(item[config.titleKey] ?? '').trim(),
field.altFallbackSuffix,
]
.filter(Boolean)
.join(' - ')
return (
<FieldControl
key={field.name}
field={field}
value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
altValue={altKey ? String(item[altKey] ?? '') : undefined}
altFallback={imageAltFallback}
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
onSave={(value) => saveField(field.name, value)}
onSaveAlt={altKey ? (alt) => saveField(altKey, alt) : undefined}
onSaveImageTitle={
imageTitleKey ? (title) => saveField(imageTitleKey, title) : undefined
}
registerFlush={
field.type === 'wysiwyg'
? (fn) => {
flushRef.current = fn
}
: undefined
}
/>
)
})}
</div>
</div>
{deletable && (
<footer className="border-t border-ivory/10 px-6 py-4">
<button
type="button"
onClick={remove}
disabled={deleting}
className="flex items-center gap-2 border border-red-400/40 px-4 py-2 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2Icon className="h-3.5 w-3.5" />
حذف این رکورد
</button>
</footer>
)}
</motion.aside>
</AnimatePresence>
)
}
function FieldControl({
field,
value,
altValue,
altFallback,
placeholder,
imageTitleValue,
onSave,
onSaveAlt,
onSaveImageTitle,
registerFlush,
}: {
field: FieldConfig
value: unknown
/** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string
altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown
/** مقدار Title تصویر — فقط برای فیلدهای image */
imageTitleValue?: string
onSave: (value: unknown) => Promise<boolean>
onSaveAlt?: (alt: string) => Promise<boolean>
onSaveImageTitle?: (title: string) => Promise<boolean>
registerFlush?: (fn: () => void) => void
}) {
const [text, setText] = useState<string>(String(value ?? ''))
// همگام‌سازی مقدار محلی با داده‌ای که از بیرون می‌آید (بارگذاری/به‌روزرسانی خوش‌بینانه)
useEffect(() => {
setText(String(value ?? ''))
}, [value])
const commitText = async () => {
const next = field.type === 'number' ? Number(text) || 0 : text
if (String(value ?? '') === String(next)) return
await onSave(next)
}
if (field.type === 'image') {
return (
<ImageField
label={field.label}
value={String(value ?? '')}
alt={altValue}
altFallback={altFallback}
title={imageTitleValue}
onSave={(url) => onSave(url)}
onSaveAlt={onSaveAlt}
onSaveTitle={onSaveImageTitle}
/>
)
}
if (field.type === 'select') {
// مقدار نمایشی از وضعیت محلی می‌آید تا افزودن/حذف گزینه فوراً دیده شود
const selectProps = {
value: text,
options: field.options ?? [],
onChange: (next: string) => {
setText(next)
void onSave(next)
},
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<div className="mt-2">
{field.creatable ? (
<LuxeCreatableSelect {...selectProps} />
) : (
<LuxeSelect {...selectProps} />
)}
</div>
</div>
)
}
if (field.type === 'date') {
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<div className="mt-2">
<JalaliDatePicker
value={String(value ?? '')}
onChange={(isoDate) => {
setText(isoDate)
void onSave(isoDate)
}}
/>
</div>
</div>
)
}
if (field.type === 'wysiwyg') {
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} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} />
)
}
if (field.type === 'textarea') {
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
<textarea
dir="auto"
rows={4}
placeholder={placeholder ? String(placeholder) : undefined}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commitText}
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>
)
}
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>}
<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}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
)
}
/** فیلد هشتگ‌ها — react-select قابل ساخت (Creatable) با ذخیره فوری */
function TagsField({
field,
value,
onSave,
}: {
field: FieldConfig
value: unknown
onSave: (value: unknown) => Promise<boolean>
}) {
const [tags, setTags] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
const update = (next: string[]) => {
setTags(next)
void onSave(next)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
<div className="mt-2">
<LuxeTagsSelect value={tags} onChange={update} />
</div>
</div>
)
}
/** ویرایش آرایه خطی (پاراگراف‌ها، گالری، نکات) — هر تغییر ذخیره می‌شود */
function ListField({
field,
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[]) : [])
const update = (next: string[]) => {
setLines(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>}
{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">
<textarea
dir="auto"
rows={line.length > 60 ? 3 : 1}
value={line}
onChange={(e) => {
const next = [...lines]
next[i] = e.target.value
setLines(next)
}}
onBlur={() => {
if (line !== (value as string[])[i]) update(lines)
}}
className="flex-1 resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
<button
type="button"
aria-label="حذف خط"
onClick={() => update(lines.filter((_, j) => j !== i))}
className="mt-1 text-ivory/40 transition-colors hover:text-red-300"
>
<XIcon className="h-4 w-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => update([...lines, ''])}
className="flex w-fit items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1.5 text-xs font-light text-ivory/60 transition-colors hover:border-copper hover:text-copper"
>
<PlusIcon className="h-3 w-3" />
افزودن خط
</button>
</div>
</div>
)
}
/** فیلد WYSIWYG مقاله — ذخیره خودکار با تاخیر کوتاه و flush هنگام بستن کشو */
function WysiwygField({
field,
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>>(
// بدنه ذخیره‌شده (سند جدید، رشته JSON یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
() => toArticleBodyDoc(value),
)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null)
const flush = () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// ثبت آخرین flush در کشو + ذخیره تغییرات در انتظار هنگام unmount
useEffect(() => {
registerFlush?.(() => flush())
return () => {
if (timer.current) clearTimeout(timer.current)
if (pending.current) {
const next = pending.current
pending.current = null
void onSave(next)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
})
const handleChange = (json: Record<string, unknown>) => {
setDoc(json)
pending.current = json
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, 800)
}
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>
<div className="mt-2">
<ArticleEditor doc={doc} onChange={handleChange} />
</div>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
// لایه تماس ادمین — همه درخواست‌ها با توکن Bearer و مدیریت خطای یکسان
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 {
constructor(
public status: number,
message: string,
) {
super(message)
}
}
async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const isFormData = options.body instanceof FormData
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: {
...(options.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.body && !isFormData ? { 'Content-Type': 'application/json' } : {}),
},
})
if (res.status === 401) {
setToken(null)
throw new HttpError(401, 'نشست شما منقضی شده — دوباره وارد شوید')
}
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
const detail =
typeof data?.detail === 'string'
? data.detail
: res.status === 409
? 'رکوردی با این مقدار تکراری وجود دارد'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید'
throw new HttpError(res.status, detail)
}
return res.status === 204 ? (null as T) : ((await res.json()) as T)
}
export const adminApi = {
login: async (username: string, password: string): Promise<string> => {
const res = await fetch(`${API_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null
throw new HttpError(
res.status,
typeof data?.detail === 'string' ? data.detail : 'ورود ناموفق بود',
)
}
const data = (await res.json()) as { access_token: string }
setToken(data.access_token)
return data.access_token
},
patch: <T = unknown>(resource: string, id: number, partial: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(partial) }),
list: <T = unknown>(resource: string) => authFetch<T>(`/api/admin/${resource}`),
get: <T = unknown>(resource: string, id: number) =>
authFetch<T>(`/api/admin/${resource}/${id}`),
create: <T = unknown>(resource: string, data: Record<string, unknown>) =>
authFetch<T>(`/api/admin/${resource}`, { method: 'POST', body: JSON.stringify(data) }),
remove: (resource: string, id: number) =>
authFetch<null>(`/api/admin/${resource}/${id}`, { method: 'DELETE' }),
reorder: <T = unknown>(resource: string, ids: number[]) =>
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
upload: async (file: File): Promise<{ url: string; key: string }> => {
const fd = new FormData()
fd.append('file', file)
return authFetch('/api/admin/upload', { method: 'POST', body: fd })
},
changePassword: (currentPassword: string, newPassword: string) =>
authFetch<{ ok: boolean }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
}
+256
View File
@@ -0,0 +1,256 @@
import type { SiteData } from '../api'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
export type FieldType = 'text' | 'textarea' | 'number' | 'image' | 'list' | 'wysiwyg' | 'select' | 'date'
export type FieldConfig = {
name: string
label: string
type: FieldType
hint?: string
/** فقط برای type === 'select' — گزینه‌های ثابت react-select */
options?: string[]
/** فقط برای type === 'select' — امکان ساخت گزینه جدید توسط کاربر */
creatable?: boolean
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
altFallbackSuffix?: string
}
export type EntityConfig = {
resource: string
dataKey: string
title: string
kind: 'collection' | 'singleton'
titleKey: string
fields: FieldConfig[]
defaults: Record<string, unknown>
}
const F = (
name: string,
label: string,
type: FieldType = 'text',
hint?: string,
sharedOrExtra?: boolean | Partial<FieldConfig>,
): FieldConfig => ({
name,
label,
type,
hint,
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
? sharedOrExtra
: { shared: !!sharedOrExtra }),
})
/** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */
export const ARTICLE_CATEGORIES = [
'معماری و ساخت',
'سرمایه‌گذاری ملکی',
'طراحی داخلی',
'اخبار و اطلاعیه‌ها',
'یادداشت مدیریت',
]
export const ENTITIES: Record<string, EntityConfig> = {
services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
titleKey: 'title',
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)', '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'),
],
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
},
articles: {
resource: 'articles', dataKey: 'articles', title: 'مقالات', kind: 'collection',
titleKey: 'title',
fields: [
F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true),
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
F('author', 'نویسنده', 'text', undefined, true),
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
F('read', 'زمان مطالعه (دقیقه)'),
F('slug', 'نشانی (slug)', 'text', undefined, true),
],
defaults: {
title: 'مقاله جدید',
slug: `article-${Date.now()}`,
date: new Date().toISOString().split('T')[0],
category: ARTICLE_CATEGORIES[0],
tags: ['مدیریت پروژه', 'ساخت‌وساز'],
image: '',
image_alt: '',
image_title: '',
author: '',
meta_description: '',
read: '۵ دقیقه',
excerpt: '',
seo_keyphrase: '',
body: { type: 'doc', content: [{ type: 'paragraph' }] }
},
},
testimonials: {
resource: 'testimonials', dataKey: 'testimonials', title: 'نظرات مشتریان', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('quote', 'متن نظر', 'textarea')],
defaults: { name: 'نظر جدید', role: '', quote: '' },
},
faqs: {
resource: 'faqs', dataKey: 'faqs', title: 'سوالات متداول', kind: 'collection',
titleKey: 'question',
fields: [F('question', 'سوال'), F('answer', 'پاسخ', 'textarea')],
defaults: { question: 'سوال جدید', answer: '' },
},
team: {
resource: 'team', dataKey: 'team', title: 'تیم', kind: 'collection',
titleKey: 'name',
fields: [F('name', 'نام'), F('role', 'سمت'), F('bio', 'معرفی', 'textarea')],
defaults: { name: 'عضو جدید', role: '', bio: '' },
},
timeline: {
resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection',
titleKey: 'title',
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', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
},
why_us: {
resource: 'why-us', dataKey: 'why_us', title: 'چرا آرامش', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { title: 'مورد جدید', desc: '' },
},
investment_models: {
resource: 'investment-models', dataKey: 'investment_models', title: 'مدل‌های سرمایه‌گذاری', kind: 'collection',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('points', 'نکات (هر خط یک مورد)', 'list')],
defaults: { title: 'مدل جدید', desc: '', points: [] },
},
process_steps: {
resource: 'process-steps', dataKey: 'process_steps', title: 'مراحل فرآیند کار', kind: 'collection',
titleKey: 'title',
fields: [F('step', 'شماره مرحله'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea')],
defaults: { step: '۰۱', title: 'مرحله جدید', desc: '' },
},
offices: {
resource: 'offices', dataKey: 'offices', title: 'دفاتر', kind: 'collection',
titleKey: 'city',
fields: [F('city', 'شهر'), F('address', 'نشانی', 'textarea'), F('hours', 'ساعات کاری')],
defaults: { city: 'شهر جدید', address: '', hours: '' },
},
gallery: {
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
titleKey: '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)', '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', true),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
],
defaults: { page_key: '', title: '', description: '' },
},
brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name',
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', 'لینک دکمه اصلی', '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('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', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
defaults: {},
},
contact_section: {
resource: 'contact-section', dataKey: 'contact_section', title: 'فرم تماس', kind: 'singleton',
titleKey: 'title',
fields: [
F('label', 'برچسب بالا'), F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'),
F('address_label', 'برچسب نشانی'), F('phone_label', 'برچسب تلفن'), F('email_label', 'برچسب ایمیل'),
F('name_label', 'برچسب نام'), F('name_placeholder', 'جای‌نگهدار نام'),
F('phone_field_label', 'برچسب تلفن (فرم)'), F('phone_placeholder', 'جای‌نگهدار تلفن'),
F('note_label', 'برچسب توضیحات'), F('note_optional_label', 'برچسب اختیاری'), F('note_placeholder', 'جای‌نگهدار توضیحات'),
F('submit_label', 'متن دکمه ارسال'), F('sending_label', 'متن در حال ارسال'),
F('success_title', 'عنوان پیام موفقیت'), F('success_text', 'متن پیام موفقیت', 'textarea'), F('success_button_label', 'دکمه پیام موفقیت'),
],
defaults: {},
},
newsletter: {
resource: 'newsletter', dataKey: 'newsletter', title: 'خبرنامه', kind: 'singleton',
titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('placeholder', 'جای‌نگهدار ایمیل'), F('button_label', 'متن دکمه'), F('sending_label', 'متن در حال ارسال'), F('success_text', 'پیام موفقیت')],
defaults: {},
},
footer: {
resource: 'footer', dataKey: 'footer', title: 'فوتر', kind: 'singleton',
titleKey: 'pages_heading',
fields: [F('about_text', 'متن معرفی', 'textarea'), F('pages_heading', 'تیتر ستون صفحات'), F('services_heading', 'تیتر ستون خدمات'), F('contact_heading', 'تیتر ستون تماس'), F('copyright', 'متن کپی‌رایت'), F('tagline', 'شعار پایین')],
defaults: {},
},
}
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
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
}
+146 -23
View File
@@ -2,11 +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, 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
@@ -14,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 }
@@ -120,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 {
@@ -153,15 +216,29 @@ export interface SiteData {
contact_section: ContactSectionContent
newsletter: NewsletterContent
footer: FooterContent
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
}
interface SiteContextValue {
data: SiteData | null
error: boolean
reload: () => void
/** به‌روزرسانی خوش‌بینانه درخت داده — پس از موفقیت PATCH استفاده می‌شود */
updateSite: (mutate: (data: SiteData) => void) => void
}
const SiteContext = createContext<SiteContextValue>({ data: null, error: false, reload: () => {} })
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)
@@ -170,12 +247,35 @@ 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>
})
.then((json) => {
.then(async (json) => {
if (cancelled) return
// برای ادمین واردشده، پیش‌نویس مقاله‌ها از انتهای مدیریتی خوانده و ادغام می‌شوند —
// بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند
if (getToken()) {
try {
const res = await fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
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) {
if (json.brand && json.hero && json.footer && json.nav) setData(json)
else setError(true)
@@ -186,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
})
return () => {
cancelled = true
window.clearTimeout(timeoutId)
}
}, [])
@@ -197,20 +298,36 @@ export function SiteProvider({ children }: { children: ReactNode }) {
load()
}, [load])
return <SiteContext.Provider value={{ data, error, reload }}>{children}</SiteContext.Provider>
const updateSite = useCallback((mutate: (data: SiteData) => void) => {
setData((prev) => {
if (!prev) return prev
const next = structuredClone(prev)
mutate(next)
return next
})
}, [])
return (
<SiteContext.Provider value={{ data, error, reload, updateSite }}>
{children}
</SiteContext.Provider>
)
}
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 = {
@@ -227,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
}
+26 -18
View File
@@ -2,44 +2,52 @@ import React from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { formatJalali } from '../utils/date'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang()
return (
<Link
to={`/blog/${article.slug}`}
className="group flex h-full flex-col border border-ivory/10 bg-char transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`خواندن مقاله ${article.title}`}
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={`${t('readArticlePrefix')} ${article.title}`}
>
<div className="overflow-hidden">
<div className="relative overflow-hidden">
<img
src={article.image}
alt={`تصویر مقاله ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-105 transform-gpu"
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 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-8">
<p className="text-xs font-light text-copper">{article.category}</p>
<h3 className="mt-4 text-lg font-bold leading-9 text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
<h3 className="mb-4 md:mb-6 text-xl font-bold leading-relaxed text-ivory transition-colors duration-300 group-hover:text-copper">
{article.title}
</h3>
<p className="mt-4 mb-8 text-sm font-light leading-[2.2rem] text-ivory/55">
<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-ivory/10 pt-6 text-xs font-light text-ivory/50">
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-6 md:pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4">
<span>{article.date}</span>
<span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" />
{article.read}
<span>{formatJalali(article.date)}</span>
<span className="flex items-center gap-2">
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
{toPersianDigits(article.read)}
</span>
</span>
<span className="flex items-center gap-2 text-copper">
ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 ease-out group-hover:-translate-x-1.5" />
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:hover:[&>svg]:-translate-x-1 ltr:hover:[&>svg]:translate-x-1">
{t('readMore')}
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
</span>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date'
import { toPersianDigits } from '../utils/digits'
type ArticleMetaProps = {
article: Article
className?: string
}
/**
* سربرگ نویسنده/تاریخ مقاله — زیر تیتر اصلی نمایش داده می‌شود:
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
*/
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
const rawDate = (article.date ?? '').trim()
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش'
const initial = author.charAt(0)
// تاریخ ذخیره‌شده (میلادی) به شمسی تبدیل می‌شود؛ در نبود تاریخ، امروز با ساعت نمایش داده می‌شود
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
const dateLabel = formatJalali(rawDate, todayLabel)
return (
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
{/* آواتار و نام نویسنده */}
<span className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-lg font-bold text-copper shadow-[0_0_20px_rgba(213,135,76,0.25),inset_0_1px_1px_rgba(255,255,255,0.2)]">
{initial}
</span>
<span className="flex flex-col">
<span className="text-sm font-bold text-ivory">{author}</span>
<span className="text-[11px] font-light text-ivory/45">نویسنده</span>
</span>
</span>
<span className="hidden h-9 w-px bg-ivory/10 sm:block" aria-hidden="true" />
{/* تاریخ جلالی */}
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<CalendarIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{dateLabel}
</span>
{/* زمان مطالعه — با ارقام فارسی */}
{article.read && (
<span className="flex items-center gap-2 text-xs font-light text-ivory/60">
<ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{toPersianDigits(article.read)}
</span>
)}
</div>
)
}
-93
View File
@@ -1,93 +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
}
const STYLES: Record<Variant, { base: string; fill: string; text: string }> = {
copper: {
base: 'bg-copper text-char',
fill: 'bg-char',
text: 'group-hover:text-ivory',
},
outline: {
base: 'border border-ivory/30 text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
},
dark: {
base: 'bg-char text-ivory',
fill: 'bg-copper',
text: 'group-hover:text-char',
},
'dark-ivory': {
base: 'bg-char text-ivory',
fill: 'bg-ivory',
text: 'group-hover:text-char',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full transition-transform duration-[600ms] ease-lux group-hover:translate-y-0 ${style.fill}`}
/>
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-[600ms] ease-lux ${style.text}`}
>
{children}
</span>
</>
)
}
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden px-8 py-4 text-sm font-bold focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
return (
<Link to={to} className={`${SHELL} ${STYLES[variant].base} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a href={href} className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className={`${SHELL} ${STYLES[variant].base} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+100 -52
View File
@@ -2,16 +2,32 @@ import React, { useState } from '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'
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)
const [sending, setSending] = useState(false)
const saveContent = (field: string, value: string) =>
save('contact-section', content.id, { [field]: value }, (d) => {
;(d.contact_section as unknown as Record<string, unknown>)[field] = value
})
const saveBrand = (field: string, value: string) =>
save('brand', brand.id, { [field]: value }, (d) => {
;(d.brand as unknown as Record<string, unknown>)[field] = value
})
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
@@ -20,14 +36,14 @@ export function ContactSection() {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
confirmButtonText: t('understood'),
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-[8px] bg-ink/60',
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
container: 'backdrop-blur-md bg-ink/70',
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
},
showClass: {
popup: 'swal2-show',
@@ -36,7 +52,7 @@ export function ContactSection() {
hideClass: {
popup: 'swal2-hide',
backdrop: 'swal2-backdrop-hide',
}
},
})
}
@@ -48,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
}
@@ -69,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)
}
@@ -90,53 +106,73 @@ export function ContactSection() {
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
<div className="relative mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-6xl items-stretch gap-6 lg:grid-cols-12">
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[8px] md:p-10 lg:col-span-5">
<div className="mx-auto grid max-w-6xl items-stretch gap-8 lg:grid-cols-12">
{/* باکس اطلاعات تماس */}
<div className="rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-5">
<Reveal>
<p className="text-sm font-light text-copper">{content.label}</p>
<h2 id="contact-title" className="mt-5 text-section font-bold text-ivory">
{content.title}
<p className="text-sm font-light tracking-wide text-copper">
<InlineEdit value={content.label} onSave={(v) => saveContent('label', v)} />
</p>
<h2 id="contact-title" className="mt-4 text-section font-bold text-ivory">
<InlineEdit value={content.title} onSave={(v) => saveContent('title', v)} />
</h2>
<p className="mt-6 text-base font-light leading-9 text-ivory/75">
{content.lead}
<p className="mt-5 text-base font-light leading-9 text-ivory/75">
<InlineEdit multiline value={content.lead} onSave={(v) => saveContent('lead', v)} />
</p>
</Reveal>
<Reveal delay={0.1}>
<ul className="mt-10 space-y-6">
<li className="flex items-start gap-4">
<MapPinIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<MapPinIcon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-bold text-ivory">{content.address_label}</p>
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">{brand.address}</p>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.address_label} onSave={(v) => saveContent('address_label', v)} />
</p>
<p className="mt-1 text-sm font-light leading-7 text-ivory/70">
<InlineEdit value={brand.address} onSave={(v) => saveBrand('address', v)} />
</p>
</div>
</li>
<li className="flex items-start gap-4">
<PhoneIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<PhoneIcon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-bold text-ivory">{content.phone_label}</p>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.phone_label} onSave={(v) => saveContent('phone_label', v)} />
</p>
<a
href={brand.phone_href}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
{brand.phone}
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
</a>
</div>
</li>
<li className="flex items-start gap-4">
<MailIcon className="mt-1 h-5 w-5 shrink-0 text-copper" />
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-copper/30 bg-copper/10 text-copper shadow-[inset_0_1px_1px_rgba(255,255,255,0.2)]">
<MailIcon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-bold text-ivory">{content.email_label}</p>
<p className="text-sm font-bold text-ivory">
<InlineEdit value={content.email_label} onSave={(v) => saveContent('email_label', v)} />
</p>
<a
href={`mailto:${brand.email}`}
dir="ltr"
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
{brand.email}
<InlineEdit value={brand.email} onSave={(v) => saveBrand('email', v)} />
</a>
</div>
</li>
@@ -144,31 +180,35 @@ export function ContactSection() {
</Reveal>
</div>
<div className="border border-ivory/20 bg-char/45 p-8 backdrop-blur-[9px] md:p-10 lg:col-span-7">
{/* باکس فرم ارسال درخواست */}
<div className="relative rounded-3xl border border-white/10 bg-char/60 p-8 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.15)] md:p-10 lg:col-span-7">
<SingletonPill resource="contact_section" label="ویرایش برچسب‌ها و متن‌های فرم" className="absolute left-4 top-4" />
{sent ? (
<div className="flex h-full flex-col items-center justify-center py-14 text-center">
<CheckCircle2Icon className="h-12 w-12 text-copper" />
<h3 className="mt-6 text-xl font-bold text-ivory">{content.success_title}</h3>
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-copper/40 bg-copper/15 text-copper shadow-[0_0_30px_rgba(197,143,92,0.4)]">
<CheckCircle2Icon className="h-8 w-8" />
</div>
<h3 className="mt-6 text-xl font-bold text-ivory">
<InlineEdit value={content.success_title} onSave={(v) => saveContent('success_title', v)} />
</h3>
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
{content.success_text}
<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"
>
{content.success_button_label}
</Button>
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</PremiumButton>
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
<div>
<label htmlFor="contact-name" className="block text-sm font-bold text-ivory/90">
{content.name_label}
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
</label>
<input
id="contact-name"
@@ -176,13 +216,13 @@ export function ContactSection() {
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={content.name_placeholder}
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper 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 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>
<div>
<label htmlFor="contact-phone" className="block text-sm font-bold text-ivory/90">
{content.phone_field_label} <span className="text-copper">*</span>
<label htmlFor="contact-phone" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.phone_field_label} onSave={(v) => saveContent('phone_field_label', v)} /> <span className="text-copper">*</span>
</label>
<input
id="contact-phone"
@@ -192,28 +232,36 @@ export function ContactSection() {
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder={content.phone_placeholder}
className="mt-3 w-full border border-ivory/20 bg-ink/45 px-5 py-4 text-right text-sm font-light text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper 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>
<div className="md:col-span-2">
<label htmlFor="contact-note" className="block text-sm font-bold text-ivory/90">
{content.note_label} <span className="font-light text-ivory/50">{content.note_optional_label}</span>
<label htmlFor="contact-note" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.note_label} onSave={(v) => saveContent('note_label', v)} /> <span className="font-light text-ivory/50">{content.note_optional_label}</span>
</label>
<textarea
id="contact-note"
rows={6}
rows={5}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
placeholder={content.note_placeholder}
className="mt-3 w-full resize-none border border-ivory/20 bg-ink/45 px-5 py-4 text-sm font-light leading-8 text-ivory placeholder:text-ivory/40 transition-colors duration-200 ease-lux focus:border-copper focus:outline-none"
className="mt-3 w-full resize-none rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light leading-8 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>
<div className="md:col-span-2">
<Button type="submit" variant="copper" className="w-full md:w-auto md:px-12" disabled={sending}>
{sending ? content.sending_label : content.submit_label}
</Button>
<div className="md:col-span-2 flex justify-start">
<PremiumButton
type="submit"
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)} />
)}
</PremiumButton>
</div>
</form>
)}
+15 -6
View File
@@ -1,17 +1,26 @@
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 bg-copper text-char shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-copper/50 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
<PhoneIcon className="h-5 w-5" />
</a>);
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
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full border border-copper/60 animate-ping opacity-30"
/>
{/* آیکون تلفن با انیمیشن چرخش ظریف در زمان هاور */}
<PhoneIcon className="relative z-10 h-6 w-6 transition-transform duration-300 ease-lux group-hover:rotate-12" />
</a>
);
}
+88 -31
View File
@@ -1,7 +1,11 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from './admin/InlineEdit';
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
instagram: InstagramIcon,
@@ -11,16 +15,38 @@ 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) =>
save('footer', footer.id, { [field]: value }, (d) => {
;(d.footer as unknown as Record<string, unknown>)[field] = value;
});
const saveNav = (id: number, changes: Record<string, unknown>) =>
save('nav', id, changes, (d) => {
const item = d.nav.find((n) => n.id === id);
if (item) Object.assign(item, changes);
});
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<footer className="border-t border-ivory/10 bg-char">
<footer className="relative border-t border-white/10 bg-char/95 backdrop-blur-2xl">
<div className="mx-auto max-w-site px-6 py-16 lg:px-10">
<div className="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-4">
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/55">
{footer.about_text}
<div className="relative w-fit">
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
</div>
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
</p>
<div className="mt-6 flex gap-3">
{/* آیکون‌های شبکه‌های اجتماعی: گوی‌های شیشه‌ای شناور */}
<div className="mt-8 flex items-center gap-3">
{footer.socials.map(({ label, href, icon }) => {
const Icon = SOCIAL_ICONS[icon] ?? InstagramIcon;
return (
@@ -28,9 +54,9 @@ export function Footer() {
key={label}
href={href}
aria-label={label}
className="flex h-10 w-10 items-center justify-center border border-ivory/15 text-ivory/70 transition-colors duration-200 ease-lux hover:border-copper hover:text-copper">
<Icon className="h-4 w-4" />
className="group flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.04] text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_12px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:-translate-y-1 hover:border-copper/70 hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.4)] active:scale-95"
>
<Icon className="h-4 w-4 transition-transform duration-300 group-hover:scale-110" />
</a>
);
})}
@@ -38,34 +64,48 @@ export function Footer() {
</div>
<div className="lg:col-span-2">
<h3 className="text-sm font-bold text-copper">{footer.pages_heading}</h3>
<h3 className="text-sm font-bold text-copper">
<InlineEdit value={footer.pages_heading} onSave={(v) => saveFooter('pages_heading', v)} />
</h3>
<ul className="mt-5 space-y-3">
{nav.map((item) =>
<li key={item.href}>
<Link
to={item.href}
className="text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper">
{item.label}
</Link>
</li>
)}
{nav.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<Link
to={href(item.href)}
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
>
<InlineEdit
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
</Link>
</li>
</EditableItem>
))}
</ul>
<div className="mt-4">
<AddItemButton resource="nav" />
</div>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">{footer.services_heading}</h3>
<h3 className="text-sm font-bold text-copper">
<InlineEdit value={footer.services_heading} onSave={(v) => saveFooter('services_heading', v)} />
</h3>
<ul className="mt-5 space-y-3">
{services.map((service) =>
<li key={service.title} className="text-sm font-light text-ivory/60">
{services.map((service) => (
<li key={service.id} className="text-sm font-light text-ivory/60">
{service.title}
</li>
)}
))}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="text-sm font-bold text-copper">{footer.contact_heading}</h3>
<h3 className="text-sm font-bold text-copper">
<InlineEdit value={footer.contact_heading} onSave={(v) => saveFooter('contact_heading', v)} />
</h3>
<ul className="mt-5 space-y-4 text-sm font-light text-ivory/60">
<li className="flex items-start gap-3">
<MapPinIcon className="mt-1 h-4 w-4 shrink-0 text-copper" />
@@ -87,11 +127,28 @@ export function Footer() {
</div>
</div>
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-ivory/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
<p>{footer.copyright}</p>
<p>{footer.tagline}</p>
{/* خط پایانی به همراه دکمه کپسولی بازگشت به بالا */}
<div className="mt-14 flex flex-col items-center justify-between gap-6 border-t border-white/10 pt-6 text-xs font-light text-ivory/40 md:flex-row">
<p>
<InlineEdit value={footer.copyright} onSave={(v) => saveFooter('copyright', v)} />
</p>
<button
type="button"
onClick={scrollToTop}
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
>
<span>{t('backToTop')}</span>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
<ArrowUpIcon className="h-3 w-3" />
</span>
</button>
<p>
<InlineEdit value={footer.tagline} onSave={(v) => saveFooter('tagline', v)} />
</p>
</div>
</div>
</footer>);
}
</footer>
);
}
+91 -63
View File
@@ -2,9 +2,22 @@ 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>) =>
save("nav", id, changes, (d) => {
const item = d.nav.find((n) => n.id === id);
if (item) Object.assign(item, changes);
});
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const { pathname } = useLocation();
@@ -21,76 +34,86 @@ export function Header() {
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-[background-color,border-color,backdrop-filter] duration-300 ease-lux ${
className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ease-lux ${
scrolled
? "border-b border-ivory/10 bg-ink/90 backdrop-blur-md"
? "border-b border-white/10 bg-ink/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)]"
: "border-b border-transparent bg-transparent"
}`}
>
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
<Link
to="/"
className="flex items-center gap-3"
aria-label={brand.name}
>
<img
src={brand.logo}
alt={brand.name}
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
scrolled ? "h-14" : "h-20"
}`}
/>
</Link>
<div className="relative flex items-center">
<Link
to={href("/")}
className="flex items-center gap-3"
aria-label={brand.name}
>
<img
src={brand.logo}
alt={brand.name}
className={`w-auto object-contain transition-[height] duration-300 ease-lux ${
scrolled ? "h-14" : "h-20"
}`}
/>
</Link>
<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) => (
<li key={item.href}>
<NavLink
to={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" : "text-ivory/80"
}`
}
>
{({ isActive }) => (
<>
{item.label}
<span
className={`absolute -bottom-0.5 left-0 right-0 h-px w-full origin-center bg-copper transition-transform duration-300 ease-lux ${
isActive
? "scale-x-100"
: "scale-x-0 group-hover:scale-x-100"
}`}
/>
</>
)}
</NavLink>
</li>
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<NavLink
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"
}`
}
>
{({ isActive }) => (
<>
<InlineEdit
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
<span
className={`absolute -bottom-0.5 left-0 right-0 h-0.5 w-full origin-center rounded-full bg-copper transition-transform duration-300 ease-lux ${
isActive
? "scale-x-100 shadow-[0_0_8px_rgba(197,143,92,0.8)]"
: "scale-x-0 group-hover:scale-x-100"
}`}
/>
</>
)}
</NavLink>
</li>
</EditableItem>
))}
</ul>
</nav>
<div className="flex items-center gap-3">
{/* دکمه تماس کپسولی شیشه‌ای لوکس */}
<a
href={brand.phone_href}
className="group relative hidden items-center gap-2 overflow-hidden border border-copper/50 px-5 py-2.5 text-sm font-medium text-copper [text-shadow:_0_1px_2px_rgba(0,0,0,0.5)] lg:flex"
className="group relative hidden items-center gap-3 rounded-full border border-copper/40 bg-white/[0.04] px-5 py-2 text-sm font-medium text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_25px_rgba(197,143,92,0.35)] active:scale-95 lg:flex"
>
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-lux group-hover:translate-y-0"
/>
<span className="relative z-10 flex items-center gap-2 transition-colors duration-300 ease-lux group-hover:text-char">
<PhoneIcon className="h-4 w-4" />
<span dir="ltr">{brand.phone}</span>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-copper/20 text-copper transition-all duration-300 group-hover:bg-copper group-hover:text-char">
<PhoneIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
</span>
<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 border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper xl:hidden"
aria-label={open ? "بستن منو" : "باز کردن منو"}
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 ? t("closeMenu") : t("openMenu")}
aria-expanded={open}
>
{open ? (
@@ -104,23 +127,28 @@ export function Header() {
{open && (
<nav
aria-label="ناوبری موبایل"
className="border-t border-ivory/10 bg-ink/98 px-6 py-4 xl:hidden"
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">
{nav.map((item) => (
<li key={item.href}>
<NavLink
to={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"
}`
}
>
{item.label}
</NavLink>
</li>
<EditableItem key={item.id} resource="nav" item={item}>
<li>
<NavLink
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"
}`
}
>
<InlineEdit
value={item.label}
onSave={(v) => saveNav(item.id, { label: v })}
/>
</NavLink>
</li>
</EditableItem>
))}
</ul>
</nav>
+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>
)}
View File
+56 -18
View File
@@ -2,52 +2,90 @@ import React from 'react'
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'
import { ImageField } from './admin/ImageField'
type PageHeroProps = {
title: string
lead: string
image: string
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایش‌پذیر می‌شوند */
pageKey: string
}
export function PageHero({ title, lead, image }: 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) =>
save('page-heroes', hero.id, { [field]: value }, (d) => {
const h = d.page_heroes.find((p) => p.id === hero.id)
if (h) (h as Record<string, unknown>)[field] = value
})
return (
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
{image && <img src={image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
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" />
<span className="text-copper">{title}</span>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{hero.title}</span>
</nav>
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-[700px] text-hero font-bold text-ivory"
className="mt-5 max-w-[700px]"
>
{title}
<InlineEdit
as="span"
value={hero.title}
onSave={(v) => saveField('title', v)}
className="block text-hero font-bold text-ivory"
/>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1, ease: [0.23, 1, 0.32, 1] }}
className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/65"
className="mt-5 max-w-2xl"
>
{lead}
<InlineEdit
as="span"
multiline
value={hero.lead}
onSave={(v) => saveField('lead', v)}
className="block text-base font-light leading-9 text-ivory/65"
/>
</motion.p>
<div className="mt-8 h-px w-28 bg-copper" />
{/* ویرایش تصویر پس‌زمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
<SingletonPill
resource="seo-meta"
label="عنوان و توضیح سئوی این صفحه"
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
// اگر ردیف سئوی این صفحه هنوز ساخته نشده باشد، همین‌جا ساخته می‌شود
createDefaults={() => ({ page_key: pageKey, title: '', description: '' })}
/>
<ImageField label="تصویر پس‌زمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
</div>
</div>
</section>
)
}
}
+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>
)
}
+67 -29
View File
@@ -1,51 +1,89 @@
import React from 'react'
import { Link } from 'react-router-dom'
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 border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift 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"
/>
<div className={`absolute right-4 top-4 flex items-center gap-2 bg-ink/85 px-4 py-2 text-xs font-light backdrop-blur-md ${textColor}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />
<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={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
{/* Fixed height image wrapper */}
<div className="relative w-full overflow-hidden">
{finalImageUrl && (
<img
src={finalImageUrl}
alt={title}
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
/>
)}
{/* نشانگر وضعیت کپسولی شیشه‌ای */}
<div
className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}
>
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText}
</div>
</div>
<div className="flex flex-1 flex-col p-8">
<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">
{project.title}
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
{title}
</h3>
<p className="mt-7 text-sm font-light text-ivory/60">
{project.location}
</p>
</div>
<div className="my-7 h-px w-full bg-ivory/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>
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
</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>
)
}
+38 -9
View File
@@ -1,29 +1,58 @@
import React from 'react'
import { Reveal } from './Reveal'
import { useSectionHeading } from '../api'
import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
type SectionHeadingProps = {
title: string
/** در صورت ارسال، تیتر و توضیح از دیتابیس خوانده و به‌صورت درجا ویرایش‌پذیر می‌شوند */
headingKey?: string
title?: string
lead?: string
align?: 'right' | 'center'
tone?: 'copper' | 'ivory'
}
export function SectionHeading({
headingKey,
title,
lead,
align = 'right',
tone = 'ivory',
}: SectionHeadingProps) {
const heading = useSectionHeading(headingKey ?? '')
const save = useFieldSaver()
const isDb = !!headingKey
const currentTitle = isDb ? heading.title : (title ?? '')
const currentLead = isDb ? heading.lead : (lead ?? '')
const isCenter = align === 'center'
const saveField = (field: 'title' | 'lead', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id)
if (h) (h as Record<string, unknown>)[field] = value
})
return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
<h2 className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}>
{title}
</h2>
{lead && <p className="mt-5 text-base font-light leading-9 text-ivory/60">{lead}</p>}
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
<InlineEdit
as="h2"
value={currentTitle}
onSave={(v) => saveField('title', v)}
className={`text-section font-bold ${tone === 'copper' ? 'text-copper' : 'text-ivory'}`}
/>
{currentLead && (
<InlineEdit
as="p"
multiline
value={currentLead}
onSave={(v) => saveField('lead', v)}
className="mt-5 text-base font-light leading-9 text-ivory/60"
/>
)}
<div className={`mt-6 h-px w-24 bg-copper/70 ${isCenter ? 'mx-auto' : ''}`} />
</Reveal>
)
}
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react'
import type { MouseEvent } from 'react'
import { ListIcon } from 'lucide-react'
import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 }
/**
* متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
*/
function headingText(element: Element): string {
const parts: string[] = []
element.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
else if (node.nodeType === Node.ELEMENT_NODE) {
const child = node as Element
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
}
})
return parts.join('')
}
/**
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
* (حتی داخل نقل‌قول) شناسه لنگر می‌گیرند؛ متن فارسی در شناسه حفظ می‌شود و تیترهای
* تکراری پسوند عددی می‌گیرند. HTML برگشتی همان است که باید رندر شود.
*/
export function extractToc(
html: string,
transform?: (root: HTMLElement) => void,
): { items: TocItem[]; html: string } {
if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>()
const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = headingText(element).replace(/\s+/g, ' ').trim()
if (!text) return
// تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
element.setAttribute('id', id)
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
})
// تبدیل اختیاری (بسته‌بندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا می‌شود
transform?.(doc.body)
return { items, html: doc.body.innerHTML }
}
type TableOfContentsProps = {
items: TocItem[]
title?: string
className?: string
}
/**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
useEffect(() => {
setActiveId(items[0]?.id ?? '')
}, [items])
useEffect(() => {
if (!items.length || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
const first = visible[0]?.target.id
if (first) setActiveId(first)
},
{ rootMargin: '-15% 0px -70% 0px', threshold: [0, 1] },
)
items.forEach((item) => {
const el = document.getElementById(item.id)
if (el) observer.observe(el)
})
return () => observer.disconnect()
}, [items])
if (items.length === 0) return null
const go = (event: MouseEvent<HTMLButtonElement>, id: string) => {
event.preventDefault()
const el = document.getElementById(id)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.history.replaceState(null, '', `#${id}`)
}
return (
<nav
aria-label="فهرست مطالب"
className={`rounded-2xl border border-white/10 bg-char/60 p-6 backdrop-blur-xl shadow-[0_20px_50px_rgba(0,0,0,0.35),inset_0_1px_1px_rgba(255,255,255,0.08)] ${className}`}
>
<p className="flex items-center gap-2 text-sm font-bold text-copper">
<ListIcon className="h-4 w-4" aria-hidden="true" />
{title}
</p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
{/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود می‌شود تا بریده نشود */}
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
{items.map((item) => {
const isActive = item.id === activeId
return (
<li key={item.id}>
<button
type="button"
onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined}
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${
isActive
? 'font-normal text-copper'
: 'text-ivory/60 hover:text-copper'
}`}
>
{item.text}
</button>
</li>
)
})}
</ul>
</nav>
)
}
@@ -0,0 +1,432 @@
import { useEffect, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core'
import {
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
Heading1Icon,
Heading2Icon,
Heading3Icon,
ImagePlusIcon,
ItalicIcon,
Link2Icon,
ListIcon,
ListOrderedIcon,
Loader2Icon,
PilcrowIcon,
QuoteIcon,
Redo2Icon,
StrikethroughIcon,
UnderlineIcon,
Undo2Icon,
} from 'lucide-react'
import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient'
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage'
import { BidiTypography, bidiPluginKey } from './BidiTypography'
import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal'
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
import type { LinkModalValue } from './LinkModal'
/**
* مارک‌های قدیمی (سایز ۱۲ و متن انگلیسی) — کنترل آن‌ها از نوار ابزار برداشته شده و
* فقط برای اینکه محتوای قبلی از دست نرود در شِما نگه داشته شده‌اند.
*/
const LegacySmallSizeMark = Mark.create({
name: 'smallSize',
parseHTML() {
return [{ tag: 'span[data-small]' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { 'data-small': 'true' }), 0]
},
})
/** اکستنشن‌های مشترک ویرایشگر و رندر مقاله — هر دو باید یکسان باشند */
export const ARTICLE_EXTENSIONS = [
StarterKit.configure({
link: false,
heading: { levels: [1, 2, 3] },
}),
ArticleImage.configure({ allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => isAllowedHref(uri),
}),
LegacySmallSizeMark,
EnglishText,
BidiTypography,
]
type ArticleEditorProps = {
doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void
/**
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری می‌شود
* (مثلاً وقتی مقاله از سرور می‌رسد). بدون این prop محتوا فقط یک‌بار مقداردهی می‌شود.
*/
contentKey?: string | number
}
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمی‌گرداند */
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
type TextDirection = 'rtl' | 'ltr'
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */
type LinkApplyMode = 'selection' | 'insert' | 'range'
type LinkModalState = {
open: boolean
editing: boolean
mode: LinkApplyMode
range: { from: number; to: number } | null
initial: LinkModalValue
}
const CLOSED_LINK_MODAL: LinkModalState = {
open: false,
editing: false,
mode: 'selection',
range: null,
initial: { href: '', newTab: true, nofollow: false },
}
/** چند نویسه پیش از نشانگر برای تشخیص نشانیِ تایپ‌شده بررسی می‌شود */
const URL_LOOKBACK = 300
function ToolButton({
onClick,
active = false,
label,
children,
}: {
onClick: () => void
active?: boolean
label: string
children: React.ReactNode
}) {
return (
<button
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 ${
active
? 'border-copper/50 bg-copper/15 text-copper'
: 'border-transparent text-ivory/60 hover:border-ivory/15 hover:text-copper'
}`}
>
{children}
</button>
)
}
const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="true" />
/**
* ویرایشگر 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,
content: doc && Object.keys(doc).length ? doc : undefined,
immediatelyRender: false,
editorProps: {
attributes: {
dir: 'rtl',
lang: 'fa',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true',
},
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) => {
if (moved) return false
const file = event.dataTransfer?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
void uploadAndInsert(file)
return true
}
return false
},
// پایان تایپ یک نشانی (فاصله بعد از آن) → مودال لینک باز می‌شود
handleTextInput: (view, from, to, text) => {
if (text !== ' ' || from !== to) return false
const start = Math.max(0, from - URL_LOOKBACK)
const before = view.state.doc.textBetween(start, from, ' ', ' ').trimEnd()
const lastToken = before.slice(before.lastIndexOf(' ') + 1)
if (!isUrlLike(lastToken)) return false
setLinkModal({
open: true,
editing: false,
mode: 'range',
range: { from: from - lastToken.length, to: from },
initial: { href: normalizeHref(lastToken) ?? lastToken, newTab: true, nofollow: false },
})
return false
},
// Enter داخل تیتر، بلوک تازه را پاراگراف می‌کند تا قالب تیتر سرایت نکند
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
},
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
})
// جهت متن فقط روی ناحیه ویرایش اعمال می‌شود؛ نوار ابزار همیشه راست‌چین می‌ماند
useEffect(() => {
if (!editor) return
editor.view.dom.setAttribute('dir', direction)
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
// تزئین‌های دوزبانه (اندازه ۲ پیکسل کوچک‌تر متن لاتین) با جهت تازه بازمحاسبه می‌شوند
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
}, [editor, direction])
/**
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
*/
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساخته‌شدن نمونه ویرایشگر
useEffect(() => {
if (!editor || contentKey === undefined) return
const loaded = loadedContentRef.current
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
const incoming = doc && Object.keys(doc).length ? doc : null
if (!incoming) return
loadedContentRef.current = { editor, key: contentKey }
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بی‌مورد را فعال نکند
editor.commands.setContent(incoming, { emitUpdate: false })
}, [editor, contentKey, doc])
async function uploadAndInsert(file: File) {
if (!editor) return
setUploading(true)
await runSave(async () => {
const { url } = await adminApi.upload(file)
// نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor
.chain()
.focus()
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
.run()
})
setUploading(false)
}
/** باز کردن مودال برای لینک انتخاب فعلی (یا ویرایش لینک زیر نشانگر) */
const openLinkModalForSelection = () => {
if (!editor) return
const attrs = editor.getAttributes('link') as {
href?: string | null
target?: string | null
rel?: string | null
}
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,
},
})
}
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" 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"
>
<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 />
{/* جهت متن — فقط محتوای ویرایشگر را عوض می‌کند */}
<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) => {
const file = e.target.files?.[0]
if (file) void uploadAndInsert(file)
e.target.value = ''
}}
/>
</div>
{/* مودال لینک — انیمیشن ورود/خروج را خودش با کلاس‌های Tailwind مدیریت می‌کند */}
{linkModal.open && (
<LinkModal
initial={linkModal.initial}
editing={linkModal.editing}
onApply={applyLink}
onRemove={removeLink}
onClose={() => setLinkModal((state) => ({ ...state, open: false }))}
/>
)}
</>
)
}
@@ -0,0 +1,67 @@
import Image from '@tiptap/extension-image'
import { mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { ImageNodeView } from './ImageNodeView'
/**
* تصویر مقاله با کپشن.
* خروجی HTML در سایت: <figure><img><figcaption>کپشن</figcaption></figure>
* و در ویرایشگر با NodeView سفارشی (ورودی کپشن + دکمه حذف) نمایش داده می‌شود.
*/
export const ArticleImage = Image.extend({
addAttributes() {
return {
// ویژگی‌های استاندارد تصویر (src/alt/title/width/height) از افزونه پایه
...this.parent?.(),
caption: {
default: '',
parseHTML: (element) =>
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
},
}
},
parseHTML() {
return [
{
tag: 'figure',
priority: 60,
getAttrs: (element) => {
if (!(element instanceof HTMLElement)) return false
const img = element.querySelector('img')
if (!img) return false
return {
src: img.getAttribute('src'),
alt: img.getAttribute('alt'),
title: img.getAttribute('title'),
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
}
},
},
// تصاویر بدون figure (محتوای قدیمی یا چسبانده‌شده)
{ tag: 'img[src]:not([src^="data:"])' },
]
},
renderHTML({ HTMLAttributes }) {
const { caption, ...imageAttributes } = HTMLAttributes
const image: [string, Record<string, unknown>] = [
'img',
mergeAttributes(this.options.HTMLAttributes, imageAttributes),
]
// کپشن خالی → عنصر figcaption اصلاً رندر نمی‌شود (فقط تصویر می‌ماند)
return caption
? [
'figure',
{ class: 'article-figure' },
image,
['figcaption', { class: 'article-figure__caption' }, caption],
]
: ['figure', { class: 'article-figure' }, image]
},
addNodeView() {
return ReactNodeViewRenderer(ImageNodeView)
},
})
@@ -0,0 +1,64 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorState } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
/** نام مارک‌های قدیمی که خودشان اندازه/جهت متن را تعیین می‌کنند */
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
type BidiState = { dir: 'rtl' | 'ltr' }
/** کلید پلاگین — جهت متن با متای همین کلید به‌روز می‌شود */
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
/**
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
* span جهت‌دار نشانه‌گذاری می‌شود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچک‌تر»
* خروجی سایت را نشان دهد. تزئین‌ها سند را تغییر نمی‌دهند و ذخیره نمی‌شوند.
*/
export const BidiTypography = Extension.create({
name: 'bidiTypography',
addProseMirrorPlugins() {
return [
new Plugin<BidiState>({
key: bidiPluginKey,
state: {
init: () => ({ dir: 'rtl' }),
apply: (tr, value) => {
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
return dir && dir !== value.dir ? { dir } : value
},
},
props: {
decorations: (state) =>
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
},
}),
]
},
})
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
const decorations: Decoration[] = []
const { doc, schema } = state
doc.descendants((node, pos, parent) => {
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
if (!node.isText || !node.text) return
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
return
}
for (const run of findScriptRuns(node.text, dir)) {
decorations.push(
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
)
}
})
return DecorationSet.create(doc, decorations)
}
@@ -0,0 +1,26 @@
import { Mark, mergeAttributes } from '@tiptap/core'
/** نام مارک متن انگلیسی — ویرایشگر و رندر استاتیک مقاله از همین نام استفاده می‌کنند */
export const ENGLISH_TEXT_MARK = 'englishText'
/**
* مارک «متن انگلیسی» — متن انتخاب‌شده را داخل
* <span dir="ltr" class="english-text"> می‌پیچد تا ۱۲ پیکسل و چپ‌به‌راست
* نمایش داده شود، بدون اینکه جهت متن فارسی اطرافش به‌هم بخورد.
*/
export const EnglishText = Mark.create({
name: ENGLISH_TEXT_MARK,
inclusive: false,
parseHTML() {
return [{ tag: 'span.english-text' }]
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(HTMLAttributes, { dir: 'ltr', class: 'english-text' }),
0,
]
},
})
@@ -0,0 +1,181 @@
import { useId, useRef, useState } from 'react'
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext'
import { useSmartTextField } from './useSmartTextField'
type ImageFieldProps = {
label: string
value: string
onSave: (url: string) => Promise<boolean>
/** متن جایگزین (Alt) */
alt?: string
onSaveAlt?: (alt: string) => Promise<boolean>
/** عنوان تصویر (Title) */
title?: string
onSaveTitle?: (title: string) => Promise<boolean>
/**
* پیشنهاد Alt وقتی کاربر چیزی ننوشته — مثل «{عنوان مقاله} - تصویر شاخص».
* هرگز از URL یا نام فایل ساخته نمی‌شود.
*/
altFallback?: string
/** پیشنهاد عنوان تصویر — پیش‌فرض «Photo 1» */
titleFallback?: string
}
const META_FIELD_CLASS =
'mt-1.5 w-full border border-ivory/15 bg-char px-3 py-2 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
/**
* انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود.
* پیش‌فرض خودکار با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمی‌گردد.
*/
export function ImageField({
label,
value,
onSave,
alt,
onSaveAlt,
title,
onSaveTitle,
altFallback = '',
titleFallback = '',
}: ImageFieldProps) {
const { runSave, isAuthenticated, isEditMode } = useAdmin()
const inputRef = useRef<HTMLInputElement>(null)
const titleId = useId()
const altId = useId()
const [uploading, setUploading] = useState(false)
const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt
const titleField = useSmartTextField({
value: title ?? '',
fallback: titleFallback,
hardFallback: DEFAULT_IMAGE_TITLE,
onCommit: onSaveTitle,
})
const altField = useSmartTextField({
value: alt ?? '',
fallback: altFallback,
hardFallback: label,
onCommit: onSaveAlt,
})
if (!isAuthenticated || !isEditMode) return null
const pick = async (file: File | undefined) => {
if (!file) return
setUploading(true)
try {
await runSave(async () => {
const { url } = await adminApi.upload(file)
const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد')
// پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit()
if (showAlt) await altField.commit()
})
} finally {
setUploading(false)
}
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
{/* پیش‌نمایش بزرگ تصویر */}
<div className="mt-2 overflow-hidden border border-ivory/15 bg-char">
{value ? (
<img
src={value}
alt={altField.display || label}
title={titleField.display || undefined}
className="h-[27rem] w-full object-cover"
/>
) : (
<div className="flex h-[27rem] w-full flex-col items-center justify-center gap-2 border border-dashed border-ivory/20 text-ivory/30">
<ImageIcon className="h-7 w-7" aria-hidden="true" />
<span className="text-[11px] font-light">تصویری انتخاب نشده است</span>
</div>
)}
</div>
<div className="mt-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/80 transition-colors duration-200 hover:border-copper hover:text-copper disabled:opacity-50"
>
{uploading ? (
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlusIcon className="h-3.5 w-3.5" />
)}
{uploading ? 'در حال آپلود…' : value ? 'تغییر تصویر' : 'انتخاب تصویر'}
</button>
</div>
{(showTitle || showAlt) && (
<div className="mt-4 grid max-w-md gap-4 border-t border-ivory/10 pt-4 sm:grid-cols-2">
{showTitle && (
<div>
<label htmlFor={titleId} className="text-[11px] font-light text-ivory/50">
عنوان تصویر (Title)
</label>
<input
id={titleId}
type="text"
dir="auto"
value={titleField.display}
onChange={(event) => titleField.onChange(event.target.value)}
onFocus={titleField.onFocus}
onBlur={titleField.onBlur}
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
className={META_FIELD_CLASS}
/>
</div>
)}
{showAlt && (
<div>
<label htmlFor={altId} className="text-[11px] font-light text-ivory/50">
متن جایگزین (Alt)
</label>
<input
id={altId}
type="text"
dir="auto"
value={altField.display}
onChange={(event) => altField.onChange(event.target.value)}
onFocus={altField.onFocus}
onBlur={altField.onBlur}
placeholder={
altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها'
}
className={META_FIELD_CLASS}
/>
</div>
)}
</div>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(event) => {
void pick(event.target.files?.[0])
event.target.value = ''
}}
/>
</div>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react'
import { NodeViewWrapper } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { PlusIcon, XIcon } from 'lucide-react'
type ImageAttrs = {
src: string
alt: string | null
title: string | null
caption: string | null
}
const DELETE_BUTTON_CLASS =
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
const INLINE_FIELD_CLASS =
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
const ADD_CAPTION_CLASS =
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
/**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure>
*
* ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال):
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند: صفت کپشن به مقدار پیش‌فرض برمی‌گردد و عنصر
* figcaption با ورودی‌اش از DOM ویرایشگر (و از خروجی ذخیره‌شده) حذف می‌شود؛
* فقط تصویر می‌ماند و «افزودن کپشن» دوباره جعبه را باز می‌کند.
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت می‌شود.
* - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs
const hasCaption = Boolean((caption ?? '').trim())
const [captionOpen, setCaptionOpen] = useState(true)
const captionInputRef = useRef<HTMLInputElement>(null)
const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption
// فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله)
useEffect(() => {
if (!focusOnOpen.current) return
focusOnOpen.current = false
captionInputRef.current?.focus()
}, [captionOpen])
/** حذف کامل کپشن: عنصر figcaption و ورودی‌اش از DOM ویرایشگر بیرون می‌رود */
const removeCaption = () => {
updateAttributes({ caption: '' })
setCaptionOpen(false)
}
const addCaption = () => {
focusOnOpen.current = true
setCaptionOpen(true)
}
return (
<NodeViewWrapper
className={`article-figure-node ${selected ? 'is-selected' : ''}`}
data-drag-handle
>
<figure className="article-figure relative">
<img src={src} alt={alt ?? ''} title={title ?? ''} draggable={false} />
{/* حذف کل تصویر */}
<button
type="button"
contentEditable={false}
onClick={() => deleteNode()}
aria-label="حذف تصویر"
title="حذف تصویر"
className={`absolute left-2 top-2 z-10 ${DELETE_BUTTON_CLASS}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
{showCaption ? (
<figcaption className="article-figure__caption" contentEditable={false}>
<input
ref={captionInputRef}
type="text"
value={caption ?? ''}
onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر"
className={INLINE_FIELD_CLASS}
/>
{/* حذف کامل کپشن — تصویر دست‌نخورده می‌ماند */}
<button
type="button"
onClick={removeCaption}
aria-label="حذف کپشن"
title="حذف کپشن (تصویر حذف نمی‌شود)"
className={DELETE_BUTTON_CLASS}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</figcaption>
) : (
<button
type="button"
contentEditable={false}
onClick={addCaption}
className={ADD_CAPTION_CLASS}
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)}
</figure>
</NodeViewWrapper>
)
}
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, ElementType, KeyboardEvent } from 'react'
import { useAdmin } from '../../admin/AdminContext'
type InlineEditProps = {
value: string
/** ذخیره async — در صورت خطا، متن به مقدار قبلی برمی‌گردد */
onSave: (value: string) => Promise<boolean> | boolean | void
as?: ElementType
/** چندخطی: Enter خط جدید می‌گذارد و فقط blur ذخیره می‌کند */
multiline?: boolean
className?: string
style?: CSSProperties
'aria-label'?: string
}
/**
* ویرایش درجا متن — در حالت عادی دقیقاً همان HTML قبلی را رندر می‌کند
* (بدون هیچ تغییر چیدمان) و فقط در Edit Mode برای ادمین قابل ویرایش می‌شود.
*/
export function InlineEdit({
value,
onSave,
as: Tag = 'span',
multiline = false,
className = '',
style,
...rest
}: InlineEditProps) {
const { isAuthenticated, isEditMode } = useAdmin()
const editing = isAuthenticated && isEditMode
const ref = useRef<HTMLElement>(null)
const [focused, setFocused] = useState(false)
// همگام‌سازی متن فقط وقتی المان در حال ویرایش نیست (پرش نشانگر رخ ندهد)
useEffect(() => {
const el = ref.current
if (el && !focused && el.innerText !== value) el.innerText = value
}, [value, focused])
if (!editing) {
return (
<Tag className={className} style={style} {...rest}>
{value}
</Tag>
)
}
const commit = async () => {
const el = ref.current
if (!el) return
setFocused(false)
const next = el.innerText.replace(/\u00a0/g, ' ')
if (next === value) return
el.innerText = value // بازگشت فوری به مقدار فعلی؛ در صورت موفقیت، به‌روزرسانی خوش‌بینانه متن تازه می‌آید
await onSave(next)
}
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!multiline && e.key === 'Enter') {
e.preventDefault()
;(e.currentTarget as HTMLElement).blur()
}
if (e.key === 'Escape') {
if (ref.current) ref.current.innerText = value
;(e.currentTarget as HTMLElement).blur()
}
}
return (
<Tag
ref={ref as never}
contentEditable
suppressContentEditableWarning
spellCheck={false}
data-inline-edit
aria-label={rest['aria-label']}
className={`${className} cursor-text rounded-sm transition-[outline-color,background-color] duration-150 outline-1 outline-dashed outline-transparent hover:bg-copper/5 hover:outline-copper/40 focus:bg-copper/5 focus:outline-copper focus:outline-solid`}
style={style}
onFocus={() => setFocused(true)}
onBlur={commit}
onKeyDown={onKeyDown}
>
{value}
</Tag>
)
}
@@ -0,0 +1,52 @@
import DatePicker from 'react-multi-date-picker'
import type { DateObject } from 'react-multi-date-picker'
import persian from 'react-date-object/calendars/persian'
import persian_fa from 'react-date-object/locales/persian_fa'
import { toISODate, toJalaliDateObject } from '../../utils/date'
type JalaliDatePickerProps = {
/** مقدار ذخیره‌شده — ISO میلادی (YYYY-MM-DD) */
value: string
/** تغییر مقدار — همیشه ISO میلادی برمی‌گرداند تا ذخیره‌سازی یکدست بماند */
onChange: (isoDate: string) => void
id?: string
placeholder?: string
disabled?: boolean
className?: string
}
/**
* انتخابگر تاریخ شمسی (react-multi-date-picker) با تقویم و locale فارسی.
* نمایش جلالی است اما مقدار ذخیره‌شده ISO میلادی می‌ماند.
*/
export function JalaliDatePicker({
value,
onChange,
id,
placeholder = 'انتخاب تاریخ',
disabled = false,
className = '',
}: JalaliDatePickerProps) {
return (
<DatePicker
id={id}
value={toJalaliDateObject(value)}
onChange={(selected) => {
const picked = Array.isArray(selected) ? selected[0] : selected
if (!picked) return
onChange(toISODate(picked as DateObject))
}}
calendar={persian}
locale={persian_fa}
format="YYYY/MM/DD"
calendarPosition="bottom-right"
className={`aramesh-datepicker ${className}`}
containerClassName="w-full"
inputClass="w-full cursor-pointer border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={placeholder}
disabled={disabled}
mobileLabels={{ OK: 'تأیید', CANCEL: 'انصراف' }}
portal
/>
)
}
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react'
import type { FormEvent } from 'react'
import { Link2Icon, Trash2Icon, XIcon } from 'lucide-react'
import { buildLinkRel, normalizeHref } from '../../utils/link'
export type LinkModalValue = {
href: string
newTab: boolean
nofollow: boolean
}
type LinkModalProps = {
/** مقادیر اولیه — از لینک فعال زیر نشانگر یا خالی برای لینک تازه */
initial: LinkModalValue
/** ویرایش لینک موجود (دکمه «حذف لینک» نمایش داده می‌شود) */
editing: boolean
onApply: (value: LinkModalValue) => void
onRemove: () => void
onClose: () => void
}
const FIELD_CLASS =
'mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
/** مدت انیمیشن ورود/خروج — با duration-300 کلاس‌ها هماهنگ است */
const TRANSITION_MS = 300
/**
* مودال ثبت و ویرایش لینک — انیمیشن نرم fade و scale با کلاس‌های Tailwind.
* ورود با یک فریم تاخیر (تا کلاس‌های بسته اعمال شوند) و خروج پیش از بستن مودال اجرا می‌شود.
* جایگزین پنجره پیش‌فرض مرورگر برای لینک شده است.
*/
export function LinkModal({ initial, editing, onApply, onRemove, onClose }: LinkModalProps) {
const [href, setHref] = useState(initial.href)
const [newTab, setNewTab] = useState(initial.newTab)
const [nofollow, setNofollow] = useState(initial.nofollow)
const [error, setError] = useState('')
const [visible, setVisible] = useState(false)
const [closing, setClosing] = useState(false)
// انیمیشن ورود: ابتدا حالت بسته رندر می‌شود، سپس به حالت باز می‌رود
useEffect(() => {
const frame = window.requestAnimationFrame(() => setVisible(true))
return () => window.cancelAnimationFrame(frame)
}, [])
/** بستن نرم: اول انیمیشن خروج کامل می‌شود، بعد عملیات و unmount */
const closeWith = (action?: () => void) => {
if (closing) return
setClosing(true)
setVisible(false)
window.setTimeout(() => {
action?.()
onClose()
}, TRANSITION_MS)
}
const submit = (event: FormEvent) => {
event.preventDefault()
const normalized = normalizeHref(href)
if (!normalized) {
setError('نشانی معتبر نیست — نمونه: https://example.com یا /contact')
return
}
closeWith(() => onApply({ href: normalized, newTab, nofollow }))
}
const relPreview = buildLinkRel({ newTab, nofollow })
return (
<div
className="fixed inset-0 z-[97] flex items-center justify-center p-6"
role="dialog"
aria-modal="true"
aria-label="ثبت لینک"
>
{/* پس‌زمینه تار — fade نرم */}
<div
className={`absolute inset-0 bg-char/70 backdrop-blur-sm transition-opacity duration-300 ease-out ${
visible ? 'opacity-100' : 'opacity-0'
}`}
onClick={() => closeWith()}
aria-hidden="true"
/>
{/* کارت مودال — fade + scale نرم */}
<form
onSubmit={submit}
dir="rtl"
className={`relative w-full max-w-md border border-ivory/10 bg-ink p-7 shadow-2xl transition-all duration-300 ease-out ${
visible ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
}`}
>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-bold text-copper">
<Link2Icon className="h-4 w-4" aria-hidden="true" />
{editing ? 'ویرایش لینک' : 'افزودن لینک'}
</h2>
<button
type="button"
onClick={() => closeWith()}
aria-label="بستن"
className="text-ivory/50 transition-colors hover:text-ivory"
>
<XIcon className="h-4 w-4" />
</button>
</div>
{/* نشانی */}
<label htmlFor="link-href" className="mt-6 block text-xs font-light text-ivory/60">
نشانی پیوند (URL)
</label>
<input
id="link-href"
type="text"
dir="ltr"
autoFocus
value={href}
onChange={(event) => {
setHref(event.target.value)
if (error) setError('')
}}
placeholder="https://example.com"
className={FIELD_CLASS}
/>
{error && (
<p className="mt-2 border-r-2 border-red-400/70 pr-3 text-[11px] font-light leading-6 text-red-300">
{error}
</p>
)}
{/* باز شدن در تب جدید */}
<label className="mt-5 flex cursor-pointer items-center gap-3 text-sm font-light text-ivory/80">
<input
type="checkbox"
checked={newTab}
onChange={(event) => setNewTab(event.target.checked)}
className="h-4 w-4 accent-copper"
/>
باز شدن در تب جدید
<span className="text-[10px] font-light text-ivory/35" dir="ltr">target=&quot;_blank&quot;</span>
</label>
{/* نوع لینک از نظر سئو */}
<fieldset className="mt-5">
<legend className="text-xs font-light text-ivory/60">انتقال اعتبار سئو (rel)</legend>
<div className="mt-3 grid gap-2.5">
{[
{ key: 'dofollow', label: 'Dofollow', hint: 'اعتبار سئو به مقصد منتقل می‌شود', value: false },
{ key: 'nofollow', label: 'Nofollow', hint: 'گوگل این لینک را دنبال نکند', value: true },
].map((option) => (
<label
key={option.key}
className={`flex cursor-pointer items-start gap-3 border px-4 py-3 transition-colors duration-150 ${
nofollow === option.value
? 'border-copper/60 bg-copper/10'
: 'border-ivory/12 hover:border-ivory/25'
}`}
>
<input
type="radio"
name="link-rel"
checked={nofollow === option.value}
onChange={() => setNofollow(option.value)}
className="mt-0.5 h-4 w-4 accent-copper"
/>
<span className="flex flex-col">
<span className="text-sm font-light text-ivory">{option.label}</span>
<span className="text-[11px] font-light text-ivory/45">{option.hint}</span>
</span>
</label>
))}
</div>
</fieldset>
{relPreview && (
<p className="mt-4 text-[10px] font-light text-ivory/35" dir="ltr">
rel=&quot;{relPreview}&quot;
</p>
)}
{/* دکمه‌ها */}
<div className="mt-7 flex items-center justify-between gap-3">
{editing ? (
<button
type="button"
onClick={() => closeWith(onRemove)}
className="flex items-center gap-1.5 border border-red-400/40 px-4 py-2.5 text-xs font-light text-red-300 transition-colors hover:bg-red-500/10"
>
<Trash2Icon className="h-3.5 w-3.5" aria-hidden="true" />
حذف لینک
</button>
) : (
<span />
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => closeWith()}
className="border border-ivory/20 px-5 py-2.5 text-xs font-light text-ivory/70 transition-colors hover:border-ivory/40 hover:text-ivory"
>
انصراف
</button>
<button
type="submit"
className="bg-copper px-6 py-2.5 text-xs font-bold text-char transition-transform duration-200 hover:scale-105"
>
{editing ? 'ذخیره لینک' : 'افزودن لینک'}
</button>
</div>
</div>
</form>
</div>
)
}
@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react'
import Select from 'react-select'
import CreatableSelect from 'react-select/creatable'
import type { ClassNamesConfig, GroupBase, MultiValueRemoveProps } from 'react-select'
import { XIcon } from 'lucide-react'
export type LuxeOption = { label: string; value: string }
type LuxeClassNames<IsMulti extends boolean> = ClassNamesConfig<
LuxeOption,
IsMulti,
GroupBase<LuxeOption>
>
const CONTROL_CLASS_NAMES = (isFocused: boolean): string =>
`!min-h-[42px] !rounded-none !border !bg-char !transition-colors ${
isFocused
? '!border-copper !shadow-[0_0_0_1px_rgba(213,135,76,0.35)]'
: '!border-ivory/15 !shadow-none hover:!border-copper/50'
}`
const OPTION_CLASS_NAMES = (isSelected: boolean, isFocused: boolean): string =>
`!cursor-pointer !rounded-none !text-[13px] ${
isSelected
? '!bg-copper/25 !font-bold !text-copper'
: isFocused
? '!bg-copper/15 !text-ivory'
: '!bg-transparent !text-ivory/85'
}`
/**
* کلاس‌های Tailwind برای همه اسلات‌های react-select — تم تیره پروژه.
* react-select برای هر اسلات تابع می‌خواهد، پس همه با تابع برگردانده می‌شوند.
*/
function makeClassNames<IsMulti extends boolean>(
extras?: Partial<LuxeClassNames<IsMulti>>,
): LuxeClassNames<IsMulti> {
return {
control: (state) => CONTROL_CLASS_NAMES(state.isFocused),
option: (state) => OPTION_CLASS_NAMES(state.isSelected, state.isFocused),
valueContainer: () => '!gap-1 !px-3 !py-0.5',
placeholder: () => '!text-[13px] !text-ivory/35',
singleValue: () => '!text-[13px] !text-ivory',
input: () => '!text-[13px] !text-ivory',
indicatorsContainer: () => '!text-ivory/40',
dropdownIndicator: () => '!text-ivory/40 hover:!text-copper',
clearIndicator: () => '!text-ivory/40 hover:!text-copper',
indicatorSeparator: () => '!bg-ivory/15',
menu: () => '!mt-1 !rounded-none !border !border-ivory/10 !bg-char !shadow-lift',
menuList: () => '!p-1',
noOptionsMessage: () => '!text-xs !text-ivory/45',
loadingMessage: () => '!text-xs !text-ivory/45',
multiValue: () => '!m-0.5 !rounded-full !border !border-copper/40 !bg-copper/15 !py-0.5 !pe-0 !ps-2',
multiValueLabel: () => '!text-xs !text-copper',
...extras,
}
}
/** دکمه ضربدر حذف برچسب انتخاب‌شده — همیشه واضح و قابل کلیک */
function TagRemoveButton({ innerProps }: MultiValueRemoveProps<LuxeOption, true>) {
return (
<div
{...innerProps}
role="button"
aria-label="حذف برچسب"
title="حذف برچسب"
className="mx-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full text-copper transition-colors duration-150 hover:bg-copper hover:text-char"
>
<XIcon className="h-3 w-3" />
</div>
)
}
const portalTarget = (): HTMLElement | undefined =>
typeof document === 'undefined' ? undefined : document.body
const uniqueOptions = (values: string[]): LuxeOption[] =>
[...new Set(values.filter(Boolean))].map((item) => ({ label: item, value: item }))
type LuxeSelectProps = {
value: string
options: string[]
onChange: (value: string) => void
placeholder?: string
isClearable?: boolean
isDisabled?: boolean
inputId?: string
}
/** react-select تکی با تم تیره — گزینه فعلی حتی اگر در لیست نباشد حفظ می‌شود */
export function LuxeSelect({
value,
options,
onChange,
placeholder = 'انتخاب کنید…',
isClearable = true,
isDisabled = false,
inputId,
}: LuxeSelectProps) {
const selectOptions = useMemo(
() => uniqueOptions([...options, ...(value ? [value] : [])]),
[options, value],
)
return (
<Select<LuxeOption, false>
inputId={inputId}
classNamePrefix="luxe"
options={selectOptions}
value={selectOptions.find((option) => option.value === value) ?? null}
onChange={(option) => onChange(option?.value ?? '')}
placeholder={placeholder}
isClearable={isClearable}
isDisabled={isDisabled}
noOptionsMessage={() => 'موردی یافت نشد'}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
type LuxeCreatableSelectProps = {
value: string
options: string[]
onChange: (value: string) => void
placeholder?: string
inputId?: string
/** متن گزینه «ساخت مقدار جدید» در فهرست بازشو */
createLabel?: (input: string) => string
}
/** react-select تکی و قابل ساخت — کاربر با کلیک روی گزینه ساخت، مقدار تازه می‌سازد */
export function LuxeCreatableSelect({
value,
options,
onChange,
placeholder = 'انتخاب یا نوشتن مورد جدید…',
inputId,
createLabel = (input) => `افزودن «${input}»`,
}: LuxeCreatableSelectProps) {
const selectOptions = useMemo(
() => uniqueOptions([...options, ...(value ? [value] : [])]),
[options, value],
)
return (
<CreatableSelect<LuxeOption, false>
inputId={inputId}
classNamePrefix="luxe"
options={selectOptions}
value={selectOptions.find((option) => option.value === value) ?? null}
onChange={(option) => onChange(option?.value?.trim() ?? '')}
placeholder={placeholder}
formatCreateLabel={(input) => createLabel(input)}
noOptionsMessage={() => 'موردی یافت نشد'}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
isClearable
/>
)
}
export type CategoryOption = { label: string; value: string }
/** کلید نگهداری دسته‌های ساخته‌شده در مرورگر */
const CREATED_CATEGORIES_KEY = 'aramesh_article_categories'
/** دسته‌هایی که ادمین ساخته است — برای مقاله‌های بعدی هم در فهرست می‌مانند */
function readCreatedCategories(): string[] {
try {
const raw = window.localStorage.getItem(CREATED_CATEGORIES_KEY)
const parsed: unknown = raw ? JSON.parse(raw) : []
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: []
} catch {
return []
}
}
function storeCreatedCategory(category: string): void {
try {
const current = readCreatedCategories()
if (current.includes(category)) return
window.localStorage.setItem(CREATED_CATEGORIES_KEY, JSON.stringify([...current, category]))
} catch {
/* حافظه مرورگر در دسترس نیست — بی‌صدا رد شو */
}
}
type CategorySelectProps = {
/** مقدار انتخاب‌شده — یک شیء {value,label} یا null */
value: CategoryOption | null
/** تغییر انتخاب — null یعنی فقط انتخاب پاک شده است (دکمه X) */
onChange: (option: CategoryOption | null) => void
/** دسته‌های پایه */
options: string[]
/** متن گزینه «ساخت دسته جدید» در فهرست بازشو */
createLabel?: (input: string) => string
}
/**
* انتخابگر تک‌مقداری دسته‌بندی (react-select Creatable).
* - isMulti=false و isClearable: دکمه X فقط انتخاب جاری را پاک می‌کند و گزینه‌ها دست‌نخورده می‌مانند.
* - ساخت دسته جدید: به انتهای فهرست گزینه‌ها اضافه و برای مقاله جاری انتخاب می‌شود و
* در همین مرورگر برای مقاله‌های بعدی نگه داشته می‌شود.
*/
export function CategorySelect({
value,
onChange,
options,
createLabel = (input) => `ایجاد دسته جدید: ${input}`,
}: CategorySelectProps) {
const [createdOptions, setCreatedOptions] = useState<string[]>(() => readCreatedCategories())
// گزینه‌های پایه + دسته‌های ساخته‌شده (همیشه در انتهای فهرست)
const selectOptions = useMemo(
() => uniqueOptions([...options, ...createdOptions.filter((item) => !options.includes(item))]),
[options, createdOptions],
)
/** ساخت دسته جدید: افزودن به انتهای فهرست + انتخاب خودکار برای مقاله جاری */
const handleCreate = (input: string) => {
const category = input.trim()
if (!category) return
setCreatedOptions((previous) =>
previous.includes(category) ? previous : [...previous, category],
)
storeCreatedCategory(category)
onChange({ label: category, value: category })
}
return (
<CreatableSelect<CategoryOption, false>
classNamePrefix="luxe"
isMulti={false}
isClearable
options={selectOptions}
value={value}
onChange={(option) => onChange(option ? { label: option.label, value: option.value } : null)}
onCreateOption={handleCreate}
formatCreateLabel={(input) => createLabel(input)}
placeholder="انتخاب یا نوشتن دسته جدید…"
noOptionsMessage={() => 'موردی یافت نشد'}
classNames={makeClassNames<false>()}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
type LuxeTagsSelectProps = {
value: string[]
onChange: (value: string[]) => void
placeholder?: string
}/** هشتگ‌ها با react-select قابل ساخت — هر برچسب دکمه ضربدر حذف مستقل دارد */
export function LuxeTagsSelect({
value,
onChange,
placeholder = 'هشتگ بنویسید و Enter بزنید…',
}: LuxeTagsSelectProps) {
const options = useMemo(() => uniqueOptions(value), [value])
/** پاک‌سازی و حذف تکراری‌ها هنگام افزودن یا حذف برچسب */
const applyTags = (next: readonly LuxeOption[]) => {
const tags = next.map((option) => option.value.trim()).filter(Boolean)
onChange([...new Set(tags)])
}
return (
<CreatableSelect<LuxeOption, true>
classNamePrefix="luxe"
isMulti
options={options}
value={options}
onChange={applyTags}
placeholder={placeholder}
formatCreateLabel={(input) => `افزودن هشتگ «${input}»`}
noOptionsMessage={() => 'هشتگی یافت نشد'}
closeMenuOnSelect={false}
components={{ MultiValueRemove: TagRemoveButton }}
classNames={makeClassNames<true>({ multiValueRemove: () => '!bg-transparent' })}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 200 }) }}
menuPosition="fixed"
menuPortalTarget={portalTarget()}
/>
)
}
@@ -0,0 +1,137 @@
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
slug: string
excerpt: string
body: ArticleBodyDoc | string[]
keyphrase: string
}
type Status = 'good' | 'ok' | 'bad'
type Check = { label: string; status: Status; hint: string }
// ---------- محاسبه بررسی‌ها ----------
export function useSeoChecks(input: SeoInput): Check[] {
return useMemo(() => {
const text = docToText(input.body)
const words = text.split(/\s+/).filter(Boolean)
const kp = input.keyphrase.trim()
const has = (s: string, needle: string) => s.includes(needle)
const checks: Check[] = []
// عنوان
const tLen = input.title.trim().length
if (tLen === 0) checks.push({ label: 'عنوان مقاله', status: 'bad', hint: 'برای مقاله عنوان بنویسید.' })
else if (tLen >= 20 && tLen <= 60) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'good', hint: 'طول عنوان مناسب است.' })
else if (tLen >= 10 && tLen <= 70) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی عنوان بین ۲۰ تا ۶۰ نویسه است.' })
else checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'bad', hint: 'طول عنوان خارج از بازه ۱۰ تا ۷۰ نویسه است.' })
// کلمه کلیدی هدف
if (!kp) {
checks.push({ label: 'کلمه کلیدی هدف', status: 'ok', hint: 'کلمه کلیدی هدف را بنویسید تا حضور آن در متن بررسی شود.' })
} else {
checks.push(
has(input.title, kp)
? { label: 'کلمه کلیدی در عنوان', status: 'good', hint: 'کلمه کلیدی در عنوان هست.' }
: { label: 'کلمه کلیدی در عنوان', status: 'bad', hint: `کلمه «${kp}» در عنوان نیست.` },
)
const firstP = firstParagraph(input.body)
checks.push(
has(firstP, kp)
? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' }
: { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' },
)
const h2s = docHeadings(input.body, 2)
if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' })
else
checks.push(
h2s.some((h) => has(h, kp))
? { label: 'کلمه کلیدی در تیترها', status: 'good', hint: 'کلمه کلیدی در یکی از تیترها هست.' }
: { label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'کلمه کلیدی در هیچ تیتر H2 نیست.' },
)
}
// چکیده (توضیح متا)
const eLen = input.excerpt.trim().length
if (eLen === 0) checks.push({ label: 'چکیده (توضیح متا)', status: 'bad', hint: 'چکیده بنویسید — روی کارت مقاله و توضیح متای گوگل نمایش داده می‌شود.' })
else if (eLen >= 70 && eLen <= 160) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'good', hint: 'طول چکیده برای توضیح متا ایده‌آل است.' })
else if (eLen >= 40 && eLen <= 200) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی چکیده بین ۷۰ تا ۱۶۰ نویسه است.' })
else checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'bad', hint: 'چکیده خیلی کوتاه یا بلندتر از ۲۰۰ نویسه است.' })
// طول متن
if (words.length >= 150) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'good', hint: 'طول متن برای محتوای ارزشمند کافی است.' })
else if (words.length >= 80) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'ok', hint: 'متن کمی کوتاه است — حداقل ۱۵۰ واژه پیشنهاد می‌شود.' })
else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' })
// تیتر H2
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 بخش‌بندی کنید.' })
// تصاویر
const imgs = docImages(input.body)
if (imgs.length === 0)
checks.push({ label: 'تصاویر', status: 'ok', hint: 'تصویری داخل متن نیست — افزودن تصویر به جذابیت مقاله کمک می‌کند.' })
else if (imgs.every((i) => i.alt.trim()))
checks.push({ label: `تصاویر (${imgs.length} تصویر با متن جایگزین)`, status: 'good', hint: 'همه تصاویر متن جایگزین دارند.' })
else
checks.push({ label: 'تصاویر', status: 'bad', hint: 'برای همه تصاویر، متن جایگزین (alt) بنویسید.' })
// لینک‌ها
const links = docLinks(input.body)
if (links.length > 0) checks.push({ label: `پیوندها (${links.length} لینک)`, status: 'good', hint: 'متن پیوند دارد.' })
else checks.push({ label: 'پیوندها', status: 'ok', hint: 'یک لینک مرتبط (داخلی به پروژه/خدمات یا خارجی) اضافه کنید.' })
// نشانی
if (/^article-\d+$/.test(input.slug))
checks.push({ label: 'نشانی (slug)', status: 'bad', hint: 'نشانی پیش‌فرض را به یک نشانی خوانا (انگلیسی) تغییر دهید.' })
else if (input.slug.length > 96)
checks.push({ label: 'نشانی (slug)', status: 'ok', hint: 'نشانی خیلی بلند است — کوتاهش کنید.' })
else
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
return checks
}, [input])
}
const STATUS_STYLE = {
good: { icon: CheckCircle2Icon, color: 'text-emerald-400' },
ok: { icon: AlertCircleIcon, color: 'text-amber-400' },
bad: { icon: XCircleIcon, color: 'text-red-400' },
} as const
/** پنل بررسی سئو — فهرست سبز/نارنجی/قرمز به سبک یوست، سازگار با فارسی */
export function SeoChecklist({ input }: { input: SeoInput }) {
const checks = useSeoChecks(input)
const good = checks.filter((c) => c.status === 'good').length
return (
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="بررسی سئو">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-copper">بررسی سئو</h2>
<span className="text-xs font-light text-ivory/50">
{good} از {checks.length} مورد سبز
</span>
</div>
<ul className="mt-4 grid gap-2.5">
{checks.map((c) => {
const S = STATUS_STYLE[c.status]
return (
<li key={c.label} className="flex items-start gap-3">
<S.icon className={`mt-1 h-4 w-4 shrink-0 ${S.color}`} aria-hidden="true" />
<div>
<p className="text-sm font-light text-ivory/85">{c.label}</p>
<p className="text-xs font-light leading-6 text-ivory/45">{c.hint}</p>
</div>
</li>
)
})}
</ul>
</section>
)
}
@@ -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 }
}
+36 -14
View File
@@ -1,31 +1,51 @@
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();
// پیش‌نویس‌ها فقط در صفحه وبلاگ (برای ادمین) دیده می‌شوند، نه در خانه
const published = articles.filter((a) => a.status !== 'draft');
const saveHeading = (field: 'label' | 'title' | 'lead' | 'cta_label', value: string) =>
save('section-headings', heading.id, { [field]: value }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id);
if (h) (h as Record<string, unknown>)[field] = value;
});
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">
<p className="text-sm font-light text-copper">{heading.label}</p>
<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>
<h2 id="articles-title" className="mt-5 text-section font-bold text-ivory">
{heading.title}
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
</h2>
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
{heading.lead}
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
</p>
<div className="mt-6 h-px w-24 bg-copper/70" />
</Reveal>
<div className="mt-14 grid gap-6 lg:grid-cols-3">
{articles.slice(0, 3).map((article, i) =>
<Reveal key={article.slug} delay={0.06 * i} className="h-full">
<ArticleCard article={article} />
{published.slice(0, 3).map((article, i) =>
<Reveal key={article.id} delay={0.06 * i} className="h-full">
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<ArticleCard article={article} />
</EditableItem>
</Reveal>
)}
</div>
@@ -33,14 +53,16 @@ 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">
{heading.cta_label}
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
<PremiumButton to={href(heading.cta_href)} className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
</PremiumButton>
</div>
</Reveal>
)}
<div className="mt-6">
<AddItemButton resource="articles" />
</div>
</div>
</section>);
}
}
+8 -1
View File
@@ -3,9 +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)
@@ -54,6 +57,10 @@ export function BeforeAfter() {
align="center"
/>
<div className="flex justify-start">
<SingletonPill resource="before_after" label="ویرایش تصاویر و برچسب‌های قبل/بعد" />
</div>
<Reveal delay={0.1}>
<div
ref={containerRef}
@@ -97,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)}
+51 -41
View File
@@ -3,7 +3,9 @@ import { AnimatePresence, motion } from 'framer-motion'
import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite } from '../../api'
import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable'
type FaqProps = {
hideHeading?: boolean;
@@ -11,65 +13,73 @@ type FaqProps = {
export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite()
const heading = useSectionHeading('faq')
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={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"}>
<div className="mx-auto max-w-site px-4 md:px-8 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={heading.title}
lead={heading.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
return (
<Reveal key={faq.question} delay={0.04 * i}>
<li className="border-b border-ivory/10">
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
</motion.div>
)}
</AnimatePresence>
</li>
<Reveal key={faq.id} delay={0.04 * i}>
<EditableItem resource="faqs" item={faq}>
<li className="border-b border-ivory/10">
<button
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
</span>
<PlusIcon
className={`h-5 w-5 shrink-0 text-copper transition-transform duration-300 ease-lux ${
isOpen ? 'rotate-45' : ''
}`}
/>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
</motion.div>
)}
</AnimatePresence>
</li>
</EditableItem>
</Reveal>
)
})}
</ul>
<div className="mt-8">
<AddItemButton resource="faqs" />
</div>
</div>
</div>
</section>
)
}
}
@@ -1,89 +1,130 @@
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 type { ProjectStatus } from '../../data/site'
import { projectsData } from '../../data/projectsData'
export function FeaturedProjects() {
const { projects } = useSite()
const { status_labels } = useSite()
const { t, href } = useLang()
const heading = useSectionHeading('featured_projects')
const doneLabel = useStatusLabel('done')
const progressLabel = useStatusLabel('progress')
const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key)
if (!label) return
void save('status-labels', label.id, { label: value }, (d) => {
const s = d.status_labels.find((x) => x.id === label.id)
if (s) s.label = value
})
}
const FILTERS: { key: ProjectStatus; label: string }[] = [
{ key: 'done', label: doneLabel },
{ key: 'progress', label: progressLabel },
{ 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">
<div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} />
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
<div className="mt-10 flex flex-wrap items-center gap-3" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter) => (
<button
key={filter.key}
type="button"
role="tab"
aria-selected={status === filter.key}
onClick={() => setStatus(filter.key)}
className={`group relative overflow-hidden border px-7 py-3 text-sm transition-colors duration-300 ease-out ${
status === filter.key
? 'border-copper bg-copper font-bold text-char'
: 'border-ivory/15 font-light text-ivory/65'
}`}
>
{status !== filter.key && (
<span
aria-hidden="true"
className="absolute inset-0 translate-y-full bg-copper transition-transform duration-300 ease-out group-hover:translate-y-0"
/>
)}
<span className="relative z-10 transition-colors duration-300 ease-out group-hover:text-char">
{filter.label}
</span>
</button>
))}
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */}
<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" />
)}
<button
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => setStatus(filter.key)}
className={`group relative rounded-lg px-6 py-2.5 text-sm font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md ${
isSelected
? 'bg-copper/15 text-copper shadow-[0_4px_15px_rgba(197,143,92,0.1)]'
: 'bg-transparent text-ivory/60 hover:bg-white/5 hover:text-ivory'
}`}
>
<span className="relative z-10 transition-colors duration-300 ease-out">
<InlineEdit
value={filter.label}
onSave={(v) => saveStatusLabel(filter.key, v)}
/>
</span>
</button>
</React.Fragment>
)
})}
</div>
<div className="mt-10 min-h-[420px]">
<AnimatePresence mode="wait">
<motion.div
key={status}
key={status}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
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.slug}
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} />
</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>
{/* دکمه مشاهده همه پروژه‌ها */}
{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">
{heading.cta_label}
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
<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 as unknown as Record<string, unknown>)["cta_label"] = v
})
}
/>
</PremiumButton>
</div>
</Reveal>
)}
+83 -55
View File
@@ -3,11 +3,13 @@ import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite } from '../../api'
import { useSite } from '../../api'
import { useLang, formatNumber } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Gallery() {
const { gallery } = useSite()
const heading = useSectionHeading('gallery')
const { t, lang } = useLang()
const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), [])
@@ -30,90 +32,116 @@ 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={heading.title}
lead={heading.lead}
title={t('fallbackGalleryTitle')}
lead={t('fallbackGalleryLead')}
headingKey="gallery"
align="center"
/>
<div className="mt-14 grid grid-cols-2 gap-4 lg:grid-cols-3">
{gallery.map((image, i) => (
<Reveal key={image.id} delay={0.04 * i}>
<button
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')}`}
>
<img
src={image.src}
alt={image.alt}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
<EditableItem resource="gallery" item={image}>
<button
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={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
>
<img
src={image.src}
alt={image.alt}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/>
</button>
</EditableItem>
</Reveal>
))}
<AddItemButton resource="gallery" />
</div>
</div>
<AnimatePresence>
{active !== null && (
{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>
</section>
)
}
}
+57 -45
View File
@@ -1,36 +1,33 @@
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])
const contentY = useTransform(scrollY, [0, 700], [0, -55])
const overlay = useTransform(scrollY, [0, 600], [0.5, 0.78])
// واژه highlight در میانه تیتر با رنگ مسی رندر می‌شود
const renderTitle = () => {
if (hero.title_highlight && hero.title.includes(hero.title_highlight)) {
const [before, ...rest] = hero.title.split(hero.title_highlight)
return (
<>
{before}
<span className="text-copper">{hero.title_highlight}</span>
{rest.join(hero.title_highlight)}
</>
)
}
return hero.title
}
const saveHero = (field: string, value: unknown) =>
save('hero', hero.id, { [field]: value }, (d) => {
;(d.hero as unknown as Record<string, unknown>)[field] = value
})
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"
/>
@@ -44,73 +41,88 @@ export function Hero() {
aria-hidden="true"
/>
{/* محتوای اصلی هیرو */}
<motion.div
style={{ y: contentY }}
className="relative mx-auto flex h-full max-w-site flex-col justify-center px-6 lg:px-10"
>
<div className="w-full max-w-[700px] lg:max-w-[58%]">
{/* کیکر بالای تیتر */}
<motion.p
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper"
className="text-sm font-light text-copper tracking-wide"
>
{hero.kicker}
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
</motion.p>
{/* تیتر اصلی */}
<motion.h1
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory"
className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
>
{renderTitle()}
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
</motion.h1>
{/* زیرتیتر */}
<motion.p
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }}
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/70"
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
>
{hero.subtitle}
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
</motion.p>
{/* داک دکمه‌ها: استایل کپسولی، شیشه‌ای و دایره‌ای */}
<motion.div
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
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">
{hero.cta_primary_label}
<ArrowLeftIcon className="h-4 w-4" />
</ButtonLink>
<ButtonLink to={hero.cta_secondary_href} variant="outline" className="font-light">
{hero.cta_secondary_label}
</ButtonLink>
{/* دکمه اصلی */}
<PremiumButton to={href(hero.cta_primary_href)}>
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</PremiumButton>
{/* دکمه ثانویه */}
<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)} />
</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-60 border border-ivory/15 bg-char/60 p-6 backdrop-blur-md lg:block lg:left-10"
>
<p className="text-3xl font-bold text-copper">{hero.badge_value}</p>
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">{hero.badge_label}</p>
</motion.div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */}
</motion.div>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
<a
href="#story"
aria-label="رفتن به بخش بعدی"
className="absolute bottom-8 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/50 transition-colors duration-200 ease-lux hover:text-copper"
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-xs font-light">{hero.scroll_label}</span>
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
<InlineEdit value={hero.scroll_label} onSave={(v) => saveHero('scroll_label', v)} />
</span>
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-ivory/20 bg-char/40 backdrop-blur-md transition-all duration-300 group-hover:border-copper group-hover:bg-char/70 group-hover:shadow-lg group-hover:shadow-copper/20">
<ChevronDownIcon className="h-4 w-4 animate-bounce text-ivory group-hover:text-copper" />
</div>
</a>
{/* کنترل‌های ادمین */}
<div className="absolute left-6 top-24 z-20 flex flex-col gap-2 lg:left-10">
<SingletonPill resource="hero" label="تنظیمات هیرو (واژه مسی، دکمه‌ها)" />
<SingletonPill
resource="seo-meta"
label="عنوان و توضیح سئوی صفحه اصلی"
select={(d) => d.seo_meta.find((s) => s.page_key === 'home')}
/>
</div>
</section>
)
}
}
+49 -24
View File
@@ -1,17 +1,28 @@
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'
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')
const [sending, setSending] = useState(false)
const saveContent = (field: string, value: string) =>
save('newsletter', newsletter.id, { [field]: value }, (d) => {
;(d.newsletter as unknown as Record<string, unknown>)[field] = value
})
const showErrorAlert = (title: string, text: string) => {
Swal.fire({
icon: 'warning',
@@ -20,14 +31,14 @@ export function Newsletter() {
background: '#1e1a16',
color: '#F5F3EF',
iconColor: '#d5874c',
confirmButtonText: 'متوجه شدم',
confirmButtonText: t('understood'),
buttonsStyling: false,
customClass: {
container: 'backdrop-blur-[8px] bg-ink/60',
popup: 'font-pinar border border-ivory/10 rounded-none shadow-lift',
container: 'backdrop-blur-md bg-ink/70',
popup: 'font-pinar border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl bg-char/95',
title: 'font-pinar font-bold text-lg text-ivory pt-2',
htmlContainer: 'font-pinar font-light text-sm leading-8 text-ivory/70',
confirmButton: 'mt-6 bg-copper text-char font-bold px-10 py-3 transition-transform duration-300 ease-lux hover:scale-105',
confirmButton: 'mt-6 rounded-full bg-copper text-char font-bold px-10 py-3 transition-all duration-300 ease-lux hover:scale-105 hover:shadow-[0_0_20px_rgba(197,143,92,0.5)]',
},
showClass: {
popup: 'swal2-show',
@@ -49,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
}
@@ -71,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
}
@@ -80,26 +91,32 @@ export function Newsletter() {
setEmail('')
} catch {
setStatus('error')
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
} finally {
setSending(false)
}
}
return (
<section className="bg-copper py-16" aria-labelledby="newsletter-title">
<div className="mx-auto grid max-w-site items-center gap-8 px-6 lg:grid-cols-2 lg:px-10">
<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" />
{/* هاله نور پس‌زمینه برای عمق بصری */}
<div className="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-black/15 blur-3xl" />
<div className="relative mx-auto grid max-w-site items-center gap-10 px-6 lg:grid-cols-2 lg:px-10">
<Reveal>
<h2 id="newsletter-title" className="text-2xl font-bold leading-relaxed text-char md:text-3xl">
{newsletter.title}
<InlineEdit value={newsletter.title} onSave={(v) => saveContent('title', v)} />
</h2>
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/75">
{newsletter.lead}
<p className="mt-3 max-w-md text-sm font-light leading-8 text-char/80">
<InlineEdit multiline value={newsletter.lead} onSave={(v) => saveContent('lead', v)} />
</p>
</Reveal>
<Reveal delay={0.08}>
<form onSubmit={submit} className="flex flex-col gap-3 sm:flex-row" noValidate>
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
<div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only">
{newsletter.placeholder}
@@ -114,21 +131,29 @@ export function Newsletter() {
if (status !== 'idle') setStatus('idle')
}}
placeholder={newsletter.placeholder}
className="w-full border border-transparent bg-char px-5 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 transition-all duration-300 ease-lux focus:border-ivory focus:outline-none"
className="w-full rounded-full border border-black/15 bg-char/90 px-6 py-4 text-left text-sm font-light text-ivory placeholder:text-ivory/40 shadow-[inset_0_2px_4px_rgba(0,0,0,0.35)] backdrop-blur-xl transition-all duration-300 ease-lux focus:border-ivory focus:bg-char focus:shadow-[0_0_20px_rgba(255,255,255,0.25)] focus:outline-none"
aria-invalid={status === 'error'}
/>
{status === 'done' && (
<p className="mt-2 flex items-center gap-1.5 text-xs font-light text-char" role="status">
<CheckIcon className="h-3.5 w-3.5" />
<p className="mt-2.5 flex items-center gap-1.5 px-3 text-xs font-medium text-char" role="status">
<CheckIcon className="h-4 w-4" />
{newsletter.success_text}
</p>
)}
</div>
<Button type="submit" variant="dark-ivory" disabled={sending}>
<SendIcon className="h-4 w-4" />
{sending ? newsletter.sending_label : newsletter.button_label}
</Button>
<button
type="submit"
disabled={sending}
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"
>
<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>
</form>
</Reveal>
</div>

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