102 Commits
Author SHA1 Message Date
aramesh-site deploy 353d4639b4 feat(brand): لوگوی اختصاصی نسخهٔ انگلیسی — ستون logo_en، میگریشن ۰۰۰۳، لوکالایز و فیلد پنل (کد از قبل در تولید اجرا می‌شد)
CI / tests (push) Canceled after 0s
CI / images (push) Canceled after 0s
2026-10-09 19:36:09 +00:00
aramesh-site deploy dfbb61f75f fix(projects): فلش پیمایش به بیرون کادر روی لبهٔ بیرونی منتقل شد (آینهٔ هم، متن داخل کادر راست‌چین) 2026-10-09 19:31:56 +00:00
aramesh-site deploy c1d7f61a45 fix(projects): فلش پیمایش روی لبهٔ بیرونی هر کارت و آینهٔ هم — قبلی رو به راست، بعدی رو به چپ 2026-10-09 19:25:52 +00:00
aramesh-site deploy c200159afd fix(projects): «قبلی» = قدیمی‌تر و «بعدی» = تازه‌تر (فهرست نزولی سال)؛ فلش‌ها فقط آینهٔ سادهٔ هم 2026-10-09 19:20:22 +00:00
aramesh-site deploy ebc97e03a4 fix(projects): فلش‌های پیمایش آینهٔ هم شدند — قبلی رو به شروع، بعدی رو به ادامه 2026-10-09 19:14:54 +00:00
aramesh-site deploy b088f68fd7 feat(projects): پیمایش پروژهٔ قبلی/بعدی در صفحهٔ هر پروژه (حلقه‌ای، دوزبانه) — کاوش بدون بازگشت به فهرست 2026-10-09 19:08:41 +00:00
aramesh-site deploy 1ceb7cb156 feat(hero): نشان «پروژه فعال الیسیان» به صفحهٔ همان پروژه وصل شد (تطبیق دو‌زبانهٔ نام با نگاشت Elysian→الیسیان) 2026-10-09 18:57:46 +00:00
aramesh-site deploy e571b19f4a fix(footer): آیکون بله = تیک (CheckIcon) — به‌جای نشان اپ 2026-10-09 18:51:32 +00:00
aramesh-site deploy bcd2b756ed feat(footer): آیکون رسمی «بله» — نشان اپ از bale.ai، چون هیچ مجموعه‌آیکونی آن را ندارد 2026-10-09 18:46:07 +00:00
aramesh-site deploy e8010b1e79 fix(contact): حذف «نیاز به گفتگوی فوری دارید؟» از فرم موبایل — متن هاردکد فارسی بود و در /en هم فارسی دیده می‌شد 2026-10-09 18:32:54 +00:00
aramesh-site deploy 7c2eeda232 fix(mobile): توضیح کارت‌های تخصص تیم ما روی موبایل کامل نمایش داده می‌شود — حذف line-clamp که با «...» می‌برید 2026-10-09 18:23:14 +00:00
aramesh-site deploy 7e4605d2b7 chore: خاموش‌کردن موقت چت‌بات — با یک کامنت برای روشن‌کردن دوباره 2026-10-09 18:19:17 +00:00
aramesh-site deploy 7d78a1fc58 fix(map): هم‌گام‌سازی بوم با ResizeObserver و کف ارتفاع ظرف — در حالت موبایل/DevTools پس از تغییر viewport نقشه ناقص و خالی می‌ماند 2026-10-09 18:10:27 +00:00
aramesh-site deploy 324298c9d7 fix(map): مقاوم‌سازی راه‌اندازی نقشه برای موبایل — پیش‌سنجی WebGL، antialias فقط در صورت پشتیبانی، و خطای روشن به‌جای قاب خالی 2026-10-09 18:01:47 +00:00
aramesh-site deploy 2d1c670715 fix(map): CSS پایهٔ مپ‌لیبر به شیت اصلی منتقل شد — روی موبایل چانک تنبل دیر می‌رسید و ظرف نقشه با ارتفاع صفر نامرئی می‌ماند 2026-10-09 17:58:22 +00:00
aramesh-site deploy 450d5dd239 fix(map): زوم‌اوت آزاد در نقشهٔ صفحهٔ پروژه — حذف maxBounds که جابه‌جایی را به محدودهٔ پروژه می‌بست 2026-10-09 17:46:11 +00:00
aramesh-site deploy f0f483a9d1 feat(map): پیوند آدرس پروژه از نشان به گوگل‌مپ — قالب رسمی Maps URLs با مختصات (?api=1&query=lat,lng)، بی‌نیاز از کلید API 2026-10-09 17:41:07 +00:00
aramesh-site deploy 00abef23df fix(map): پاپ‌آپ نقشهٔ اصلی هم فقط گالری را نشان می‌دهد — بدون تکرار تصویر شاخص و بدون قاب خالی 2026-10-09 17:33:49 +00:00
aramesh-site deploy 29dc8459f7 fix(map): پاپ‌آپ نقشهٔ صفحهٔ پروژه تصویر شاخص را تکرار نمی‌کند — فقط گالری؛ در نبود گالری قاب تصویر پنهان می‌شود 2026-10-09 17:27:27 +00:00
aramesh-site deploy afcace4fa3 feat(projects): نقشهٔ مختصات پروژه زیر هیرو در صفحهٔ هر پروژه — سنجاق دقیق با پاپ‌آپ تصاویر و پیوند نشان 2026-10-09 17:21:38 +00:00
aramesh-site deploy 2ca30178ed feat(map): افزودن لکسون، ارغوانیه و ابوریحان؛ هم‌نام‌سازی سنجاق‌های تغییرنام‌یافته (زنیث/الیسیان/آوالون) و حذف میرزاطاهر ۶ 2026-10-09 17:11:49 +00:00
aramesh-site deploy 40ec3d406b fix(admin): صفحهٔ ویرایش پروژه با body/body_en غیرآرایه سفید می‌شد — asLines همهٔ مقادیر را امن می‌کند (رفع پروژه‌های تازه‌ساخته) 2026-10-09 17:00:20 +00:00
aramesh-site deploy b801867611 feat(projects): صفحه‌بندی شماره‌دار به‌جای «مشاهده بیشتر» — ۱۲ پروژه در هر صفحه، با برگشت نگاه به ابتدای فهرست 2026-10-09 16:52:50 +00:00
aramesh-site deploy 3a72752598 fix(home): نام‌های تازهٔ پروژه‌های شاخص پس از تغییر نام (زنیث، الیسیان، آوالون) 2026-10-09 16:42:01 +00:00
aramesh-site deploy b2af06397a fix(team): کارت‌های تیم در دسکتاپ هم‌اندازه — مدیرعامل تنها بالا، دو نفر ردیف بعد (مثل موبایل) 2026-10-09 16:12:09 +00:00
aramesh-site deploy 838cc01190 feat(team): مدیرعامل تنها در ردیف اول و دو نفر دیگر در ردیف بعد — چیدمان سه‌نفره متعادل در همهٔ اندازه‌ها 2026-10-09 16:05:24 +00:00
aramesh-site deploy adedb9ea41 merge(mobile): ادغام شاخهٔ new-one2 — طراحی ریسپانسیو صفحهٔ اصلی (کلاس‌های breakpoint در همهٔ بخش‌ها) با حفظ تغییرات طراحی ما: تیم وسط‌چین بدون دایره، پروژه‌های شاخص بدون تب، تناوب پس‌زمینه (tone)، پس‌زمینهٔ پیوستهٔ تماس و منطق دوزبانهٔ مقالات 2026-10-09 15:58:57 +00:00
aramesh-site deploy e5fc0e1705 feat(hero): نشان پروژهٔ فعال وسط‌چین، بزرگ‌تر و متناسب با کادر (کامپوننت اختصاصی با فاصلهٔ یکنواخت آیکون/متن) 2026-10-09 15:46:01 +00:00
aramesh-site deploy e2f6853c2c feat(design): بازگرداندن ریتم تناوب پس‌زمینهٔ بخش‌ها — هر صفحه با بخش سیاه شروع و سیاه/خاکستری یکی‌درمیان ادامه می‌یابد (پراپ tone برای بخش‌های مشترک چندصفحه‌ای) 2026-10-09 15:26:56 +00:00
aramesh-site deploy 9bf2a30f8d feat(investment): بخش مدل‌های همکاری وسط‌چین با طراحی مراحل فرآیند — بدون شماره و بدون فهرست گلوله‌ای 2026-10-09 15:17:18 +00:00
aramesh-site deploy ad151bbae7 fix(blog): حذف دایرهٔ حرف اول از سربرگ نویسندهٔ مقاله 2026-10-09 15:08:29 +00:00
aramesh-site deploy 8865149aab feat(services): جابه‌جایی ترتیب — «فرآیند کار ما» پیش از «تبحر تیم ما» در صفحهٔ خدمات 2026-10-09 15:04:26 +00:00
aramesh-site deploy 879481df9f fix(home): حذف دایره‌های حرف اول از کارت‌های تیم 2026-10-09 14:58:37 +00:00
aramesh-site deploy 2c58978c78 feat(home): کارت‌های تیم وسط‌چین — سه کارت هم‌عرض در مرکز ردیف (بدون تصویر پرتره) 2026-10-09 14:57:44 +00:00
aramesh-site deploy 3c17bd5081 feat(home): پروژه‌های شاخص بدون تب وضعیت — سه کارت ثابت (بابک، میرزاطاهر ۳، میرزا طاهر ۵) 2026-10-09 14:49:42 +00:00
aramesh-site deploy adcb288064 feat(home): نقل‌قول مدیرعامل در بخش دربارهٔ صفحهٔ اصلی — همان نقل‌قول صفحهٔ درباره ما 2026-10-09 14:42:47 +00:00
aramesh-site deploy 016b4074d8 fix(contact): حذف پردهٔ خاکستری روی تصویر — فقط گرادیان خوانایی می‌ماند؛ لایه‌های هیرو در حالت پس‌زمینهٔ پیوسته خاموش 2026-10-09 14:33:11 +00:00
aramesh-site deploy 5e2d5dfabb feat(contact): یک تصویر پیوسته برای کل صفحهٔ تماس — هیرو و فرم روی همان پس‌زمینه؛ حذف تکرار عکس (هوم دست‌نخورده) 2026-10-09 14:27:30 +00:00
aramesh-site deploy 9759ab2698 feat(timeline): خط روی دایره تمام می‌شود و کارت پایانی زیر آن می‌نشیند — لبهٔ بالای کارت دقیقاً روی نقطهٔ پایان خط (اندازه‌گیری DOM برای هر محتوا/اندازه) 2026-10-07 18:20:14 +00:00
aramesh-site deploy 82e33fb0e0 fix(timeline): پر شدن خط با ورود کارت پایانی به دید کامل می‌شود — دیگر هیچ‌وقت بالای کارت معلق نمی‌ماند؛ انتهای گرادیان پررنگ‌تر 2026-10-07 12:32:46 +00:00
aramesh-site deploy 9dab4f929c fix(blog): تاریخ/زمان مطالعه کارت‌ها در /en انگلیسی شد؛ کرسر فیلدهای تصویر دیگر به آخر متن نمی‌پرد (مقدار محلی با همگام‌سازی فوکوس‌آگاه) 2026-10-07 12:30:28 +00:00
aramesh-site deploy bef132f82f fix(timeline): کارت پایانی پایین‌تر می‌نشیند و پر شدن خط با رسیدن کارت کامل می‌شود — خط از دل دایره می‌گذرد 2026-10-07 12:26:54 +00:00
aramesh-site deploy 07570bd14a fix(timeline): خط زمانی روی خود فهرست بسته می‌شود و تا دایرهٔ کارت پایانی پر می‌شود 2026-10-07 12:09:04 +00:00
aramesh-site deploy d68db09810 feat(timeline): کارت پایانی (تعهدی فراتر از تحویل ساختمان) وسط خط زمانی می‌نشیند — قاب نتیجه‌گیری داستان 2026-10-07 09:56:15 +00:00
aramesh-site deploy 573d4ab452 feat(home): بازطراحی کادر آمار هیرو — شیشهٔ چندلایه با خط مویی مسی، هالهٔ نرم، نشان زنده و سلسله‌مراتب عدد/برچسب (محتوای کاربر دست‌نخورده) 2026-10-07 09:46:40 +00:00
aramesh-site deploy 436acf3cca feat(home): بازطراحی کادر آمار هیرو — شیشهٔ چندلایه با خط مویی مسی، هالهٔ نرم، نشان زنده و سلسله‌مراتب عدد/برچسب؛ محتوا: +۶۴ پروژه تحویل‌شده در ۲۴ سال 2026-10-07 09:41:04 +00:00
aramesh-site deploy 2c4fd5a9d4 fix(blog): آکاردئون پرسش‌وپاسخ مقاله که هرگز رندر نمی‌شد — داده و اسکیما بودند ولی کامپوننت صفحه نبود 2026-10-07 08:51:24 +00:00
aramesh-site deploy cf6e71839b fix(seo): تطبیق کلمه کلیدی بی‌توجه به بزرگ/کوچکی حروف 2026-10-07 08:41:30 +00:00
aramesh-site deploy 99ecb0fa9f fix(blog): زبان ویرایش به‌ازای هر مقاله به‌خاطر سپرده می‌شود، شمارندهٔ متا روی زبان فعال، جهت محتوای ادیتور از زبان ویرایش 2026-10-07 08:07:54 +00:00
aramesh-site deploy bd310e8d18 fix(blog): لینک کپشن رندر عمومی را می‌کراند (اسپرد اسپک) + نوار ابزار با جابه‌جایی کرسر آپدیت شود + صفحهٔ ادیتور انگلیسی LTR 2026-10-07 07:20:43 +00:00
aramesh-site deploy 27a9a82a37 fix(api): Cache-Control no-store روی همهٔ پاسخ‌های API — دادهٔ کهنهٔ مرورگر علت دیدن نسخهٔ قدیمی مقاله‌ها بود 2026-10-06 12:26:02 +00:00
aramesh-site deploy 00e7490912 fix(blog): نقل‌قول داخل آیتم فهرست بیرون می‌آید — خط مسی همهٔ نقل‌قول‌ها یک‌خط align می‌شود 2026-10-06 12:19:34 +00:00
aramesh-site deploy 23e0c97822 fix(blog): قاعدهٔ display:inline محدود به p مستقیم li — نقل‌قول داخل فهرست دیگر پاراگراف‌هایش را یکی نمی‌کند 2026-10-06 12:04:46 +00:00
aramesh-site deploy 2556bbfa1b fix(blog): پاک‌ساز سمت سرور — پاراگراف‌های خالی/nbsp داخل نقل‌قول در هر ذخیره حذف می‌شوند 2026-10-06 11:51:22 +00:00
aramesh-site deploy f05cd8b820 fix(blog): پاراگراف خالی داخل نقل‌قول خط خالی رندر نکند 2026-10-06 11:29:16 +00:00
aramesh-site deploy d16db803ba fix(blog): پاراگراف‌های داخل نقل‌قول فاصله بگیرند — متن/شخص/نقش یک بلاک یکسره دیده می‌شد 2026-10-06 11:09:29 +00:00
aramesh-site deploy 1b491357ab feat(blog): جدول — ستون‌ها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگ‌های نسخه انگلیسی بزرگ‌تر 2026-10-06 10:57:01 +00:00
aramesh-site deploy 61a2e95278 feat(blog): جداسازی دوزبانهٔ اسلاگ/کلیدواژه/نویسنده/تگ‌ها/متا + میگریشن ۰۰۰۲، رفع «دقیقه مطالعه دقیقه»، فونت مقاله ۱۵/۱۷px، جدول تیپ‌تپ در ادیتور، مسیر /en/blog با slug_en 2026-10-06 10:11:55 +00:00
aramesh-site deploy 426dc783df feat(site): حذف بخش گالری و نظرات مشتریان از صفحهٔ اصلی و صفحهٔ پروژه‌ها 2026-10-06 09:19:13 +00:00
aramesh-site deploy 41ead01e02 fix(map): مرز خطای نقشه — تبِ قدیمی پس از استقرار به‌جای مرگ بی‌صدای نقشه، دکمهٔ بارگیری دوباره می‌گیرد 2026-10-03 08:38:07 +00:00
aramesh-site deploy 4e2ecaa69e fix(en): حرف Q در فونت پینار به شکل O ترسیم شده — روی نسخه انگلیسی همهٔ عناصر font-pinar به فونت لاتین می‌روند 2026-10-01 17:06:01 +00:00
aramesh-site deploy 632edaf081 fix(en): هشت اصلاح سایت انگلیسی — دکمهٔ هیرو با ps منطقی، پیوند درباره ما زیر تیتر، justify متن درباره ما، متای مقاله (نویسنده/تاریخ/زمان مطالعه) دوزبانه، تیتر فهرست مطالب، H1 انگلیسی در ادیتور + چک‌لیست سئو زبان‌آگاه، بازگشت به وبلاگ با حفظ زبان، دسته‌بندی انگلیسی 2026-10-01 16:54:44 +00:00
aramesh-site deploy d56dff2a2f fix(admin): دکمهٔ «ویرایش این پروژه» در صفحهٔ عمومی پروژه برای همه رندر می‌شد — فقط برای ادمین واردشده 2026-10-01 16:01:54 +00:00
aramesh-site deploy 696cec2fb5 feat(map): بازسازی فهرست سنجاق‌ها از شیت مختصات تازه — ۶۲ پین با نام دقیق دیتابیس؛ همهٔ پین‌ها به پروژه وصل‌اند (آرامش 2 = میرزا طاهر 2، ادیب/باهنر/کوثرها/نگین/نیکان/انقلاب/خلیفه سلطانی/بهشت اصلاح شد) 2026-10-01 15:39:46 +00:00
aramesh-site deploy 729b19bc12 feat(map): زوم‌اوت آزاد تا دید کشوری، قاب نخست روی همهٔ پروژه‌ها با fitBounds، و تصاویر دیتابیس در پاپ‌آپ نقشه 2026-10-01 15:15:32 +00:00
aramesh-site deploy 6f1f42ba7a fix(media): هیچ آپلودی هرگز متحرک ذخیره نمی‌شود — منبع چندفریمی (gif/ویدیو) همیشه به پوستر فریم اول می‌رسد؛ انیمیشن در کارت‌ها سوسو می‌زد 2026-10-01 11:48:08 +00:00
aramesh-site deploy 68863eb44e fix(admin): entityConfig هم کلید و هم resource را پیدا کند — رندر صفحهٔ اصلی با «منبع ناشناخته: why_us» می‌شکست
رفاکتر قبلی جستجو را فقط به resource محدود کرد در حالی که EditableItem و
AddItemButton و SingletonPill در چند صفحه با کلید فهرست صدا زده می‌شوند
(why_us، contact_section، before_after، process_steps، investment_models) —
useEntityList پرتاب خطا می‌کرد و رندر کل صفحه (خانه، خدمات، سرمایه‌گذاری و
بخش تماس) با از کار افتادن درخت ری‌اکت به صفحهٔ خالی تبدیل می‌شد.
2026-10-01 08:00:48 +00:00
aramesh-site deploy 679195b590 feat(media): سقف آپلود ۳۰ مگابایت — لبه ۴۰MB، بک‌اند ۳۵MB، پیش‌چک ۳۰MB + رمزگذاری روی ترد تا رویدادلوپ قفل نشود 2026-09-30 10:54:36 +00:00
aramesh-site deploy b070125f51 fix(frontend): مسیرهای /assets/* هرگز به index.html برنگردند — مسموم‌شدن کش CDN
در پنجرهٔ تعویض کانتینر فرانت‌اند، درخواستِ هشِ جدید که در dist کانتینرِ در
حال اجرا نبود با fallback اسپا پاسخ 200ٔ text/html می‌گرفت؛ CDN (WCDN، SMART)
همین پاسخ HTML را زیر URL فایل جاوااسکریپت با هدر immutable یک‌ساله کش می‌کرد
و اسکریپت ماژول برای همهٔ بازدیدکننده‌ها می‌شکست (سایت روی پری‌لودر قفل می‌شد).
حالا فایل ناموجود در assets واقعاً 404 می‌دهد که کش نمی‌شود.
2026-09-30 10:53:46 +00:00
aramesh-site deploy d513512b87 fix(media): پیش‌چک آپلود روی سقف واقعی CDN (۱۸MB) — WCDN بدنه‌های ~۲۰MB+ را قطع می‌کند 2026-09-30 10:44:14 +00:00
aramesh-site deploy 30038d5103 fix(media): سقف آپلود ۱۰→۲۵ مگابایت (لبه ۳۰MB) + پیام مشخص ۴۱۳ و پیش‌چک حجم سمت کاربر — فایل‌های بزرگ تیم محتوا در لبه رد می‌شدند 2026-09-30 10:37:30 +00:00
aramesh-site deploy d2ed1b1c76 fix(admin): جستجوی تعریف موجودیت بر اساس resource — ویرایش چرا آرامش/فرم تماس روی /en هم ستون فارسی را می‌نوشت
کلید فهرست ENTITIES برای چند موجودیت snake_case است (why_us، contact_section،
seo_meta، …) ولی resource همان‌ها کباب‌کامل؛ isTranslatable و سایر جستجوها با
ENTITIES[resource] پیدا نمی‌شدند و نتیجه دو بود:
- ویرایش درجای «چرا آرامش» و متن‌های فرم تماس روی صفحهٔ انگلیسی به‌جای ستون‌های
  _en ستون فارسی را بازنویسی می‌کرد (حتی با زبان ویرایش انگلیسی)؛
- قرص سئوی صفحهٔ اصلی (SingletonPill resource="seo-meta") اصلاً رندر نمی‌شد.
حالا همهٔ جستجوها با entityConfig بر اساس resource انجام می‌شود.
2026-09-30 10:20:34 +00:00
aramesh-site deploy 5530c141a2 fix(admin): title و subtitle در تعریف هیرو — ویرایش تیتر اصلی روی /en متن فارسی را بازنویسی می‌کرد
تیتر و زیرتیتر هیرو درجا ویرایش می‌شوند اما در فهرست فیلدهای موجودیت هیرو
تعریف نشده بودند؛ isTranslatable('hero','title') false برمی‌گشت، کلید به
title_en نگاشت نمی‌شد و PATCH ستون فارسی را پر می‌کرد — با زبان ویرایش
انگلیسی هم. مقادیر خراب‌شدهٔ ستون فارسی از پشتیبان شبانه بازگردانی شد.
2026-09-30 10:15:14 +00:00
aramesh-site deploy cb6fd6f5af fix(admin): تعریف section-headings در فهرست موجودیت‌ها — ویرایش تیتر بخش‌ها روی /en به ستون انگلیسی می‌رفت
تیتر و توضیح بخش‌ها (تیتر بخش خدمات، پروژه‌های شاخص، چرا آرامش و…) فقط
درجا ویرایش می‌شوند و تعریفشان در ENTITIES جا افتاده بود؛ isTranslatable
برایشان false برمی‌گشت و کلید تبدیل به title_en نمی‌شد — نتیجه اینکه
ویرایش روی صفحهٔ انگلیسی حتی با زبان ویرایش انگلیسی، متن فارسی را
بازنویسی می‌کرد و نسخهٔ انگلیسی به‌ظاهر هیچ تغییری نمی‌کرد.
2026-09-30 09:59:28 +00:00
aramesh-site deploy 1800909661 fix(media): ابعاد خروجی FFmpeg با Pillow خوانده شود — ffprobe روی WebP صفر برمی‌گرداند 2026-09-30 09:39:18 +00:00
aramesh-site deploy c0eaac1daa feat(media): هر رسانهٔ آپلودی به WebP نرمال می‌شود — Pillow مسیر اصلی، FFmpeg برای ویدیو و فرمت‌های خاص؛ ویدیو تا ۸ثانیه/۱۲fps وب متحرک، غیرقابل‌رمزگشایی ۴۱۵ 2026-09-30 09:27:21 +00:00
aramesh-site deploy 79ebe69221 fix(admin): زبان ویرایش از زبان صفحه پیروی کند — ویرایش روی /en دیگر متن فارسی را بازنویسی نمی‌کند
قبلاً زبان ویرایش (editLang) کلید جداگانه‌ای در نوار ادمین بود که با زبان صفحهٔ
در حال نمایش هیچ ارتباطی نداشت و پیش‌فرضش فارسی می‌ماند؛ نتیجه‌اش این بود که
ویرایش درجا روی صفحه‌های /en به ستون‌های فارسی می‌رفت و تغییرها «سمت دیگر»
یعنی سایت فارسی ظاهر می‌شدند.

- AdminBar: در حالت ویرایش، editLang با زبان URL همگام می‌شود (syncEditLang)؛
  کلید دستی فقط برای ویرایش ستون زبان دیگر روی همین صفحه می‌ماند.
- entities: تعریف status-labels اضافه شد — نبودش یعنی ویرایش برچسب وضعیت روی
  صفحهٔ انگلیسی همیشه ستون فارسی را بازنویسی می‌کرد.
2026-09-30 09:14:34 +00:00
aramesh-site deploy e5fe31f6ba feat(admin): پس از ذخیرهٔ موفق پروژهٔ موجود، بازگشت به فهرست — نبود بازخورد «ذخیره شد» القای شکست می‌کرد 2026-09-30 08:11:15 +00:00
aramesh-site deploy 05945099a1 fix(admin): creator_scoped=True روی ثبت مقاله‌ها — منطق نقش فعال شد
پارامتر تعریف شده بود ولی هنگام ثبت مقاله‌ها پاس داده نمی‌شد؛ نتیجه:
ساخت/ویرایش نقش creator مثل ادمین رفتار می‌کرد (status=published قابل ثبت).
2026-09-29 09:58:45 +00:00
aramesh-site deploy 399a76da8e feat(auth): author_id در خروجی مقاله‌ها 2026-09-29 09:48:29 +00:00
aramesh-site deploy 659b1a1943 fix(admin): ایمپورت oauth2_scheme برای گارد نقش 2026-09-29 09:35:49 +00:00
aramesh-site deploy fb1d53d97a fix(admin): گارد نقش پیش از تعریف روتر — NameError در استارتاپ 2026-09-29 08:51:01 +00:00
aramesh-site deploy 79813ff52c feat(auth): نقش سوم «creator» — نویسندهٔ محتوا با دسترسی محدود
- بک‌اند: ستون role برای admin_users (admin/creator) + author_id برای مقاله‌ها
- نقش‌گارد روی کل روتر مدیریتی: creator فقط لیست/ساخت/خواندن/ویرایش
  پیش‌نویس‌های «خودش» را می‌بیند؛ بقیهٔ مسیرهای مدیریتی ۴۰۳
- ساخت توسط creator همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود
  (حتی تلاش برای publish در payload نادیده گرفته می‌شود)
- حذف/ترتیب‌بندی/پیام‌ها/خبرنامه/سایر بخش‌ها برای creator ممنوع
- لاگین نقش را برمی‌گرداند؛ پنل: AdminBar نویسنده فقط «نوشتن مقاله» +
  زبان ویرایش + رمز + خروج دارد؛ دکمه‌های انتشار/حذف مقاله فقط برای admin
- ورود creator مستقیم به صفحهٔ نوشتن مقاله هدایت می‌شود
2026-09-29 08:38:16 +00:00
aramesh-site deploy 1b571387df perf(home): متن هیرو بلافاصله دیده شود — تاخیرهای ۲.۱-۲.۶ ثانیه حذف و درخواست‌های داده موازی
- انیمیشن ورود هیرو تاخیر ۲.۱ تا ۲.۶ ثانیه داشت (بعد از بارگذاری JS+API هم
  متن ۳ ثانیه نامرئی بود)؛ حالا ورود نرم با تاخیر ۰.۰۵-۰.۳۶ ثانیه
- SiteProvider: /api/site و /api/admin/articles پشت سر هم fetch می‌شدند و
  setData منتظر هر دو می‌ماند؛ حالا همزمان شروع می‌شوند
2026-09-28 11:27:59 +00:00
aramesh-site deploy e1bf404455 fix(typography): فهرست‌های مقاله هم مثل پاراگراف justify شوند + متن پروژه justify
قبلاً li از قاعدهٔ justify مستثنا بود و راست/چپ‌چین می‌شد؛ حالا فقط تیترها
تراز می‌مانند. متن پروژه در صفحهٔ جزئیات هم justify شد.
2026-09-28 11:15:22 +00:00
aramesh-site deploy 18e226ad47 fix(toc): حذف نوار اسکرول فهرست مطالب و پهن‌کردن ستون آن
- ستون فهرست در دسکتاپ از ۱۷rem به ۲۱rem پهن‌تر شد تا تیترهای بلند جا شوند
- نوار اسکرول بومی مرورگر (با فلش) مخفی شد؛ اسکرول با چرخ ماوس/لمس سر جایش است
2026-09-28 11:06:51 +00:00
aramesh-site deploy 182a1bb674 feat(projects): داده‌های واقعی پروژه‌ها — ستون‌های تازه و حذف داده‌های ماک
- دیتابیس/مدل/اسکیما: land_area (مساحت عرصه)، pilot_floors (طبقات روی
  همکف/پیلوت) و سه ستون واحدها (مسکونی/تجاری/اداری) به‌جای «واحدها»ی تکی
- فایل استاتیک projectsData (داده‌های ماک) حذف شد؛ لیست، جزئیات و
  «پروژه‌های شاخص» همه از دیتابیس می‌خوانند
- برچسب‌های i18n فهرست مشخصات مطابق شیت تیم
2026-09-28 10:50:39 +00:00
aramesh-site deploy 0c87a287a3 feat(admin): صفحهٔ اختصاصی ویرایش/ساخت پروژه + گالری فقط با آپلود به MinIO
- /admin/projects/new و /admin/projects/:id — همهٔ فیلدها در یک صفحه با
  ورودی دوزبانهٔ صریح (فارسی/English)، ذخیره، حذف
- GalleryField تازه: گالری چندتصویری فقط با آپلود به MinIO (حذف/جابه‌جایی/
  افزودن چندفایلی) — فیلد «هر خط یک URL» دیگر استفاده نمی‌شود
- مدادِ کارت پروژه و دکمهٔ «افزودن پروژه» به صفحهٔ اختصاصی هدایت می‌شوند؛
  دکمهٔ ویرایش روی صفحهٔ جزئیات هم لینک همان صفحه است
2026-09-28 10:33:05 +00:00
aramesh-site deploy edcbd0dc58 feat(projects): فیلدهای «نوع پروژه» و «کاربری» + فهرست مشخصات جدید صفحهٔ جزئیات
- دیتابیس/مدل/اسکیما: ستون‌های project_type/usage دوزبانه (مهاجرت DDL
  idempotent — با استارت بک‌اند اعمال می‌شود)
- پنل: کشویر پروژه‌ها دو فیلد تازه گرفت
- صفحهٔ جزئیات: فهرست مشخصات مطابق داده‌های تیم — وضعیت / سال / طبقات /
  واحدها / نوع پروژه / زیربنا / کاربری (ردیف‌های خالی پنهان‌اند)
2026-09-28 10:17:25 +00:00
aramesh-site deploy 5cf7b51686 revert(ui): بازگردانی دکمه‌های فرم تماس، خبرنامه، مقالات، داستان و صفحهٔ مقاله به طرح قبلی
همه جای Button/PremiumButton جابه‌جا شده بودند؛ به Button/ButtonLink قبلی
برگشتند (کامپوننت Button قبلاً بازگردانده شده بود).
2026-09-28 09:58:49 +00:00
aramesh-site deploy 531e8d63c1 revert(ui): بازگردانی هیروی صفحهٔ اصلی به حالت پیش از UI v1
- دکمه‌های CTA: بازگشت به ButtonLink شیشه‌ای (اصلی با دایرهٔ مسی، ثانویه مات)
- کارت شناور آمار (badge_value/badge_label) که UI v1 حذف کرده بود برگشت —
  فیلدهایش در کشوی «تنظیمات هیرو» دوباره معنا دارند
- Button.tsx که قبلاً بازگردانده شده بود دوباره استفاده می‌شود
2026-09-28 09:52:21 +00:00
aramesh-site deploy dc7a92f040 revert(ui): بازگردانی کامپوننت Button (ButtonLink) — وابستگی ساختار قدیمی جزئیات پروژه
UI v1 این کامپوننت را حذف کرده بود؛ ساختار قدیمی صفحهٔ جزئیات پروژه به آن
وابسته است (دکمهٔ بازگشت به فهرست پروژه‌ها).
2026-09-28 09:41:11 +00:00
aramesh-site deploy 11552d6124 revert(ui): بازگردانی سه بخش به حالت پیش از UI v1 (کامیت 853ad67)
- دکمهٔ تغییر زبان: کپسول بزرگ دوزبانه → دکمهٔ کوچک آیکون+متن (طرح قبلی)
- ساختار صفحهٔ جزئیات پروژه: بازگشت به چیدمان قبلی (دیتابیس‌محور بود و می‌ماند)
- قالب پرسش‌وپاسخ: بازگشت به چیدمان دوستونه (تیتر کنار آکاردئون)

توجه: بخشی از UI v1 (fatemeh) عمداً برگردانده شده به درخواست تیم UI؛
بقیهٔ تغییرات آن کامیت سر جایش است. ویرایش‌پذیری دیتابیس‌محور پروژه‌ها
و دکمهٔ ویرایش، روی همین ساختار قدیمی دوباره اعمال شده‌اند.
2026-09-28 09:30:15 +00:00
aramesh-site deploy 7b4783bb3e feat(admin): ویرایش‌پذیری هیروی صفحه‌ها و پروژه‌ها
- page-heroes به فهرست موجودیت‌های پنل اضافه شد و PageHero یک دکمهٔ واضح
  «عنوان، سرآغاز و تصویر هیروی این صفحه» گرفت (پیش‌تر فقط ویرایش درجای
  نامرئی روی خود متن بود).
- صفحهٔ پروژه‌ها و صفحهٔ جزئیات پروژه حالا از دیتابیس خوانده می‌شوند
  (فایل استاتیک projectsData فقط جانشین دیتابیس خالی/اسلاگ‌های قدیمی است) —
  پیش‌تر ویرایش پنل روی این دو صفحه اصلاً نمایش داده نمی‌شد.
- روی لیست پروژه‌ها دکمهٔ ویرایش/جابه‌جایی/حذف و «افزودن پروژه» اضافه شد و
  روی صفحهٔ جزئیات هر پروژه دکمهٔ ویرایش همان پروژه.
2026-09-28 08:36:30 +00:00
aramesh-site deploy 98c7977a19 fix(frontend): کپشن وسط‌چین، نام تصویر در Alt، و تورفتگی واضح H3 در فهرست
- کپشن: قاعدهٔ وسط‌چینِ .article-body figcaption با قاعده‌های [data-dir=...] برای
  راست/چپ‌چین بازنویسی می‌شد و عملاً وسط‌چین نمی‌شد؛ figcaption از آن فهرست‌ها
  برداشته شد. متن کپشن در ویرایشگر هم وسط‌چین شد تا با خروجی سایت یکی باشد.
- نام تصویر: نامی که پیش از بارگذاری نوشته می‌شود، حالا همان Alt تصویر می‌شود
  (پیش‌تر نام فایل مبنا بود و باید دستی در جعبهٔ زیر تصویر اصلاح می‌شد).
- فهرست مطالب: H3 با ps-4 فقط ۴ پیکسل بیشتر از H2 تورفتگی داشت (عملاً نامرئی)؛
  حالا ps-6 با فونت یک پله کوچک‌تر.
2026-09-28 07:49:08 +00:00
aramesh-site deploy c9e0720764 feat(preview): پنل مدیریت و API از گیت Basic Auth مستثنی شدند
پیش‌نمایش خصوصی دو بار احراز هویت می‌خواست (Basic Auth لبه + ورود خود پنل) و
هر origin (www/apex/.ir) هم پرسش جداگانه‌ای داشت؛ نتیجه سردرگمی تیم.
حالا روترهای /admin و /api میدل‌ور preview-auth ندارند (noindex می‌ماند) و
پنل با همان ورود خودش کار می‌کند؛ صفحات عمومی همچنان خصوصی‌اند.
2026-09-27 11:20:17 +00:00
aramesh-site deploy 87b744c9c7 fix(scripts): جلوگیری از خرابی Basic Auth با کوتیشن‌کردن مقدارهای دارای $
docker compose مقادیر .env را خودش تفسیر می‌کند؛ هش htpasswd شامل $apr1$...
است و بدون کوتیشن، $apr1 و بخش‌های بعدی به‌عنوان متغیر خالی جایگزین می‌شدند
(برچسب تبدیل به preview:/OBoRS/... می‌شد) و پیش‌نمایش خصوصی هرگز رمز را
نمی‌پذیرفت. حالا env_set مقدارهای دارای $ را تک‌کوتیشن می‌گذارد و env_get
کوتیشن را برمی‌دارد.
2026-09-27 10:41:00 +00:00
aramesh-site deploy 275c3d6bb3 fix(chatbot): row_factory=dict_row در sync_chunks و fetch_all_chunks
بدون آن، psycopg3 تاپل برمی‌گرداند و row["model"] خطای
«tuple indices must be integers or slices, not str» می‌داد؛ نتیجه:
«Initial index sync failed» و unavailable ماندن چت با وجود امبد موفق.
2026-09-27 10:32:48 +00:00
aramesh-site deploy ef619c635e fix(chatbot): کوتیشن desc در سه کوئری دیگر ایندکس
همان باگ قبلی در investment_models/process_steps/why_us/timeline هم بود؛
بدون این، سینک ایندکس ناقص می‌ماند و بخشی از محتوا هرگز بردار نمی‌گرفت.
2026-09-27 10:31:12 +00:00
aramesh-site deploy d55c8cc1e5 fix(chatbot): کوئری خدمات و نصب torch نسخهٔ CPU
- content.py: ستون desc در SQL بدون کوتیشن بود و PostgreSQL آن را کلمهٔ کلیدی
  می‌گیرد؛ نتیجه: «Initial index sync failed: syntax error at or near "desc"»
  در استارت و غیرقابل استفاده ماندن چت تا reindex. حالا "desc" کوتیشن شده.
- Dockerfile: با این‌که سرور GPU ندارد، pip نسخهٔ CUDA تورچ را از PyPI می‌گرفت
  (ایمیج ۹.۷GB با کتابخانه‌های nvidia). اول torch از ایندکس CPU نصب می‌شود.
2026-09-26 22:04:29 +00:00
aramesh-site deploy 9d0015808c feat(frontend): تحلیل رفتار با OpenPanel به‌جای اجرای نمونهٔ دوم Umami
نمونهٔ OpenPanel موجود (stats.*) با یک پروژهٔ دوم برای این سایت استفاده می‌شود؛
VITE_OPENPANEL_URL/VITE_OPENPANEL_CLIENT_ID در زمان بیلد تزریق می‌شوند و در
نبودشان همه‌چیز بی‌اثر است. API عمومی track() تغییر نکرده و مسیر Umami هم
برای عقب‌نشینی دست‌نخورده مانده.
2026-09-26 21:43:59 +00:00
aramesh-site deploy 8600d2d4a3 fix(scripts): جلوگیری از تولید راز خالی و اجرای اسکریپت پیش‌نمایش با python3
- gen-env.sh: rand با دو آرگومان صدا زده می‌شد ولی فقط $1 را می‌خواند؛
  openssl rand -hex بدون تعداد بایت خروجی خالی و کد ۰ می‌دهد، پس fallback
  هرگز اجرا نمی‌شد و .env با رازهای خالی ساخته می‌شد. حالا تعداد بایت
  رعایت می‌شود و اگر مقداری خالی بماند، اسکریپت با خطا متوقف می‌شود.
- preview-mode.sh: python روی اوبونتو ۲۴.۰۴ وجود ندارد (فقط python3).
2026-09-26 21:43:59 +00:00
108 changed files with 5776 additions and 1277 deletions
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
import { track } from '../analytics';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
streaming?: boolean;
error?: boolean;
};
type SseFrame = {
content?: string;
error?: string;
done?: boolean;
};
/** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */
function getThreadId(): string {
const KEY = 'chat_thread_id';
let id = sessionStorage.getItem(KEY);
if (!id) {
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
sessionStorage.setItem(KEY, id);
}
return id;
}
/** خواندن فریم‌های SSE از پاسخ fetch (استریم توکن‌به‌توکن) */
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
const reader = response.body?.getReader();
if (!reader) throw new Error('پاسخ استریمی ندارد');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
try {
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
} catch {
// فریم ناقص — نادیده بگیر
}
}
}
}
}
export function ChatWidget() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const scrollEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const assistantIndexRef = useRef<number | null>(null);
// پیشنهاد پرسش‌ها فقط یک بار برای هر بازشدن اول
useEffect(() => {
if (!open || suggestions.length > 0) return;
fetch('/chat/suggestions')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
.catch(() => setSuggestions([]));
}, [open, suggestions.length]);
useEffect(() => {
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, open]);
const sendMessage = async (text: string) => {
const question = text.trim();
if (!question || isLoading) return;
track('chat-question')
setInput('');
setIsLoading(true);
setMessages((prev) => {
const next: ChatMessage[] = [
...prev,
{ role: 'user', content: question },
{ role: 'assistant', content: '', streaming: true },
];
assistantIndexRef.current = next.length - 1;
return next;
});
const patchAssistant = (patch: Partial<ChatMessage>) => {
setMessages((prev) => {
const idx = assistantIndexRef.current;
if (idx === null || !prev[idx]) return prev;
const updated = [...prev];
updated[idx] = { ...updated[idx], ...patch };
return updated;
});
};
try {
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
const response = await fetch(`/chat/stream?${params.toString()}`, {
headers: { Accept: 'text/event-stream' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let accumulated = '';
await readSse(response, (frame) => {
if (frame.error) {
patchAssistant({ content: frame.error, streaming: false, error: true });
return;
}
if (frame.content) {
accumulated += frame.content;
patchAssistant({ content: accumulated });
}
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
});
patchAssistant({ streaming: false });
} catch (err) {
patchAssistant({
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
streaming: false,
error: true,
});
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
return (
<>
{/* دکمه شناور — گوشه راست‌پایین (دکمه تماس سمت چپ است) */}
<button
type="button"
onClick={() => {
setOpen((v) => {
if (!v) track('chat-open');
return !v;
});
}}
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink 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-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
</button>
{/* پنل گفتگو */}
{open && (
<div
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
{/* سربرگ */}
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
<SparklesIcon className="h-4 w-4" />
</span>
<div className="flex-1">
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="بستن"
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
<XIcon className="h-4 w-4" />
</button>
</div>
{/* پیام‌ها */}
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="mt-6 text-center">
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
سلام! هر سؤالی درباره خدمات، پروژه‌ها و مقالات
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
دارید بپرسید.
</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
msg.role === 'user'
? 'rounded-bl-md bg-copper text-char'
: msg.error
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
}`}>
{msg.content || (msg.streaming ? '…' : '')}
{msg.streaming && msg.content && (
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
)}
</div>
</div>
))}
<div ref={scrollEndRef} />
</div>
{/* پیشنهاد پرسش‌ها */}
{suggestions.length > 0 && messages.length === 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
{suggestions.map((q) => (
<button
key={q}
type="button"
onClick={() => sendMessage(q)}
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
{q}
</button>
))}
</div>
)}
{/* ورودی */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
}}
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="پرسش خود را بنویسید…"
disabled={isLoading}
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
aria-label="ارسال"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
<SendIcon className="h-4 w-4 -scale-x-100" />
</button>
</form>
</div>
)}
</>
);
}
+284
View File
@@ -0,0 +1,284 @@
"""سرویس چت‌بات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
import asyncio
import json
import logging
import time
import uuid
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from langchain_core.messages import AIMessage, HumanMessage
from . import content as content_mod
from . import pipeline
from . import vectorstore
from .config import settings
from .indexing import INDEX, sync_index
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
log = logging.getLogger("chatbot")
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
_refresh_task: asyncio.Task | None = None
@asynccontextmanager
async def lifespan(_app: FastAPI):
try:
await asyncio.to_thread(sync_index)
except Exception as e:
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
global _refresh_task
_refresh_task = asyncio.create_task(_refresh_loop())
yield
_refresh_task.cancel()
async def _refresh_loop() -> None:
interval = settings.index_refresh_seconds
if interval <= 0:
return
while True:
await asyncio.sleep(interval)
try:
# همگام‌سازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد می‌شوند
await asyncio.to_thread(sync_index)
except Exception as e:
log.warning("Background index refresh failed: %s", e)
api = FastAPI(
title="Aramesh Site Chatbot",
version="1.0.0",
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ری‌رنکر BGE",
lifespan=lifespan,
)
api.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
_hits: dict[str, deque] = defaultdict(deque)
_last_sweep = 0.0
def _rate_limited(ip: str) -> bool:
global _last_sweep
now = time.time()
# پاک‌سازی دوره‌ای کلیدهای بی‌مصرف تا دیکشنری بی‌نهایت بزرگ نشود
if now - _last_sweep > settings.rate_limit_window:
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
for k in stale:
_hits.pop(k, None)
_last_sweep = now
window = _hits[ip]
while window and now - window[0] > settings.rate_limit_window:
window.popleft()
if len(window) >= settings.rate_limit_messages:
return True
window.append(now)
return False
def _client_ip(request: Request | None) -> str:
if request is None:
return "?"
forwarded = request.headers.get("x-forwarded-for")
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
# --- کمکی‌های SSE ---
def _sse(payload: dict) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _chunk_text(chunk) -> str:
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخش‌ها)."""
content = getattr(chunk, "content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
return ""
async def _stream_answer(message: str, thread_id: str):
if not INDEX.ready:
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
return
if pipeline.get_llm() is None:
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
return
config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]}
try:
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
# فقط توکن‌های پاسخ نهایی — نه فراخوانی‌های کمکی (بازنویسی/گسترش پرسش)
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
text = _chunk_text(event["data"]["chunk"])
if text:
yield _sse({"content": text, "done": False})
yield _sse({"content": "", "done": True})
except Exception as e:
log.exception("Streaming failed")
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
# --- مسیرها ---
@api.get("/chat/healthz")
async def healthz():
return {
"status": "ok",
"index_ready": INDEX.ready,
"documents": len(INDEX.docs),
"vectors": await asyncio.to_thread(vectorstore.vector_count),
"llm_configured": bool(settings.llm_base_url),
"reranker_enabled": settings.reranker_enabled,
}
@api.get("/chat/suggestions")
async def suggestions():
return {"questions": _get_suggestions()}
@api.get("/chat/eval")
async def eval_question(message: str = Query(...)):
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده، با thread تازه."""
if not INDEX.ready:
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
if pipeline.get_llm() is None:
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]}
final_state = await pipeline.graph.ainvoke(inputs, config=config)
answer = ""
for msg in reversed(final_state.get("messages", [])):
if isinstance(msg, AIMessage):
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
break
return {
"question": message,
"contextualized_query": final_state.get("contextualized_query") or message,
"answer": answer,
"contexts": final_state.get("documents", []),
"thread_id": thread_id,
}
@api.post("/chat/reindex")
async def reindex(request: Request):
"""همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود."""
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
raise HTTPException(status_code=403, detail="invalid reindex token")
stats = await asyncio.to_thread(sync_index, True)
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval می‌سازد
@api.get("/chat/{scope}/stream")
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
return await stream_chat(message=message, thread_id=thread_id, request=request)
@api.get("/chat/{scope}/eval")
async def eval_scoped(scope: str, message: str = Query(...)):
return await eval_question(message=message)
@api.get("/chat/{scope}/suggestions")
async def suggestions_scoped(scope: str):
return {"questions": _get_suggestions()}
@api.get("/chat/stream")
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
"""استریم توکن‌به‌توکن پاسخ با SSE."""
if _rate_limited(_client_ip(request)):
raise HTTPException(status_code=429, detail="تعداد پیام‌های شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
async def generate():
async for chunk in _stream_answer(message, thread_id):
yield chunk
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
# --- پیشنهاد پرسش‌ها (کش‌شده) ---
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
def _get_suggestions() -> list[str]:
global _suggestions_cache
now = time.time()
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
return _suggestions_cache[1]
questions: list[str] = []
if INDEX.ready:
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
model = pipeline.get_llm()
if model and (faqs or titles):
material = "\n".join([d["text"][:200] for d in faqs] + titles)
try:
prompt = (
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
"احتمالاً می‌پرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
'فقط آرایه JSON رشته‌ای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
f"محتوا:\n{material[:2000]}"
)
resp = model.invoke([HumanMessage(content=prompt)])
text = resp.content if isinstance(resp.content, str) else ""
if text.startswith("```"):
text = text.split("```")[1]
text = text[4:] if text.startswith("json") else text
import re
match = re.search(r"\[.*\]", text, re.DOTALL)
if match:
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
except Exception as e:
log.warning("Suggestion generation failed: %s", e)
if not questions and faqs:
# جایگزین: خود پرسش‌های متداول
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
_suggestions_cache = (now, questions)
return questions
if __name__ == "__main__":
import uvicorn
uvicorn.run(api, host="0.0.0.0", port=8000)
+315
View File
@@ -0,0 +1,315 @@
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و
پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند.
"""
import logging
from typing import Any
from .persian import normalize
log = logging.getLogger(__name__)
# URL پایه صفحات — چت‌بات مسیر نسبی می‌دهد؛ فرانت‌اند روی همان دامنه است
_ARTICLE_URL = "/blog/{slug}"
_PROJECT_URL = "/projects/{slug}"
def _psycopg_dsn(database_url: str) -> str:
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند."""
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
import psycopg
from psycopg.rows import dict_row
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
cur = conn.execute(sql)
return cur.fetchall()
def tiptap_blocks(node: Any) -> list[str]:
"""متن بلوک‌به‌بلوک از سند TipTap (یا هر JSON تو در تو) بیرون می‌کشد."""
if not isinstance(node, dict):
return []
ntype = node.get("type")
if ntype in ("paragraph", "heading", "codeBlock"):
text = _inline_text(node).strip()
return [text] if text else []
blocks: list[str] = []
for child in node.get("content") or []:
blocks.extend(tiptap_blocks(child))
return blocks
def _inline_text(node: dict) -> str:
parts: list[str] = []
for child in node.get("content") or []:
ctype = child.get("type")
if ctype == "text":
parts.append(child.get("text", ""))
elif ctype == "hardBreak":
parts.append(" ")
elif ctype == "image":
alt = (child.get("attrs") or {}).get("alt") or ""
if alt:
parts.append(f"[تصویر: {alt}]")
else:
parts.append(_inline_text(child))
return "".join(parts)
def body_to_text(body: Any) -> str:
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
if not body:
return ""
if isinstance(body, list):
# فرمت قدیمی: آرایه رشته‌ای پاراگراف‌ها
return "\n".join(str(p).strip() for p in body if str(p).strip())
blocks = tiptap_blocks(body)
return "\n".join(b for b in blocks if b)
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
text = (text or "").strip()
if not text:
return None
return {"text": text, "type": dtype, "title": title, "url": url}
def _clean(value: Any) -> str:
return normalize(str(value or "")).strip()
def fetch_site_documents(database_url: str) -> list[dict]:
"""همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند)."""
dsn = _psycopg_dsn(database_url)
docs: list[dict] = []
# --- برند و اطلاعات تماس (رکورد یگانه) ---
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
if brand_rows:
b = brand_rows[0]
contact = "\n".join(
filter(None, [
f"نام مجموعه: {_clean(b['name'])}",
f"تلفن تماس: {_clean(b['phone'])}",
f"ایمیل: {_clean(b['email'])}",
f"آدرس: {_clean(b['address'])}",
])
)
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
if d:
docs.append(d)
# --- دفاتر ---
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
d = _doc(
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
"office", f"دفتر {_clean(row['city'])}", "/contact",
)
if d:
docs.append(d)
# --- خدمات ---
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'):
d = _doc(
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
"service", _clean(row["title"]), "/services",
)
if d:
docs.append(d)
# --- پرسش‌های متداول ---
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
d = _doc(
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
"faq", _clean(row["question"]), "/faq",
)
if d:
docs.append(d)
# --- پروژه‌ها ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
):
meta = "\n".join(filter(None, [
f"مکان: {_clean(row['location'])}" if row["location"] else "",
f"سال: {_clean(row['year'])}" if row["year"] else "",
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیل‌شده'}",
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
]))
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
if d:
docs.append(d)
# --- مقاله‌ها (فقط منتشرشده) ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
):
text = "\n".join(filter(None, [
f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "",
_clean(row["excerpt"]),
body_to_text(row["body"]),
]))
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
if d:
docs.append(d)
# --- مدل‌های سرمایه‌گذاری ---
for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
d = _doc(
"\n".join(filter(None, [
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
f"نکات: {points}" if points else "",
])),
"investment", _clean(row["title"]), "/investment",
)
if d:
docs.append(d)
# --- مراحل همکاری ---
for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'):
d = _doc(
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"process", _clean(row["title"]), "/investment",
)
if d:
docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما ---
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
d = _doc(
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
"team", _clean(row["name"]), "/about",
)
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
d = _doc(
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
"testimonial", _clean(row["name"]), "/",
)
if d:
docs.append(d)
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
d = _doc(
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"timeline", _clean(row["title"]), "/about",
)
if d:
docs.append(d)
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
if story_rows and story_rows[0]:
s = story_rows[0]
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
if d:
docs.append(d)
log.info("Fetched %d documents from site database", len(docs))
return docs
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
"""متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند.
هر قطعه یک doc_id پایدار می‌گیرد («type:ترتیب در همان url:شماره قطعه») تا
همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
from .vectorstore import chunk_content_hash
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=["\n", "۔", ".", "،", " ", ""],
)
# شماره ردیف هر سند در میان سندهای هم‌type و هم-url (برای FAQs که url یکسان دارند)
occurrence: dict[str, int] = {}
out: list[dict] = []
for doc in docs:
key = f"{doc['type']}|{doc['url']}"
occ = occurrence.get(key, 0)
occurrence[key] = occ + 1
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
for i, piece in enumerate(pieces, 1):
chunk = {
"doc_id": f"{doc['type']}:{occ}:{i}",
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
"type": doc["type"],
"title": doc["title"],
"url": doc["url"],
}
chunk["content_hash"] = chunk_content_hash(chunk)
out.append(chunk)
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
return out
def content_fingerprint(docs: list[dict]) -> str:
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام می‌شود."""
import hashlib
import json
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
# --- زمینه همیشه‌تازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
_site_context_cache: tuple[float, str] | None = None
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
"""نام برند، تماس و فهرست خدمات — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند."""
global _site_context_cache
import time as _time
now = _time.time()
if _site_context_cache and now - _site_context_cache[0] < ttl:
return _site_context_cache[1]
try:
dsn = _psycopg_dsn(database_url)
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
lines: list[str] = []
if brand:
b = brand[0]
lines += [
f"نام مجموعه: {_clean(b['name'])}",
f"تلفن تماس: {_clean(b['phone'])}",
f"ایمیل: {_clean(b['email'])}",
f"آدرس: {_clean(b['address'])}",
]
if services:
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
ctx = "\n".join(lines)
_site_context_cache = (now, ctx)
return ctx
except Exception as e:
log.warning("Site context fetch failed: %s", e)
return _site_context_cache[1] if _site_context_cache else ""
+130
View File
@@ -0,0 +1,130 @@
# گزارش ارزیابی چت‌بات — 2026-10-01 21:05
- مجموع پرسش‌ها: 21 — درست: 17 — خطا: 0
- بازبینی دستی: 4 — خطای فنی: 0
- میانگین زمان پاسخ: 9.1s
## [pass] پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟
*موضوع: projects — 8.2s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5*
پروژه «آرامش - میرزا طاهر 5» در اصفهان، وحدت، شهریار (محله‌ی زهران) ساخته شده و متراژ زیربنای آن ۸۱۰۰ مترمربع است.
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
## [pass] پروژه آرامش - بابک کی و کجا تحویل شده؟
*موضوع: projects — 10.4s — منابع: معرفی صفحه اصلی — /، آخرین مقالات — /blog، آرامش - بابک — /projects/آرامش---بابک، پروژه‌ها — /projects، تحویل و پشتیبانی — /investment*
پروژه «آرامش - بابک» طبق اطلاعات سایت در سال ۱۴۰۵ تکمیل شده و در اصفهان، شهید رضایی، بین باغ مسجد و ارشاد (محله برزان) قرار دارد.
جزئیات کامل در صفحه /projects/آرامش---بابک در دسترس است.
## [pass] وضعیت پروژه میرزاطاهر ۶ چطور است؟
*موضوع: projects — 8.7s — منابع: آرامش - میرزاطاهر 6 — /projects/آرامش---میرزاطاهر-6، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، کوثر- میرزاطاهر — /projects/کوثر--میرزاطاهر، نیکان میرزاطاهر — /projects/نیکان-میرزاطاهر، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر*
پروژه «آرامش - میرزاطاهر ۶» در اصفهان، میرزاطاهر شرقی (محله زهران) قرار دارد و وضعیت آن **در حال ساخت** است؛ عرصه نیز **در حال اخذ مجوز** است.
جزئیات کامل در صفحه /projects/آرامش---میرزاطاهر-6 در دسترس است.
## [pass] کدام پروژه شما در محله گورتان اصفهان است؟
*موضوع: projects — 8.2s — منابع: آرامش - وحدت — /projects/آرامش---وحدت، سال‌های توسعه و تجربه — /about، آرامش - آتشگاه — /projects/آرامش---آتشگاه*
پروژه «آرامش - وحدت» در اصفهان، آتشگاه، محله‌ی گورتان واقع شده و وضعیت آن تکمیل‌شده است. این پروژه کاربری مسکونی-تجاری دارد و شامل ۸ واحد مسکونی و ۱ واحد تجاری است.
جزئیات کامل در صفحه /projects/آرامش---وحدت موجود است.
## [pass] پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟
*موضوع: projects — 9.0s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2*
پروژه «آرامش - میرزا طاهر 5» دارای **۲۹ واحد مسکونی** است.
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
## [pass] گروه ساختمانی آرامش چند سال سابقه دارد؟
*موضوع: stats — 10.3s — منابع: آخرین مقالات — /blog، شرکت آرامش از چه سالی فعالیت می‌کند؟ — /faq، درباره ما — /about، تجربه بیش از دو دهه — /about، گروه ساختمانی آرامش — /about*
گروه ساختمانی آرامش بیش از ۲۳ سال سابقه فعالیت در حوزه ساخت‌وساز دارد و فعالیت مستمر خود را از سال ۱۳۸۱ آغاز کرده است.
## [pass] تا امروز چند پروژه تحویل داده‌اید؟
*موضوع: stats — 8.7s — منابع: پروژه‌ها — /projects، آمار و افتخارات — /، تعهدی فراتر از تحویل ساختمان — /about، تحویل و پشتیبانی — /investment*
طبق اطلاعات ثبت‌شده در سایت، گروه ساختمانی آرامش تاکنون **۶۴ پروژه تحویل‌داده** است.
## [pass] مجموع متراژ ساخت‌وساز شما چقدر است؟
*موضوع: stats — 9.2s — منابع: آمار و افتخارات — /، شروع مسیر ساخت‌وساز — /about، سرمایه‌گذاری — /investment، درباره ما — /about، گروه ساختمانی آرامش — /about*
مجموع زیربنای ساخت‌وساز گروه ساختمانی آرامش ۱۱۱ هزار مترمربع است.
## [pass] چند خانواده در پروژه‌های شما ساکن هستند؟
*موضوع: stats — 7.2s — منابع: آمار و افتخارات — /*
بر اساس آمار ثبت‌شده، بیش از ۵۰۰ خانواده در پروژه‌های گروه ساختمانی آرامش ساکن هستند.
## [pass] شماره تماس شما چیست؟
*موضوع: contact — 6.6s — منابع: اطلاعات تماس — /contact*
شماره تماس گروه ساختمانی آرامش: **۰۹۱۳۳۱۰۷۸۰۹**
جزئیات بیشتر در صفحه «تماس با ما» موجود است.
## [pass] دفتر تهران کجاست؟
*موضوع: offices — 7.6s — منابع: دفتر تهران — /contact*
دفتر تهران گروه ساختمانی آرامش در خیابان ولیعصر، برج آرامش، طبقه هجدهم واقع شده است.
جزئیات بیشتر در صفحه /contact در دسترس است.
## [pass] آدرس ایمیل مجموعه چیست؟
*موضوع: contact — 8.7s — منابع: اطلاعات تماس — /contact، دفتر نوشهر — /contact، دفتر اصفهان — /contact، تماس با ما — /contact*
ایمیل مجموعه: arameshsakhtemani@gmail.com
## [pass] دفاتر شما در چه شهرهایی است؟
*موضوع: offices — 9.2s — منابع: ساختمان اداری — /services، ساختمان تجاری — /services*
دفاتر گروه ساختمانی آرامش در سه شهر زیر فعال هستند:
- **تهران:** خیابان ولیعصر، برج آرامش، طبقه هجدهم
- **اصفهان:** خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم
- **نوشهر:** بلوار دریا، دفتر پروژه دریاکنار
## [pass] گروه آرامش چه سالی تأسیس شده؟
*موضوع: faqs — 18.5s — منابع: شرکت آرامش از چه سالی فعالیت می‌کند؟ — /faq، آخرین مقالات — /blog، ورود به صنعت ساختمان — /about*
بر اساس اطلاعات موجود، شرکت ساختمانی آرامش از سال ۱۳۸۱ فعالیت خود را آغاز کرده است. همچنین در صفحه «درباره ما» سابقه ورود مجموعه به صنعت ساختمان از سال ۱۳۷۷ با تأسیس شرکت پیمانکاری ذکر شده است. جزئیات بیشتر در صفحات /faq و /about در دسترس است.
## [manual] زمین دارم، ساخت را از کجا شروع کنم؟
*موضوع: faqs — 7.9s — منابع: اگر زمین داشته باشیم، از کجا باید برای ساخت شروع کنیم؟ — /faq*
اولین قدم، بررسی زمین و شرایط پروژه است. موقعیت، ضوابط و تراکم، شرایط فنی و ظرفیت اقتصادی زمین بررسی می‌شود تا پیش از ورود به طراحی و اجرا، مسیر مناسب پروژه مشخص شود. جزئیات کامل در صفحه /faq موجود است.
## [manual] شرایط مشارکت در ساخت چگونه است؟
*موضوع: investment — 8.5s — منابع: آیا امکان مشارکت در ساخت هم دارید؟ — /faq، مشارکت در ساخت — /investment، وبلاگ — /blog، تحویل و پشتیبانی — /investment*
شرایط مشارکت در ساخت به این صورت است که مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک می‌شوند و اجرا، دریافت مجوزها و فروش بر عهده ماست. ابتدا ملک به‌صورت کارشناسی بررسی می‌شود، سپس قرارداد شفاف با ضمانت اجرا تنظیم می‌گردد و در طول پروژه گزارش پیشرفت ارائه می‌شود. جزئیات کامل در صفحه /investment قابل مشاهده است.
## [pass] مدل صندوق سرمایه‌گذاری چطور کار می‌کند؟
*موضوع: investment — 8.1s — منابع: صندوق سرمایه‌گذاری — /investment، مدل‌های همکاری — /investment، سرمایه‌گذاری ساختمانی — /services، سرمایه‌گذاری — /investment*
صندوق سرمایه‌گذاری به این صورت کار می‌کند که سرمایه‌گذاران واحدهایی از پروژه‌های آرامش را خریداری می‌کنند و بازدهی آن‌ها در پایان دوره محاسبه می‌شود. حداقل دوره سرمایه‌گذاری ۲۴ ماه است، گزارش ارزش‌گذاری به‌صورت فصلی ارائه می‌شود و امکان انتقال واحد سرمایه نیز وجود دارد.
جزئیات کامل در صفحه /investment در دسترس است.
## [pass] پیش‌خرید واحد یعنی چه؟
*موضوع: investment — 8.0s — منابع: پیش‌خرید واحد — /investment*
پیش‌خرید واحد یعنی خرید واحد مسکونی، اداری یا تجاری در مراحل مختلف ساخت. از جمله نکات آن می‌توان به امکان شخصی‌سازی پلان، تضمین زمان تحویل و پرداخت توافقی اشاره کرد. جزئیات کامل در صفحه /investment.
## [manual] خدمات گروه آرامش چیست؟
*موضوع: services — 7.7s — منابع: خدمات شرکت آرامش شامل چه مواردی است؟ — /faq، آخرین مقالات — /blog، آیا امکان مشارکت در ساخت هم دارید؟ — /faq، همراهی از صفر تا صد — /about*
خدمات گروه ساختمانی آرامش شامل طراحی معماری، ساخت ساختمان‌های مسکونی، تجاری و اداری، طراحی و ویلاسازی، طراحی فضاهای عمومی و رفاهی، بازسازی، مدیریت پروژه و سرمایه‌گذاری ساختمانی است. ما خدمات کامل صفر تا صد را از طراحی تا تحویل کلید پوشش می‌دهیم. جزئیات بیشتر در صفحه /faq قابل مشاهده است.
## [manual] نظرات مشتریان قبلی درباره شما چیست؟
*موضوع: testimonials — 11.2s — منابع: همراهی با تغییر نیازهای بازار مسکن — /about، نظرات مشتریان — /، سوالات متداول — /faq، سوالات متداول — /faq*
بر اساس نظرات ثبت‌شده در سایت، مشتریان قبلی رضایت خود را این‌گونه اعلام کرده‌اند:
- مهندس سارا کیانی، سرمایه‌گذار پروژه پارسه: گزارش‌های ماهانه آن‌قدر دقیق بود که نیازی به نگرانی درباره سرمایه نبود و پروژه دو ماه زودتر از موعد به بهره‌برداری رسید.
- دکتر امیرحسین راد، ساکن برج الهیه: کیفیت اجرا در جزئیاتی مانند عایق صوتی پنجره‌ها و نور غیرمستقیم راهروها دیده می‌شود.
- مریم دلاور، مدیر مجموعه ویلاهای دریاکنار: همکاری با تیم طراحی حرفه‌ای بود و ایده‌ها به زبان معماری ترجمه شد.
جزئیات بیشتر در صفحه اصلی سایت (/) در بخش نظرات مشتریان آمده است.
## [fallback-expected] هزینه ساخت هر متر مربع چقدر است؟
*موضوع: canary — 9.7s — منابع: آمار و افتخارات — /، اجرا و کنترل کیفیت — /investment، مدیریت پروژه — /services، شفافیت کامل — /about*
متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید.
+191
View File
@@ -0,0 +1,191 @@
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفه‌جویی در تأخیر)
۲) بازیابی ترکیبی با چند نوع‌واژه (اختیاری)
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
"""
import logging
import re
import json as _json
from typing import Annotated, List, Optional
from typing_extensions import TypedDict
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from . import content as content_mod
from .config import settings
from .retrieval import retrieve_final
log = logging.getLogger(__name__)
FALLBACK_MESSAGE = (
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
"برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید."
)
LLM_UNAVAILABLE = (
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چت‌بات تنظیم کنید."
)
_llm = None
def get_llm() -> ChatOpenAI | None:
"""LLM تولید — اگر پیکربندی نشده باشد None برمی‌گرداند."""
global _llm
if _llm is None and settings.llm_base_url:
_llm = ChatOpenAI(
model=settings.llm_model,
base_url=settings.llm_base_url,
api_key=settings.llm_api_key or "not-needed",
temperature=0,
streaming=True,
)
return _llm
class GraphState(TypedDict):
messages: Annotated[List, add_messages]
documents: List[str]
contextualized_query: Optional[str]
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
def contextualize_query(question: str, history: list) -> str:
"""ضمیرها و اشاره‌های پرسش را با تاریخچه گفتگو مشخص می‌کند («اون دومی» → موضوع واقعی)."""
model = get_llm()
if model is None or not history:
return question
try:
history_text = "\n".join(
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
for m in history[-6:]
)
prompt = (
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
"- ضمیرها و اشاره‌ها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
"- فقط متن پرسش بازنویسی‌شده را برگردان؛ هیچ توضیحی ننویس.\n\n"
f"تاریخچه گفتگو:\n{history_text}\n\n"
f"پرسش کاربر: {question}\n\n"
"پرسش مستقل:"
)
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
if not rewritten or len(rewritten) > len(question) * 4:
return question
if rewritten != question:
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
return rewritten
except Exception as e:
log.warning("Contextualization failed: %s", e)
return question
def expand_query(query: str) -> list[str]:
"""گسترش پرسش به چند نوع‌واژه (اختیاری — پیش‌فرض خاموش برای پیکره کوچک)."""
model = get_llm()
if model is None:
return [query]
try:
prompt = (
"برای جست‌وجو در محتوای سایت یک گروه ساختمانی، ۳ نوع‌واژه جایگزین برای این پرسش بساز: "
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
f"پرسش: {query}"
)
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
text = resp.content if isinstance(resp.content, str) else ""
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
if match:
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
if variants:
log.info("Query variants: %s", [query] + variants)
return [query] + variants
except Exception as e:
log.warning("Query expansion failed: %s", e)
return [query]
# --- گام ۲: بازیابی ---
def retrieve_node(state: GraphState) -> dict:
messages = state.get("messages", [])
if not messages:
return {"documents": []}
question = messages[-1].content
history = messages[:-1]
query = contextualize_query(question, history) if settings.contextualize_enabled else question
variants = expand_query(query) if settings.query_expansion else [query]
docs = retrieve_final(query, variants)
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
return {
"documents": doc_texts,
"contextualized_query": query if query != question else None,
}
# --- گام ۳: تولید پاسخ ---
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
reminder = (
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
if contextualized_query else ""
)
return (
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ می‌دهی.\n"
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
f"--- اسناد بازیابی‌شده مرتبط با پرسش ---\n{docs_block}\n"
f"{reminder}"
"--- قوانین پاسخ ---\n"
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ به‌هیچ‌وجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفه‌ای و محترمانه داشته باشد.\n"
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در داده‌ها آمده است."
)
def generate_node(state: GraphState) -> dict:
messages = state.get("messages", [])
question = messages[-1].content if messages else ""
model = get_llm()
if model is None:
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
documents = state.get("documents") or []
system = build_system_prompt(documents, state.get("contextualized_query"))
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
history = messages[:-1]
reply = model.invoke(
[SystemMessage(content=system), *history, HumanMessage(content=question)],
config={"tags": ["generate"]},
)
return {"messages": [reply]}
# --- گراف ---
memory = MemorySaver()
workflow = StateGraph(GraphState)
workflow.add_node("retrieve", retrieve_node)
workflow.add_node("generate", generate_node)
workflow.set_entry_point("retrieve")
workflow.add_edge("retrieve", "generate")
workflow.add_edge("generate", END)
graph = workflow.compile(checkpointer=memory)
+70
View File
@@ -0,0 +1,70 @@
"""تست‌های خالص (بدون مدل/دیتابیس) برای ماژول‌های چت‌بات — اجرا: python test_pure.py"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
from chatbot.persian import normalize, tokenize
docs = [
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
]
chunks = chunk_documents(docs)
ids = [c["doc_id"] for c in chunks]
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
art = [c for c in chunks if c["type"] == "article"]
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
c = chunks[0]
assert c["content_hash"] == chunk_content_hash(c)
c2 = dict(c, title="عنوان عوض شد")
# hash فیلد کپی‌شده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
assert chunk_content_hash(c2) != c["content_hash"]
chunks2 = chunk_documents(docs)
assert [x["doc_id"] for x in chunks2] == ids
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
settings_dsn = chatbot_dsn()
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
assert "+psycopg" not in settings_dsn
# نرمال‌سازی و توکن‌سازی فارسی
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
# استخراج متن TipTap و فرمت قدیمی
tiptap_doc = {
"type": "doc",
"content": [
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
{"type": "paragraph", "content": [
{"type": "text", "text": "این برج در تهران "},
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
{"type": "hardBreak"},
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
]},
{"type": "bulletList", "content": [
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
]},
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
],
}
blocks = tiptap_blocks(tiptap_doc)
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
assert body_to_text(["الف", "ب"]) == "الف\nب"
assert body_to_text(None) == ""
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
+217
View File
@@ -0,0 +1,217 @@
"""ذخیره‌ساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
نگهداری می‌شوند تا دیتابیس سایت فقط‌خواندنی بماند. همگام‌سازی افزایشی است:
فقط سطرهای جدید/تغییریافته امبد می‌شوند (کلید: doc_id پایدار + hash محتوا).
"""
import hashlib
import json
import logging
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from .config import settings
log = logging.getLogger(__name__)
def _strip_driver(dsn: str) -> str:
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
def chatbot_dsn() -> str:
"""DSN دیتابیس اختصاصی چت‌بات — از DATABASE_URL با تعویض نام دیتابیس ساخته می‌شود."""
parts = urlsplit(_strip_driver(settings.database_url))
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
def _admin_dsn() -> str:
"""DSN دیتابیس postgres برای ساخت دیتابیس چت‌بات (فقط یک‌بار)."""
parts = urlsplit(_strip_driver(settings.database_url))
return urlunsplit(parts._replace(path="/postgres"))
def ensure_schema() -> None:
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا می‌شود."""
import psycopg
from psycopg import errors as pg_errors
try:
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
try:
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
except pg_errors.DuplicateDatabase:
pass
except Exception as e:
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS chatbot_chunks (
doc_id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
model TEXT NOT NULL,
content JSONB NOT NULL,
embedding vector({settings.embedding_dim}) NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
"""
)
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
"""همگام‌سازی افزایشی قطعات با جدول pgvector.
- سطرهایی که دیگر وجود ندارند حذف می‌شوند
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert می‌شوند
- force=True همه سطرها را از نو امبد می‌کند
خروجی: آمار عملیات برای لاگ.
"""
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .embeddings import get_embeddings
model = settings.embedding_model
wanted = {c["doc_id"]: c for c in chunks}
# row_factory لازم است: پایین‌تر row["model"]/row["doc_id"] خوانده می‌شود و
# پیش‌فرض psycopg3 تاپل برمی‌گرداند (خطای tuple indices must be integers).
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) as conn:
existing: dict[str, str] = {}
if not force:
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
if row["model"] == model:
existing[row["doc_id"]] = row["content_hash"]
else:
conn.execute("DELETE FROM chatbot_chunks")
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
if to_delete:
conn.execute(
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
[to_delete],
)
to_upsert = [
c for c in chunks
if existing.get(c["doc_id"]) != c["content_hash"]
]
inserted = updated = 0
if to_upsert:
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
for chunk, vec in zip(to_upsert, vectors):
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
conn.execute(
"""
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
VALUES (%s, %s, %s, %s, %s::vector)
ON CONFLICT (doc_id) DO UPDATE SET
content_hash = EXCLUDED.content_hash,
model = EXCLUDED.model,
content = EXCLUDED.content,
embedding = EXCLUDED.embedding
""",
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
)
if chunk["doc_id"] in existing:
updated += 1
else:
inserted += 1
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
"unchanged": len(wanted) - len(to_upsert)}
log.info("Vector sync: %s", stats)
return stats
def fetch_all_chunks() -> list[dict]:
"""همه قطعات ذخیره‌شده (برای ساخت BM25 در حافظه)."""
import psycopg
from psycopg.rows import dict_row
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) as conn:
rows = conn.execute(
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
).fetchall()
return [
{
"doc_id": r["doc_id"],
"content_hash": r["content_hash"],
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
}
for r in rows
]
def vector_count() -> int:
"""تعداد بردارهای ذخیره‌شده — برای healthz (خطا → ۰)."""
try:
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
except Exception:
return 0
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
"""
try:
import psycopg
from psycopg.rows import dict_row as _dict_row
from .embeddings import get_embeddings
vec = get_embeddings().embed_query(query)
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
rows = conn.execute(
f"""
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
FROM chatbot_chunks
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(literal, literal, top_k),
).fetchall()
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
except Exception as e:
log.warning("Dense search failed (%s) — falling back to keyword only", e)
return []
def _row_to_doc(row: Any) -> dict:
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
return {
"doc_id": row["doc_id"],
"text": content.get("text", ""),
"type": content.get("type", ""),
"title": content.get("title", ""),
"url": content.get("url", ""),
}
def chunk_content_hash(chunk: dict) -> str:
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید به‌روز شود."""
payload = json.dumps(
{k: chunk[k] for k in ("text", "type", "title", "url")},
ensure_ascii=False, sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
+6
View File
@@ -9,6 +9,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# FFmpeg — نرمال‌سازی رسانه به WebP برای ورودی‌هایی که Pillow نمی‌خواند
# (ویدیو → WebP متحرک، فرمت‌های تصویری خاص). مسیر Pillow به آن وابسته نیست.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY app ./app COPY app ./app
COPY alembic.ini ./ COPY alembic.ini ./
COPY migrations ./migrations COPY migrations ./migrations
+192 -79
View File
@@ -3,8 +3,13 @@
import csv import csv
import ipaddress import ipaddress
import io import io
import json
import os
import re import re
import shutil
import socket import socket
import subprocess
import tempfile
import urllib.request import urllib.request
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -17,9 +22,10 @@ from pydantic import BaseModel
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.concurrency import run_in_threadpool
from .. import models, schemas from .. import models, schemas
from ..auth import create_access_token, get_current_admin, hash_password, verify_password from ..auth import create_access_token, get_current_admin, get_user_from_token, hash_password, oauth2_scheme, verify_password
from ..config import settings from ..config import settings
from ..database import get_db from ..database import get_db
from ..rate_limit import clear as clear_rate_limit from ..rate_limit import clear as clear_rate_limit
@@ -113,7 +119,46 @@ from ..schemas import (
auth_router = APIRouter() auth_router = APIRouter()
# روتر محافظت‌شده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند # روتر محافظت‌شده — همه مسیرهای آن فقط با توکن معتبر قابل دسترسی هستند
router = APIRouter(dependencies=[Depends(get_current_admin)]) async def admin_access_guard(
request: Request,
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> models.AdminUser:
"""نقش‌گارد — 'admin' همه‌کاره؛ 'creator' فقط مقاله‌نویسی: ساخت پیش‌نویس،
خواندن/ویرایش پیش‌نویس خودش، و آپلود تصویر. بقیهٔ مسیرهای مدیریتی ۴۰۳."""
user = get_user_from_token(token, db)
if user.role == "admin":
return user
if user.role != "creator":
raise HTTPException(status_code=403, detail="نقش ناشناخته")
path = request.url.path.rstrip("/")
method = request.method
# آپلود تصویر مقاله‌ها — مجاز
if path in ("/api/admin/upload", "/api/admin/import-image") and method == "POST":
return user
# فهرست و ساخت مقاله — مجاز (ساخت همیشه پیش‌نویس است و نویسنده‌اش ثبت می‌شود)
if path == "/api/admin/articles" and method in ("GET", "POST"):
return user
# خواندن/ویرایش پیش‌نویس خودِ نویسنده — مالکیت در هندلر هم دوباره چک می‌شود
m = re.fullmatch(r"/api/admin/articles/(\d+)", path)
if m and method in ("GET", "PUT", "PATCH"):
article = db.get(models.Article, int(m.group(1)))
if article is not None and article.author_id == user.id:
return user
# ۴۰۴ نه ۴۰۳ — مقاله‌های بقیه برای نویسنده اصلاً وجود ندارند
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
raise HTTPException(status_code=403, detail="دسترسی نویسنده محدود است")
router = APIRouter(
dependencies=[
Depends(get_current_admin),
Depends(admin_access_guard),
]
)
@auth_router.post("/auth/login", response_model=TokenOut) @auth_router.post("/auth/login", response_model=TokenOut)
@@ -127,7 +172,7 @@ def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
detail="نام کاربری یا رمز عبور اشتباه است", detail="نام کاربری یا رمز عبور اشتباه است",
) )
clear_rate_limit(request) clear_rate_limit(request)
return TokenOut(access_token=create_access_token(user.username)) return TokenOut(access_token=create_access_token(user.username, user.role), role=user.role)
@auth_router.post("/auth/change-password") @auth_router.post("/auth/change-password")
@@ -144,13 +189,21 @@ def change_password(
return {"ok": True} return {"ok": True}
def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None: def register_crud(
path: str, model, out_schema, create_schema, patch_schema=None,
creator_scoped: bool = False,
) -> None:
"""ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی، """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
حذف و ترتیب‌بندی) روی روتر مدیریت.""" حذف و ترتیب‌بندی) روی روتر مدیریت.
creator_scoped=True (فقط مقاله‌ها): نقش creator فقط رکوردهای خودش را می‌بیند،
ساختش همیشه پیش‌نویس است و ویرایشش هرگز منتشر نمی‌شود."""
@router.get(f"/{path}", response_model=list[out_schema]) @router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db)): def list_items(db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
query = db.query(model) query = db.query(model)
if creator_scoped and admin.role == "creator":
query = query.filter(model.author_id == admin.id)
if hasattr(model, "position"): if hasattr(model, "position"):
query = query.order_by(model.position, model.id) query = query.order_by(model.position, model.id)
else: else:
@@ -158,8 +211,13 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return query.all() return query.all()
@router.post(f"/{path}", response_model=out_schema, status_code=201) @router.post(f"/{path}", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)): def create_item(payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = model(**payload.model_dump()) item = model(**payload.model_dump())
if creator_scoped:
# نویسنده ثبت می‌شود؛ ساخت توسط creator همیشه پیش‌نویس است
item.author_id = admin.id
if admin.role == "creator":
item.status = "draft"
if hasattr(model, "position"): if hasattr(model, "position"):
# آیتم جدید در انتهای لیست قرار می‌گیرد # آیتم جدید در انتهای لیست قرار می‌گیرد
item.position = (db.query(func.max(model.position)).scalar() or 0) + 1 item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
@@ -173,19 +231,26 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return item return item
@router.get(f"/{path}/{{item_id}}", response_model=out_schema) @router.get(f"/{path}/{{item_id}}", response_model=out_schema)
def get_item(item_id: int, db: Session = Depends(get_db)): def get_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
# مقالهٔ بقیه برای creator وجود ندارد
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return item return item
@router.put(f"/{path}/{{item_id}}", response_model=out_schema) @router.put(f"/{path}/{{item_id}}", response_model=out_schema)
def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db)): def update_item(item_id: int, payload: create_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
for key, value in payload.model_dump().items(): for key, value in payload.model_dump().items():
setattr(item, key, value) setattr(item, key, value)
if creator_scoped and admin.role == "creator":
item.status = "draft" # نویسنده حق انتشار ندارد
try: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:
@@ -197,13 +262,17 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
if patch_schema is not None: if patch_schema is not None:
@router.patch(f"/{path}/{{item_id}}", response_model=out_schema) @router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)): def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
"""ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده).""" """ویرایش جزئی — فقط فیلدهای ارسال‌شده به‌روزرسانی می‌شوند (برای ویرایش زنده)."""
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
if creator_scoped and admin.role == "creator" and item.author_id != admin.id:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
for key, value in payload.model_dump(exclude_unset=True).items(): for key, value in payload.model_dump(exclude_unset=True).items():
setattr(item, key, value) setattr(item, key, value)
if creator_scoped and admin.role == "creator":
item.status = "draft" # نویسنده حق انتشار ندارد
try: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:
@@ -213,7 +282,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return item return item
@router.post(f"/{path}/reorder", response_model=list[out_schema]) @router.post(f"/{path}/reorder", response_model=list[out_schema])
def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)): def reorder_items(payload: ReorderIn, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
if creator_scoped and admin.role == "creator":
raise HTTPException(status_code=403, detail="ترتیب مقاله‌ها فقط توسط مدیر تغییر می‌کند")
"""ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی.""" """ذخیره ترتیب جدید آیتم‌ها بر اساس لیست idهای ارسالی."""
if not hasattr(model, "position"): if not hasattr(model, "position"):
raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند") raise HTTPException(status_code=400, detail="این منبع از ترتیب‌بندی پشتیبانی نمی‌کند")
@@ -227,7 +298,9 @@ def register_crud(path: str, model, out_schema, create_schema, patch_schema=None
return db.query(model).order_by(model.position, model.id).all() return db.query(model).order_by(model.position, model.id).all()
@router.delete(f"/{path}/{{item_id}}", status_code=204) @router.delete(f"/{path}/{{item_id}}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)): def delete_item(item_id: int, db: Session = Depends(get_db), admin: models.AdminUser = Depends(get_current_admin)):
if creator_scoped and admin.role == "creator":
raise HTTPException(status_code=403, detail="حذف مقاله فقط توسط مدیر انجام می‌شود")
item = db.get(model, item_id) item = db.get(model, item_id)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="رکورد یافت نشد") raise HTTPException(status_code=404, detail="رکورد یافت نشد")
@@ -244,7 +317,7 @@ register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOu
register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch) register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch) register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch) register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch) register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch, creator_scoped=True)
register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch) register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch) register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch) register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
@@ -263,41 +336,106 @@ register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch) register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد # رسانه آپلودی به Object Storage (MinIO) — هر ورودی قابل‌رمزگشایی به WebP «تک‌فریمی» نرمال می‌شود:
ALLOWED_IMAGE_TYPES = { # هیچ آپلودی هرگز متحرک ذخیره نمی‌شود — منبع متحرک (gif/apng/webp متحرک/ویدیو) از فریم اول
"image/jpeg": "jpg", # پوستر می‌گیرد؛ انیمیشن در کارت‌ها و اسلایدرها سوسو می‌زند و مطلوب نیست.
"image/png": "png", # URL نسبی برمی‌گردد تا بک‌اند ذخیره‌سازی قابل تعویض باشد.
"image/webp": "webp", MAX_DIMENSION = 2000 # سقف ضلع تصویر — ابعاد بزرگ اصلی‌ترین عامل کندی سایت است
"image/gif": "gif", WEBP_QUALITY = 82
"image/avif": "avif", WEBP_CONTENT_TYPE = "image/webp"
}
# بهینه‌سازی در زمان آپلود — ابعاد بزرگ و فشرده‌سازی ضعیف، اصلی‌ترین عامل کندی سایت است SUPPORTED_MEDIA_DETAIL = "فرمت پشتیبانی نمی‌شود — فقط تصویر یا ویدیویی که بتوان به WebP تبدیل کرد مجاز است"
MAX_DIMENSION = 2000
REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]: def _to_webp_mode(img: Image.Image) -> Image.Image:
"""تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده می‌کند؛ """ذخیرهٔ WebP در Pillow فقط RGB/RGBA/L را می‌پذیرد — مدهای دیگر با حفظ شفافیت تبدیل می‌شوند."""
اگر خروجی بزرگ‌تر از اصلی شد، همان اصلی برگردد. (gif/avif دست‌نخورده)""" if img.mode in ("RGB", "RGBA", "L"):
img = Image.open(io.BytesIO(data)) return img
has_alpha = img.mode in ("LA", "PA") or (img.mode == "P" and "transparency" in img.info)
return img.convert("RGBA" if has_alpha else "RGB")
def _webp_still(img: Image.Image) -> tuple[bytes, int, int]:
"""فریم اول → WebP تک‌فریمی؛ چرخش EXIF اعمال، متادیتا حذف و سقف ضلع ۲۰۰۰ پیکسل رعایت می‌شود."""
if getattr(img, "n_frames", 1) > 1:
img.seek(0)
img = ImageOps.exif_transpose(img) img = ImageOps.exif_transpose(img)
orig_w, orig_h = img.size
if max(img.size) > MAX_DIMENSION: if max(img.size) > MAX_DIMENSION:
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS) img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
img = _to_webp_mode(img)
buf = io.BytesIO() buf = io.BytesIO()
if content_type == "image/jpeg": img.save(buf, "WEBP", quality=WEBP_QUALITY, method=4)
if img.mode not in ("RGB", "L"): return buf.getvalue(), *img.size
img = img.convert("RGB")
img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
elif content_type == "image/png": def _ffprobe_video(path: str) -> dict | None:
img.save(buf, "PNG", optimize=True) """استریم ویدیوی اول با ffprobe — None یعنی ویدیویی در فایل نیست (رسانهٔ تصویری نیست)."""
else: ffprobe = shutil.which("ffprobe")
img.save(buf, "WEBP", quality=82, method=4) if ffprobe is None:
optimized = buf.getvalue() return None
if len(optimized) < len(data): try:
return optimized, content_type, *img.size proc = subprocess.run(
return data, content_type, orig_w, orig_h [ffprobe, "-v", "error", "-print_format", "json", "-show_streams", path],
capture_output=True, timeout=20,
)
except (subprocess.TimeoutExpired, OSError):
return None
if proc.returncode != 0:
return None
try:
streams = json.loads(proc.stdout or "{}").get("streams") or []
except json.JSONDecodeError:
return None
return next((s for s in streams if s.get("codec_type") == "video"), None)
def _run_ffmpeg(args: list[str]) -> None:
try:
proc = subprocess.run(args, capture_output=True, timeout=60)
except subprocess.TimeoutExpired as exc:
raise HTTPException(status_code=415, detail="تبدیل رسانه بیش از حد طول کشید") from exc
except OSError as exc:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL) from exc
if proc.returncode != 0:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
def _ffmpeg_webp(data: bytes) -> tuple[bytes, int | None, int | None]:
"""مسیر FFmpeg — ورودی‌هایی که Pillow نمی‌خواند: ویدیو و فرمت‌های تصویری خاص.
خروجی همیشه تک‌فریم (پوستر) است — ویدیوی متحرک در صفحه لوپ می‌شود و سوسو می‌زند."""
if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "src")
dst = os.path.join(tmp, "out.webp")
with open(src, "wb") as fh:
fh.write(data)
if _ffprobe_video(src) is None:
raise HTTPException(status_code=415, detail=SUPPORTED_MEDIA_DETAIL)
ff = shutil.which("ffmpeg") or "ffmpeg"
_run_ffmpeg([ff, "-y", "-v", "error", "-i", src, "-frames:v", "1",
"-vf", f"scale='min({MAX_DIMENSION},iw)':-2:flags=lanczos",
"-c:v", "libwebp", "-q:v", str(WEBP_QUALITY), "-map_metadata", "-1", dst])
with open(dst, "rb") as fh:
out = fh.read()
# ffprobe روی WebP عرض/ارتفاع صفر برمی‌گرداند — ابعاد از خود خروجی با Pillow
try:
width, height = Image.open(io.BytesIO(out)).size
except Exception:
width = height = None
return out, width, height
def _normalize_media(data: bytes) -> tuple[bytes, int | None, int | None]:
"""هر ورودی قابل‌رمزگشایی → WebP تک‌فریمی. اول Pillow (دقیق‌ترین برای EXIF/شفافیت)؛
اگر رمزگشایی نشد، FFmpeg (پوستر ویدیو و فرمت‌های خاص). هیچ‌کدام نشد → ۴۱۵."""
try:
return _webp_still(Image.open(io.BytesIO(data)))
except HTTPException:
raise
except Exception:
pass
return _ffmpeg_webp(data)
def _readable_key(name: str, ext: str) -> str: def _readable_key(name: str, ext: str) -> str:
@@ -313,10 +451,9 @@ def _readable_key(name: str, ext: str) -> str:
return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}" return f"{datetime.now(timezone.utc):%Y/%m}/{base}.{ext}"
def _store_image(data: bytes, content_type: str, name: str) -> str: def _store_image(data: bytes, name: str) -> str:
"""ذخیره تصویر در Object Storage با کلید خوانا — URL نسبی برمی‌گردد.""" """ذخیرهٔ WebP در Object Storage با کلید خوانا — URL نسبی برمی‌گردد."""
ext = ALLOWED_IMAGE_TYPES[content_type] key = _readable_key(name, "webp")
key = _readable_key(name, ext)
s3 = boto3.client( s3 = boto3.client(
"s3", "s3",
endpoint_url=settings.s3_endpoint, endpoint_url=settings.s3_endpoint,
@@ -325,7 +462,7 @@ def _store_image(data: bytes, content_type: str, name: str) -> str:
region_name="us-east-1", region_name="us-east-1",
) )
try: try:
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type) s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=WEBP_CONTENT_TYPE)
except Exception: except Exception:
raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود") raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
return f"{settings.media_public_base}/{key}" return f"{settings.media_public_base}/{key}"
@@ -337,14 +474,6 @@ async def upload_media(
name: str = Form(""), name: str = Form(""),
_admin: models.AdminUser = Depends(get_current_admin), _admin: models.AdminUser = Depends(get_current_admin),
): ):
content_type = (file.content_type or "").lower()
ext = ALLOWED_IMAGE_TYPES.get(content_type)
if ext is None:
raise HTTPException(
status_code=415,
detail="فرمت فایل پشتیبانی نمی‌شود (مجاز: jpg، png، webp، gif، avif)",
)
max_bytes = settings.media_max_upload_mb * 1024 * 1024 max_bytes = settings.media_max_upload_mb * 1024 * 1024
data = await file.read() data = await file.read()
if len(data) == 0: if len(data) == 0:
@@ -355,23 +484,19 @@ async def upload_media(
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است", detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
) )
width = height = None # هر ورودی قابل‌رمزگشایی (تصویر رایج/خاص، متحرک، ویدیو) → WebP نرمال — روی ترد،
if content_type in REENCODE_TYPES: # تا رمزگذاری فایل حجیم رویداد‌لوپ و بقیهٔ درخواست‌ها را قفل نکند
try: data, width, height = await run_in_threadpool(_normalize_media, data)
data, content_type, width, height = _optimize_image(data, content_type)
except Exception:
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره می‌شود
pass
# نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند # نام خوانا: نام ارسالی فرم، وگرنه نام اصلی فایل بدون پسوند
display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0] display_name = (name or "").strip() or (file.filename or "").rsplit(".", 1)[0]
url = _store_image(data, content_type, display_name) url = _store_image(data, display_name)
return { return {
"url": url, "url": url,
"key": url.removeprefix(f"{settings.media_public_base}/"), "key": url.removeprefix(f"{settings.media_public_base}/"),
"size": len(data), "size": len(data),
"content_type": content_type, "content_type": WEBP_CONTENT_TYPE,
"width": width, "width": width,
"height": height, "height": height,
} }
@@ -414,15 +539,7 @@ async def import_image(
) )
try: try:
with urllib.request.urlopen(request, timeout=15) as response: with urllib.request.urlopen(request, timeout=15) as response:
content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip().lower()
if content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=415,
detail="فرمت تصویر پشتیبانی نمی‌شود (مجاز: jpg، png، webp، gif، avif)",
)
data = response.read(max_bytes + 1) data = response.read(max_bytes + 1)
except HTTPException:
raise
except Exception: except Exception:
raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود") raise HTTPException(status_code=502, detail="دانلود تصویر از نشانی ناموفق بود")
if len(data) == 0: if len(data) == 0:
@@ -433,22 +550,18 @@ async def import_image(
detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است", detail=f"حجم تصویر بیش از {settings.media_max_upload_mb} مگابایت است",
) )
width = height = None # هدر Content-Type دروغ می‌گوید — خود بدنه رمزگشایی و به WebP نرمال می‌شود (روی ترد)
if content_type in REENCODE_TYPES: data, width, height = await run_in_threadpool(_normalize_media, data)
try:
data, content_type, width, height = _optimize_image(data, content_type)
except Exception:
pass
# نام خوانا از مسیر نشانی — بدون پارامترهای کوئری # نام خوانا از مسیر نشانی — بدون پارامترهای کوئری
url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0] url_name = urlparse(payload.url).path.rsplit("/", 1)[-1].rsplit(".", 1)[0]
media_url = _store_image(data, content_type, url_name) media_url = _store_image(data, url_name)
return { return {
"url": media_url, "url": media_url,
"key": media_url.removeprefix(f"{settings.media_public_base}/"), "key": media_url.removeprefix(f"{settings.media_public_base}/"),
"size": len(data), "size": len(data),
"content_type": content_type, "content_type": WEBP_CONTENT_TYPE,
"width": width, "width": width,
"height": height, "height": height,
} }
+12 -7
View File
@@ -21,17 +21,14 @@ def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(username: str) -> str: def create_access_token(username: str, role: str = "admin") -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": username, "exp": expire} payload = {"sub": username, "role": role, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def get_current_admin( def get_user_from_token(token: str, db: Session) -> AdminUser:
token: str = Depends(oauth2_scheme), """decode توکن و برگرداندن کاربر — هم برای get_current_admin و هم نقش‌گارد"""
db: Session = Depends(get_db),
) -> AdminUser:
"""وابستگی محافظت‌شده — فقط با توکن JWT معتبر دسترسی می‌دهد."""
credentials_error = HTTPException( credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="احراز هویت نامعتبر است", detail="احراز هویت نامعتبر است",
@@ -50,3 +47,11 @@ def get_current_admin(
if user is None: if user is None:
raise credentials_error raise credentials_error
return user return user
def get_current_admin(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> AdminUser:
"""وابستگی محافظت‌شده — فقط با توکن JWT معتبر دسترسی می‌دهد."""
return get_user_from_token(token, db)
+2 -1
View File
@@ -26,7 +26,8 @@ class Settings(BaseSettings):
s3_bucket: str = "aramesh-media" s3_bucket: str = "aramesh-media"
# پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند # پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی می‌کند
media_public_base: str = "/media" media_public_base: str = "/media"
media_max_upload_mb: int = 10 # رندرهای معماری تا ~۳۰ مگابایت — لبه (Traefik api-bodylimit = ۴۰MB) باید همیشه بزرگ‌تر باشد
media_max_upload_mb: int = 35
# نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG # نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
site_url: str = "" site_url: str = ""
+8
View File
@@ -45,6 +45,14 @@ async def _preview_noindex(request, call_next):
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return response return response
# پاسخ‌های API هرگز کش مرورگر/CDN نشوند — دادهٔ پنل بعد از هر ذخیره فوراً fresh بماند
@app.middleware("http")
async def _api_no_store(request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api"):
response.headers["Cache-Control"] = "no-store"
return response
app.include_router(public.router, prefix="/api", tags=["عمومی"]) app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"]) app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"])
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"]) app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
+27 -1
View File
@@ -27,8 +27,16 @@ EN_COLUMNS: list[tuple[str, str, str]] = [
("projects", "year_en", "VARCHAR(64)"), ("projects", "year_en", "VARCHAR(64)"),
("projects", "area_en", "VARCHAR(64)"), ("projects", "area_en", "VARCHAR(64)"),
("projects", "floors_en", "VARCHAR(64)"), ("projects", "floors_en", "VARCHAR(64)"),
("projects", "units_en", "VARCHAR(64)"), ("projects", "land_area", "VARCHAR(64)"),
("projects", "pilot_floors", "VARCHAR(32)"),
("projects", "units_residential", "VARCHAR(32)"),
("projects", "units_commercial", "VARCHAR(32)"),
("projects", "units_office", "VARCHAR(32)"),
("projects", "client_en", "VARCHAR(128)"), ("projects", "client_en", "VARCHAR(128)"),
("projects", "project_type", "VARCHAR(128)"),
("projects", "project_type_en", "VARCHAR(128)"),
("projects", "usage", "VARCHAR(128)"),
("projects", "usage_en", "VARCHAR(128)"),
("projects", "summary_en", "TEXT"), ("projects", "summary_en", "TEXT"),
("projects", "body_en", "JSON"), ("projects", "body_en", "JSON"),
("articles", "title_en", "VARCHAR(256)"), ("articles", "title_en", "VARCHAR(256)"),
@@ -145,6 +153,24 @@ def run_ddl_migrations() -> None:
conn.execute( conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''") text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
) )
# نام نویسنده و توضیحات سئوی اختصاصی مقاله — فیلدهایی که پنل قبلاً نمایش می‌داد ولی در دیتابیس نبودند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author VARCHAR(128) NOT NULL DEFAULT ''")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description TEXT NOT NULL DEFAULT ''")
)
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description_en TEXT NOT NULL DEFAULT ''")
)
# نقش‌ها — 'admin' همه‌کاره، 'creator' فقط نویسندهٔ محتوا
conn.execute(
text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'")
)
# نویسندهٔ مقاله — نقش creator فقط مقاله‌های خودش را می‌بیند/ویرایش می‌کند
conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author_id INTEGER")
)
# متادیتای تصویر شاخص مقاله + پرسش‌وپاسخ‌های پایان مقاله (سئو) # متادیتای تصویر شاخص مقاله + پرسش‌وپاسخ‌های پایان مقاله (سئو)
conn.execute( conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''") text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS image_alt VARCHAR(256) NOT NULL DEFAULT ''")
+28 -2
View File
@@ -21,6 +21,8 @@ class AdminUser(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True) username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
# 'admin' همه‌کاره — 'creator' فقط نویسندهٔ محتوا (پیش‌نویس مقاله‌های خودش)
role: Mapped[str] = mapped_column(String(16), default='admin')
class Brand(Base): class Brand(Base):
@@ -30,6 +32,7 @@ class Brand(Base):
name: Mapped[str] = mapped_column(String(128), default="") name: Mapped[str] = mapped_column(String(128), default="")
name_en: Mapped[str] = mapped_column(String(128), default="") name_en: Mapped[str] = mapped_column(String(128), default="")
logo: Mapped[str] = mapped_column(Text, default="") logo: Mapped[str] = mapped_column(Text, default="")
logo_en: Mapped[str] = mapped_column(Text, default="")
hero: Mapped[str] = mapped_column(Text, default="") hero: Mapped[str] = mapped_column(Text, default="")
contact_bg: Mapped[str] = mapped_column(Text, default="") contact_bg: Mapped[str] = mapped_column(Text, default="")
phone: Mapped[str] = mapped_column(String(64), default="") phone: Mapped[str] = mapped_column(String(64), default="")
@@ -72,10 +75,18 @@ class Project(Base):
area_en: 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: Mapped[str] = mapped_column(String(64), default="")
floors_en: Mapped[str] = mapped_column(String(64), default="") floors_en: Mapped[str] = mapped_column(String(64), default="")
units: Mapped[str] = mapped_column(String(64), default="") land_area: Mapped[str] = mapped_column(String(64), default="")
units_en: Mapped[str] = mapped_column(String(64), default="") pilot_floors: Mapped[str] = mapped_column(String(32), default="")
units_residential: Mapped[str] = mapped_column(String(32), default="")
units_commercial: Mapped[str] = mapped_column(String(32), default="")
units_office: Mapped[str] = mapped_column(String(32), default="")
client: Mapped[str] = mapped_column(String(128), default="") client: Mapped[str] = mapped_column(String(128), default="")
client_en: Mapped[str] = mapped_column(String(128), default="") client_en: Mapped[str] = mapped_column(String(128), default="")
# مشخصات تکمیلی پروژه — به درخواست تیم: نوع پروژه و کاربری
project_type: Mapped[str] = mapped_column(String(128), default="")
project_type_en: Mapped[str] = mapped_column(String(128), default="")
usage: Mapped[str] = mapped_column(String(128), default="")
usage_en: Mapped[str] = mapped_column(String(128), default="")
summary: Mapped[str] = mapped_column(Text, default="") summary: Mapped[str] = mapped_column(Text, default="")
summary_en: Mapped[str] = mapped_column(Text, default="") summary_en: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) body: Mapped[list] = mapped_column(JSON, default=list)
@@ -108,10 +119,25 @@ class Article(Base):
# پرسش و پاسخ‌های پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage) # پرسش و پاسخ‌های پایان مقاله — [{question, answer}]؛ برای نتایج غنی سئو (FAQPage)
faqs: Mapped[list] = mapped_column(JSON, default=list) faqs: Mapped[list] = mapped_column(JSON, default=list)
faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی
author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقاله‌های ادمین NULL
# نام نویسنده (رشتهٔ آزاد) — سربرگ مقاله و اسکیمای Article (نوع Person برای سیگنال E-E-A-T)
author: Mapped[str] = mapped_column(String(128), default="")
# draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود # draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود
status: Mapped[str] = mapped_column(String(16), default="published") status: Mapped[str] = mapped_column(String(16), default="published")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
seo_keyphrase: Mapped[str] = mapped_column(String(128), default="") seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
# توضیحات سئوی اختصاصی مقاله — در تگ description و اسکیما، در اولویت بر چکیده
meta_description: Mapped[str] = mapped_column(Text, default="")
meta_description_en: Mapped[str] = mapped_column(Text, default="")
# نشانی انگلیسی مقاله — مسیر /en/blog/...؛ خالی یعنی همان اسلاگ فارسی
slug_en: Mapped[str] = mapped_column(String(128), default="", index=True)
# کلمه کلیدی هدف نسخه انگلیسی — چک‌لیست سئو
seo_keyphrase_en: Mapped[str] = mapped_column(String(128), default="")
# نام نویسنده نسخه انگلیسی
author_en: Mapped[str] = mapped_column(String(128), default="")
# هشتگ‌ها/برچسب‌ها — هر زبان جدا
tags: Mapped[list] = mapped_column(JSON, default=list)
tags_en: Mapped[list] = mapped_column(JSON, default=list)
class Testimonial(Base): class Testimonial(Base):
+78 -1
View File
@@ -29,6 +29,79 @@ def _clean_url(value: Any) -> str | None:
return stripped return stripped
def _is_junk_paragraph(node: Any) -> bool:
"""پاراگراف خالی یا فقط نویسهٔ خنثی (nbsp/فاصله) — داخل نقل‌قول حذف می‌شود."""
if not isinstance(node, dict) or node.get("type") != "paragraph":
return False
content = node.get("content") or []
if not content:
return True
return all(
isinstance(item, dict)
and item.get("type") == "text"
and not str(item.get("text", "")).replace("\xa0", " ").strip()
for item in content
)
def _lift_lists_flat(node: Any) -> list[Any]:
"""گره را به دنباله‌ای از گره‌های نرمال تبدیل می‌کند — نقل‌قول‌های داخل
آیتم فهرست به سطح خود فهرست می‌آیند تا خط مسی همهٔ نقل‌قول‌ها یک‌خط شود."""
if not isinstance(node, dict):
return [node]
flat_content: list[Any] = []
for child in node.get("content") or []:
flat_content.extend(_lift_lists_flat(child))
node["content"] = flat_content
if node.get("type") not in ("bulletList", "orderedList"):
return [node]
rebuilt: list[Any] = []
pending_items: list[Any] = []
for li in node.get("content") or []:
if not isinstance(li, dict) or li.get("type") != "listItem":
pending_items.append(li)
continue
parts: list[list[Any]] = [[]]
for child in li.get("content") or []:
if isinstance(child, dict) and child.get("type") == "blockquote":
parts.append(child)
parts.append([])
else:
parts[-1].append(child)
if len(parts) == 1:
pending_items.append(li)
continue
if parts[0]:
li["content"] = parts[0]
pending_items.append(li)
for part in parts[1:]:
if isinstance(part, dict) and part.get("type") == "blockquote":
if pending_items:
rebuilt.append({**node, "content": pending_items})
pending_items = []
rebuilt.append(part)
else:
pending_items.extend(part)
if pending_items:
rebuilt.append({**node, "content": pending_items})
return rebuilt
def _lift_doc(doc: Any) -> Any:
lifted = _lift_lists_flat(doc)
if len(lifted) == 1 and isinstance(lifted[0], dict) and lifted[0].get("type") == "doc":
return lifted[0]
content: list[Any] = []
for part in lifted:
if isinstance(part, dict) and part.get("type") == "doc":
content.extend(part.get("content") or [])
else:
content.append(part)
return {"type": "doc", "content": content}
def _walk(node: Any) -> Any: def _walk(node: Any) -> Any:
if isinstance(node, list): if isinstance(node, list):
return [_walk(item) for item in node] return [_walk(item) for item in node]
@@ -48,6 +121,9 @@ def _walk(node: Any) -> Any:
cleaned[key] = attrs cleaned[key] = attrs
else: else:
cleaned[key] = _walk(value) cleaned[key] = _walk(value)
# داخل نقل‌قول: پاراگراف‌های خالی/نویسهٔ خنثی حذف — وگرنه خط خالی اضافه رندر می‌شود
if cleaned.get("type") == "blockquote" and isinstance(cleaned.get("content"), list):
cleaned["content"] = [c for c in cleaned["content"] if not _is_junk_paragraph(c)]
return cleaned return cleaned
@@ -55,4 +131,5 @@ def sanitize_doc(doc: Any) -> Any:
"""سند TipTap را درجا پاک می‌کند (سایر ساختارها دست‌نخورده برمی‌گردند).""" """سند TipTap را درجا پاک می‌کند (سایر ساختارها دست‌نخورده برمی‌گردند)."""
if not isinstance(doc, dict): if not isinstance(doc, dict):
return doc return doc
return _walk(doc) walked = _walk(doc)
return _lift_doc(walked)
+47 -4
View File
@@ -23,6 +23,7 @@ class BrandOut(ORMModel):
name: str name: str
name_en: str = "" name_en: str = ""
logo: str logo: str
logo_en: str = ""
hero: str hero: str
contact_bg: str contact_bg: str
phone: str phone: str
@@ -57,10 +58,17 @@ class ProjectOut(ORMModel):
area_en: str = "" area_en: str = ""
floors: str floors: str
floors_en: str = "" floors_en: str = ""
units: str land_area: str = ""
units_en: str = "" pilot_floors: str = ""
units_residential: str = ""
units_commercial: str = ""
units_office: str = ""
client: str client: str
client_en: str = "" client_en: str = ""
project_type: str = ""
project_type_en: str = ""
usage: str = ""
usage_en: str = ""
summary: str summary: str
summary_en: str = "" summary_en: str = ""
body: list[str] body: list[str]
@@ -70,6 +78,7 @@ class ProjectOut(ORMModel):
class ArticleOut(ORMModel): class ArticleOut(ORMModel):
id: int id: int
slug: str slug: str
author_id: int | None = None
title: str title: str
title_en: str = "" title_en: str = ""
date: str date: str
@@ -90,6 +99,15 @@ class ArticleOut(ORMModel):
faqs_en: Optional[list[dict]] = None faqs_en: Optional[list[dict]] = None
status: str status: str
seo_keyphrase: str seo_keyphrase: str
# نام نویسنده و توضیحات سئوی اختصاصی — سربرگ مقاله، تگ description و اسکیمای Article
author: str = ""
meta_description: str = ""
meta_description_en: str = ""
slug_en: str = ""
seo_keyphrase_en: str = ""
author_en: str = ""
tags: list[str] = []
tags_en: list[str] = []
class TestimonialOut(ORMModel): class TestimonialOut(ORMModel):
@@ -414,6 +432,7 @@ class LoginIn(BaseModel):
class TokenOut(BaseModel): class TokenOut(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
role: str = "admin"
class ChangePasswordIn(BaseModel): class ChangePasswordIn(BaseModel):
@@ -425,6 +444,7 @@ class BrandIn(BaseModel):
name: str = "" name: str = ""
name_en: str = "" name_en: str = ""
logo: str = "" logo: str = ""
logo_en: str = ""
hero: str = "" hero: str = ""
contact_bg: str = "" contact_bg: str = ""
phone: str = "" phone: str = ""
@@ -451,14 +471,21 @@ class ProjectIn(BaseModel):
year: str = "" year: str = ""
year_en: str = "" year_en: str = ""
status: Literal["done", "progress"] = "done" status: Literal["done", "progress"] = "done"
land_area: str = ""
pilot_floors: str = ""
units_residential: str = ""
units_commercial: str = ""
units_office: str = ""
project_type: str = ""
project_type_en: str = ""
usage: str = ""
usage_en: str = ""
image: str = "" image: str = ""
gallery: list[str] = [] gallery: list[str] = []
area: str = "" area: str = ""
area_en: str = "" area_en: str = ""
floors: str = "" floors: str = ""
floors_en: str = "" floors_en: str = ""
units: str = ""
units_en: str = ""
client: str = "" client: str = ""
client_en: str = "" client_en: str = ""
summary: str = "" summary: str = ""
@@ -501,6 +528,14 @@ class ArticleIn(BaseModel):
faqs_en: list[dict] = [] faqs_en: list[dict] = []
status: Literal["draft", "published"] = "draft" status: Literal["draft", "published"] = "draft"
seo_keyphrase: str = Field(default="", max_length=128) seo_keyphrase: str = Field(default="", max_length=128)
seo_keyphrase_en: str = Field(default="", max_length=128)
slug_en: str = Field(default="", max_length=128)
author: str = Field(default="", max_length=128)
author_en: str = Field(default="", max_length=128)
tags: list[str] = []
tags_en: list[str] = []
meta_description: str = ""
meta_description_en: str = ""
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body) _validate_body_en = field_validator("body_en")(validate_doc_body)
@@ -807,6 +842,14 @@ class ArticlePatch(BaseModel):
faqs_en: Optional[list[dict]] = None faqs_en: Optional[list[dict]] = None
status: Optional[Literal["draft", "published"]] = None status: Optional[Literal["draft", "published"]] = None
seo_keyphrase: Optional[str] = None seo_keyphrase: Optional[str] = None
seo_keyphrase_en: Optional[str] = None
slug_en: Optional[str] = None
author: Optional[str] = None
author_en: Optional[str] = None
tags: Optional[list[str]] = None
tags_en: Optional[list[str]] = None
meta_description: Optional[str] = None
meta_description_en: Optional[str] = None
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body) _validate_body_en = field_validator("body_en")(validate_doc_body)
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn) TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
+638 -161
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
"""ستون‌های چندزبانه مقاله — اسلاگ/کلیدواژه/نویسنده انگلیسی و تگ‌های دو زبانه.
فیلدهای «نویسنده» و «متا دیسکریپشن» در ادیتور تا پیش از این نسخه یا مشترک
بودند یا بی‌اثر؛ از این نسخه هر زبان ستون خودش را دارد.
Revision ID: 0002
Revises: 0001
Create Date: 2026-10-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
articles = sa.table("articles")
columns = sa.inspect(op.get_bind()).get_columns("articles")
existing = {c["name"] for c in columns}
if "slug_en" not in existing:
op.add_column("articles", sa.Column("slug_en", sa.String(128), nullable=False, server_default=""))
if "seo_keyphrase_en" not in existing:
op.add_column("articles", sa.Column("seo_keyphrase_en", sa.String(128), nullable=False, server_default=""))
if "author_en" not in existing:
op.add_column("articles", sa.Column("author_en", sa.String(128), nullable=False, server_default=""))
if "tags" not in existing:
op.add_column("articles", sa.Column("tags", sa.JSON(), nullable=False, server_default="[]"))
if "tags_en" not in existing:
op.add_column("articles", sa.Column("tags_en", sa.JSON(), nullable=False, server_default="[]"))
_ = articles
def downgrade() -> None:
op.drop_column("articles", "tags_en")
op.drop_column("articles", "tags")
op.drop_column("articles", "author_en")
op.drop_column("articles", "seo_keyphrase_en")
op.drop_column("articles", "slug_en")
@@ -0,0 +1,31 @@
"""لوگوی انگلیسی برند — ستون logo_en در جدول brand.
نسخهٔ انگلیسی سایت لوگوی اختصاصی خودش را دارد؛ فیلد مشترک logo برای هر دو
زبان کافی نبود. بازگشت به فارسی با خالی‌بودن ستون انجام می‌شود.
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
cols = sa.inspect(op.get_bind()).get_columns("brand")
existing = {c["name"] for c in cols}
if "logo_en" not in existing:
op.add_column("brand", sa.Column("logo_en", sa.Text(), nullable=False, server_default=""))
def downgrade() -> None:
cols = sa.inspect(op.get_bind()).get_columns("brand")
if "logo_en" in {c["name"] for c in cols}:
op.drop_column("brand", "logo_en")
+102 -2
View File
@@ -404,12 +404,111 @@ def test_upload_with_readable_name(auth_client, monkeypatch):
) )
assert res.status_code == 200, res.text assert res.status_code == 200, res.text
key = res.json()["key"] key = res.json()["key"]
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر # نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + نرمال‌شدن به WebP
assert "برج-الهیه-tower" in key assert "برج-الهیه-tower" in key
assert key.endswith(".png") assert key.endswith(".webp")
assert res.json()["content_type"] == "image/webp"
assert "!" not in key assert "!" not in key
def _animated_gif_bytes() -> bytes:
from PIL import Image
import io
buf = io.BytesIO()
first = Image.new("RGB", (12, 8), (200, 120, 60))
second = Image.new("RGB", (12, 8), (60, 120, 200))
first.save(buf, "GIF", save_all=True, append_images=[second], duration=100, loop=0)
return buf.getvalue()
def test_upload_jpeg_converted_to_webp(auth_client, monkeypatch):
_mock_storage(monkeypatch)
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (3000, 1500), (10, 10, 10)).save(buf, "JPEG", quality=90)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("tower.jpg", buf.getvalue(), "image/jpeg")},
)
assert res.status_code == 200, res.text
body = res.json()
assert body["content_type"] == "image/webp"
assert body["key"].endswith(".webp")
# سقف ضلع ۲۰۰۰ پیکسل روی خروجی اعمال شده
assert body["width"] == 2000 and body["height"] == 1000
def test_upload_animated_gif_becomes_static_webp(auth_client, monkeypatch):
fake = _mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("anim.gif", _animated_gif_bytes(), "image/gif")},
)
assert res.status_code == 200, res.text
assert res.json()["content_type"] == "image/webp"
from PIL import Image
import io
stored = next(iter(fake.objects.values()))
out = Image.open(io.BytesIO(stored))
assert out.format == "WEBP"
# هیچ آپلودی هرگز متحرک ذخیره نمی‌شود — پوستر فریم اول
assert getattr(out, "n_frames", 1) == 1
def test_upload_undecodable_file_rejected(auth_client, monkeypatch):
_mock_storage(monkeypatch)
res = auth_client.post(
"/api/admin/upload",
files={"file": ("notes.txt", b"this is not a media file at all", "text/plain")},
)
assert res.status_code == 415
def test_upload_video_becomes_static_webp_poster(auth_client, monkeypatch):
# ویدیو تنها با FFmpeg قابل پذیرش است — بدون آن ۴۱۵، با آن پوستر تک‌فریمی WebP
import shutil as _shutil
import subprocess as _subprocess
import tempfile as _tempfile
import os as _os
fake = _mock_storage(monkeypatch)
if _shutil.which("ffmpeg") is None:
res = auth_client.post(
"/api/admin/upload",
files={"file": ("clip.mp4", b"\x00\x00\x00\x18ftypmp42", "video/mp4")},
)
assert res.status_code == 415
return
with _tempfile.TemporaryDirectory() as tmp:
clip = _os.path.join(tmp, "clip.mp4")
_subprocess.run(
["ffmpeg", "-y", "-v", "error", "-f", "lavfi",
"-i", "testsrc=duration=0.5:size=128x96:rate=10", clip],
check=True, capture_output=True, timeout=30,
)
with open(clip, "rb") as fh:
clip_bytes = fh.read()
res = auth_client.post(
"/api/admin/upload",
files={"file": ("clip.mp4", clip_bytes, "video/mp4")},
)
assert res.status_code == 200, res.text
assert res.json()["content_type"] == "image/webp"
from PIL import Image
import io
stored = next(iter(fake.objects.values()))
out = Image.open(io.BytesIO(stored))
# ویدیو هرگز وب متحرک نمی‌شود — در کارت‌ها سوسو نمی‌زند
assert getattr(out, "n_frames", 1) == 1
def test_upload_without_name_uses_filename(auth_client, monkeypatch): def test_upload_without_name_uses_filename(auth_client, monkeypatch):
_mock_storage(monkeypatch) _mock_storage(monkeypatch)
res = auth_client.post( res = auth_client.post(
@@ -475,6 +574,7 @@ def test_import_image_happy_path(auth_client, monkeypatch):
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"}) res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
assert res.status_code == 200, res.text assert res.status_code == 200, res.text
assert res.json()["url"].startswith("/media/") assert res.json()["url"].startswith("/media/")
assert res.json()["key"].endswith(".webp")
assert "Elahieh-pic" in res.json()["key"] assert "Elahieh-pic" in res.json()["key"]
+5
View File
@@ -6,6 +6,11 @@ WORKDIR /srv
# همهٔ وابستگی‌های سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند — # همهٔ وابستگی‌های سنگین (numpy/scikit-learn/torch/psycopg) ویل prebuilt دارند —
# بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابل‌اعتماد نیست # بدون کامپایلر؛ دسترسی apt در شبکهٔ بیلد قابل‌اعتماد نیست
COPY requirements.txt /srv/chatbot/requirements.txt COPY requirements.txt /srv/chatbot/requirements.txt
# torch روی PyPI پیش‌فرض لینوکس نسخهٔ CUDA است و کل استک nvidia را می‌آورد
# (ایمیج ~۹.۷GB روی سرور بدون GPU). نسخهٔ CPU حدود ۲۰۰MB است — همین اول نصب
# می‌شود تا pip در گام بعد آن را برآورده‌شده ببیند.
# روی ماشینی با GPU می‌توان این خط را حذف کرد.
RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu "torch>=2.2"
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
COPY . /srv/chatbot/ COPY . /srv/chatbot/
+30 -7
View File
@@ -33,18 +33,27 @@
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص) └─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
``` ```
ایندکس از محتوای PostgreSQL سایت ساخته می‌شود (`articles` منتشرشده، `projects`، `services`، ایندکس از محتوای PostgreSQL سایت ساخته می‌شود (`articles` منتشرشده + پرسش‌وپاسخ‌های
`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`، پایان مقاله، `projects` با مشخصات کامل، `services`، `faqs`، `brand`، `offices`،
`testimonials`، `timeline`، `story`). متن‌های بلند به قطعات ~۷۰۰ نویسه‌ای هم‌پوشان برش می‌خورند. `investment_models`، `process_steps`، `why_us`، `team`، `testimonials`، `timeline`،
`story`، و متن‌های مدیریت محتوا: `stats`، `hero`، `page_heroes`، `section_headings`،
`footer`، `before_after`). سطرهای جای‌گاه‌خالی (متن زیر ~۳۰ نویسه) ایندکس نمی‌شوند.
متن‌های بلند به قطعات ~۷۰۰ نویسه‌ای هم‌پوشان برش می‌خورند.
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی - **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و `aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگام‌سازی ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار (شامل url: `type:url-key:occ:chunk`)
فقط سطرهای جدید/تغییریافته را امبد می‌کند. دیتابیس و جدول در شروع سرویس خودکار ساخته می‌شوند. و `content_hash` دارد و همگام‌سازی فقط سطرهای جدید/تغییریافته را امبد می‌کند.
دیتابیس و جدول در شروع سرویس خودکار ساخته می‌شوند.
- **تیتر در متن قطعه**: در سندهای تک‌قطعه‌ای که تیترشان در متن نیست (مثلاً نام پروژه)،
تیتر به ابتدای متن می‌چسبد تا BM25 و جست‌وجوی معنایی پیدایش کنند.
- **بازسازی خودکار**: یک حلقهٔ پس‌زمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک - **بازسازی خودکار**: یک حلقهٔ پس‌زمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
می‌کند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام می‌شود می‌کند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام می‌شود
(امبد اجباری همه: `POST /chat/reindex`). (امبد اجباری همه: `POST /chat/reindex`).
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگ‌گراف) — با ری‌استارت پاک می‌شود. - **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگ‌گراف) — با ری‌استارت پاک می‌شود.
- **گزارش گفتگوها**: هر پاسخ (به‌همراه تعداد سند بازیابی‌شده، تشخیص «پاسخی نبود» و تأخیر)
در جدول `chat_logs` همان دیتابیس ذخیره می‌شود تا پرسش‌های بی‌پاسخ — یعنی شکاف‌های
دانش — قابل کشف باشند؛ `GET /chat/stats` جمع‌بندی می‌دهد.
## متغیرهای محیطی (در `.env` ریشه پروژه) ## متغیرهای محیطی (در `.env` ریشه پروژه)
@@ -75,15 +84,29 @@
| مسیر | توضیح | | مسیر | توضیح |
|---|---| |---|---|
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکن‌به‌توکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) | | `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکن‌به‌توکن پاسخ (`{"content": …}` و در پایان `{"done": true, "fallback": true|false}`) |
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) | | `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده با thread تازه | | `GET /chat/eval?message=…` | مخصوص ارزیابی — پاسخ + اسناد بازیابی‌شده با thread تازه |
| `GET /chat/stats` | آمار گفتگوها (کل، ۷ روز اخیر، نرخ پیام جایگزین)؛ فهرست پرسش‌های بی‌پاسخ اخیر فقط با هدر `X-Reindex-Token` |
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد | | `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) | | `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید. `eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
## ارزیابی
مجموعه طلایی `eval_set.json` (پرسش‌های واقعی از محتوای دیتابیس + یک کنترل مثبت برای
«نباید قیمت بسازد») با `eval_run.py` اجرا می‌شود — داخل کانتینر:
```bash
docker exec aramesh-site-chatbot-1 python /srv/chatbot/eval_run.py --out /tmp/report.md
docker exec aramesh-site-chatbot-1 cat /tmp/report.md
```
خروجی: وضعیت هر پرسش (`pass` / `miss` / `manual` / `fallback-expected` / `unexpected-fallback`)،
منابع بازیابی‌شده و زمان پاسخ — برای سنجش اثر تغییرات دانش و کشف پسرفت‌ها.
## اجرا ## اجرا
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`): با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
+32 -1
View File
@@ -130,14 +130,31 @@ async def _stream_answer(message: str, thread_id: str):
config = {"configurable": {"thread_id": thread_id}} config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]} inputs = {"messages": [HumanMessage(content=message)]}
started = time.time()
answer_parts: list[str] = []
try: try:
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"): async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
# فقط توکن‌های پاسخ نهایی — نه فراخوانی‌های کمکی (بازنویسی/گسترش پرسش) # فقط توکن‌های پاسخ نهایی — نه فراخوانی‌های کمکی (بازنویسی/گسترش پرسش)
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []): if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
text = _chunk_text(event["data"]["chunk"]) text = _chunk_text(event["data"]["chunk"])
if text: if text:
answer_parts.append(text)
yield _sse({"content": text, "done": False}) yield _sse({"content": text, "done": False})
yield _sse({"content": "", "done": True})
# وضعیت نهایی گراف: تعداد اسناد بازیابی‌شده — برای تشخیص «پاسخی نبود»
n_docs = 0
try:
state = await pipeline.graph.aget_state(config)
n_docs = len(state.values.get("documents") or [])
except Exception as e:
log.warning("Final state fetch failed: %s", e)
answer = "".join(answer_parts)
is_fallback = n_docs == 0 or answer.strip() == pipeline.FALLBACK_MESSAGE
await asyncio.to_thread(
vectorstore.log_chat, thread_id, message, answer, n_docs, is_fallback,
int((time.time() - started) * 1000),
)
yield _sse({"content": "", "done": True, "fallback": is_fallback})
except Exception as e: except Exception as e:
log.exception("Streaming failed") log.exception("Streaming failed")
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True}) yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
@@ -189,6 +206,20 @@ async def eval_question(message: str = Query(...)):
} }
@api.get("/chat/stats")
async def chat_stats(request: Request):
"""آمار گفتگوها — جمع‌بندی برای همه؛ پرسش‌های بی‌پاسخ اخیر فقط با توکن."""
stats = await asyncio.to_thread(vectorstore.chat_stats)
payload = {
"total_logged": stats["total_logged"],
"last_7d": stats["last_7d"],
"fallback_7d": stats["fallback_7d"],
}
if settings.reindex_token and request.headers.get("x-reindex-token") == settings.reindex_token:
payload["recent_fallback_questions"] = stats["recent_fallback_questions"]
return payload
@api.post("/chat/reindex") @api.post("/chat/reindex")
async def reindex(request: Request): async def reindex(request: Request):
"""همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود.""" """همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود."""
+5 -1
View File
@@ -1,5 +1,6 @@
"""تنظیمات سرویس چت‌بات — از متغیرهای محیطی یا فایل .env خوانده می‌شود.""" """تنظیمات سرویس چت‌بات — از متغیرهای محیطی یا فایل .env خوانده می‌شود."""
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -44,7 +45,10 @@ class Settings(BaseSettings):
rate_limit_window: int = 300 rate_limit_window: int = 300
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار می‌کند # توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار می‌کند
reindex_token: str = "" # (.env از نام CHAT_REINDEX_TOKEN استفاده می‌کند — هر دو نام پذیرفته می‌شود)
reindex_token: str = Field(
default="", validation_alias=AliasChoices("REINDEX_TOKEN", "CHAT_REINDEX_TOKEN")
)
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه) # ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
suggestions_ttl: int = 600 suggestions_ttl: int = 600
+178 -20
View File
@@ -2,7 +2,12 @@
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و
پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند. پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند؛ اما پرسش‌وپاسخ‌های
پایان مقاله‌های منتشرشده به‌عنوان سند faq ایندکس می‌شوند.
به‌جز جدول‌های محتوایی، متن‌های مدیریت محتوا (آمار، هیرو، هیرو صفحه‌ها،
تیتر بخش‌ها، فوتر، قبل/بعد) هم ایندکس می‌شوند — ادعاهای مرکزی سایت
(سال تجربه، تعداد پروژه و…) فقط آنجا ثبت شده‌اند.
""" """
import logging import logging
@@ -16,6 +21,19 @@ log = logging.getLogger(__name__)
_ARTICLE_URL = "/blog/{slug}" _ARTICLE_URL = "/blog/{slug}"
_PROJECT_URL = "/projects/{slug}" _PROJECT_URL = "/projects/{slug}"
# بخش‌های صفحه اصلی و صفحات داخلی — برای نسبت دادن URL به تیتر بخش‌ها
_SECTION_URL = {
"services": "/services",
"featured_projects": "/projects",
"articles": "/blog",
"faq": "/faq",
"investment_models": "/investment",
"process": "/investment",
}
# سندهای کوتاه‌تر از این جای‌گاه‌خالی‌اند («خدمت جدید» و امثال آن) — ایندکس نمی‌شوند
_MIN_DOC_CHARS = 30
def _psycopg_dsn(database_url: str) -> str: def _psycopg_dsn(database_url: str) -> str:
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند.""" """DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند."""
@@ -75,7 +93,9 @@ def body_to_text(body: Any) -> str:
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None: def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
text = (text or "").strip() text = (text or "").strip()
if not text: if len(text) < _MIN_DOC_CHARS:
if text:
log.info("Skipped tiny doc (%d chars, type=%s, title=%s)", len(text), dtype, title)
return None return None
return {"text": text, "type": dtype, "title": title, "url": url} return {"text": text, "type": dtype, "title": title, "url": url}
@@ -84,6 +104,19 @@ def _clean(value: Any) -> str:
return normalize(str(value or "")).strip() return normalize(str(value or "")).strip()
def _format_stat_rows(rows: list[dict[str, Any]]) -> list[str]:
"""سطرهای جدول stats → «۲۳+ سال تجربه ساخت» (پسوند تک‌حرفی می‌چسبد، بقیه با فاصله)."""
parts: list[str] = []
for r in rows:
suffix = _clean(r.get("suffix"))
value = str(r.get("value") or "").strip()
if not value:
continue
num = f"{value}{suffix}" if len(suffix) == 1 else f"{value} {suffix}".strip()
parts.append(f"{num} {_clean(r.get('label'))}".strip())
return parts
def fetch_site_documents(database_url: str) -> list[dict]: def fetch_site_documents(database_url: str) -> list[dict]:
"""همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند).""" """همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند)."""
dsn = _psycopg_dsn(database_url) dsn = _psycopg_dsn(database_url)
@@ -105,6 +138,36 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d: if d:
docs.append(d) docs.append(d)
# --- آمار و افتخارات — اعداد مرکزی سایت فقط اینجا ثبت‌اند ---
stats = _format_stat_rows(_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position"))
if stats:
d = _doc("آمار و افتخارات مجموعه: " + "؛ ".join(stats), "stats", "آمار و افتخارات", "/")
if d:
docs.append(d)
# --- هیرو صفحه اصلی (رکورد یگانه) ---
hero_rows = _fetch_rows(
dsn,
"SELECT kicker, title, title_highlight, subtitle, badge_value, badge_label FROM hero LIMIT 1",
)
if hero_rows:
h = hero_rows[0]
title = _clean(h["title"])
if _clean(h["title_highlight"]):
title = f"{title} (کلمهٔ برجسته: {_clean(h['title_highlight'])})"
lines = list(filter(None, [
f"شعار بالای تیتر: {_clean(h['kicker'])}" if _clean(h["kicker"]) else "",
f"تیتر اصلی: {title}" if title else "",
f"زیرتیتر: {_clean(h['subtitle'])}" if _clean(h["subtitle"]) else "",
(
f"نشان: {_clean(h['badge_value'])} {_clean(h['badge_label'])}"
if _clean(h["badge_value"]) or _clean(h["badge_label"]) else ""
),
]))
d = _doc("\n".join(lines), "hero", "معرفی صفحه اصلی", "/")
if d:
docs.append(d)
# --- دفاتر --- # --- دفاتر ---
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"): for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
d = _doc( d = _doc(
@@ -115,7 +178,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
docs.append(d) docs.append(d)
# --- خدمات --- # --- خدمات ---
for row in _fetch_rows(dsn, "SELECT title, desc FROM services ORDER BY position"): for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'):
d = _doc( d = _doc(
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}", f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
"service", _clean(row["title"]), "/services", "service", _clean(row["title"]), "/services",
@@ -132,29 +195,50 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d: if d:
docs.append(d) docs.append(d)
# --- پروژه‌ها --- # --- پروژه‌ها (شامل پروژه‌های نقشهٔ صفحه پروژه‌ها — فقط مشخصات، بدون توضیح) ---
for row in _fetch_rows( for row in _fetch_rows(
dsn, dsn,
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position", """
SELECT slug, title, location, year, status, area, floors, land_area, pilot_floors,
units_residential, units_commercial, units_office, client,
project_type, usage, summary, body
FROM projects ORDER BY position
""",
): ):
units = "، ".join(
f"{v} واحد {label}"
for label, col in (("مسکونی", row["units_residential"]),
("تجاری", row["units_commercial"]),
("اداری", row["units_office"]))
if (v := _clean(col)) and v != "0"
)
meta = "\n".join(filter(None, [ meta = "\n".join(filter(None, [
f"مکان: {_clean(row['location'])}" if row["location"] else "", f"مکان: {_clean(row['location'])}" if row["location"] else "",
f"سال: {_clean(row['year'])}" if row["year"] else "", f"سال: {_clean(row['year'])}" if row["year"] else "",
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیل‌شده'}", f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیل‌شده'}",
f"متراژ: {_clean(row['area'])}" if row["area"] else "", f"نوع پروژه: {_clean(row['project_type'])}" if row["project_type"] else "",
f"کاربری: {_clean(row['usage'])}" if row["usage"] else "",
f"متراژ زیربنا: {_clean(row['area'])}" if row["area"] else "",
f"عرصه: {_clean(row['land_area'])}" if row["land_area"] else "",
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "", f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
f"واحدها: {_clean(row['units'])}" if row["units"] else "", f"طبقات پیلوت: {_clean(row['pilot_floors'])}" if row["pilot_floors"] else "",
f"واحدها: {units}" if units else "",
f"کارفرما: {_clean(row['client'])}" if row["client"] else "", f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
])) ]))
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])])) text = "\n".join(filter(None, [
f"پروژه «{_clean(row['title'])}»",
meta,
_clean(row["summary"]),
body_to_text(row["body"]),
]))
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"])) d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
if d: if d:
docs.append(d) docs.append(d)
# --- مقاله‌ها (فقط منتشرشده) --- # --- مقاله‌ها (فقط منتشرشده) + پرسش‌وپاسخ‌های پایان مقاله ---
for row in _fetch_rows( for row in _fetch_rows(
dsn, dsn,
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position", "SELECT slug, title, date, category, excerpt, body, faqs FROM articles WHERE status = 'published' ORDER BY position",
): ):
text = "\n".join(filter(None, [ text = "\n".join(filter(None, [
f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "", f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "",
@@ -165,8 +249,17 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d: if d:
docs.append(d) docs.append(d)
article_url = _ARTICLE_URL.format(slug=row["slug"])
for f in row["faqs"] or []:
if not isinstance(f, dict):
continue
q, a = _clean(f.get("question")), _clean(f.get("answer"))
d = _doc(f"پرسش: {q}\nپاسخ: {a}", "faq", q, article_url)
if d:
docs.append(d)
# --- مدل‌های سرمایه‌گذاری --- # --- مدل‌های سرمایه‌گذاری ---
for row in _fetch_rows(dsn, "SELECT title, desc, points FROM investment_models ORDER BY position"): for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'):
points = "؛ ".join(_clean(p) for p in (row["points"] or [])) points = "؛ ".join(_clean(p) for p in (row["points"] or []))
d = _doc( d = _doc(
"\n".join(filter(None, [ "\n".join(filter(None, [
@@ -179,7 +272,7 @@ def fetch_site_documents(database_url: str) -> list[dict]:
docs.append(d) docs.append(d)
# --- مراحل همکاری --- # --- مراحل همکاری ---
for row in _fetch_rows(dsn, "SELECT step, title, desc FROM process_steps ORDER BY position"): for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'):
d = _doc( d = _doc(
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}", f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"process", _clean(row["title"]), "/investment", "process", _clean(row["title"]), "/investment",
@@ -187,8 +280,30 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d: if d:
docs.append(d) docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما --- # --- تیتر و توضیح بخش‌ها (مدیریت محتوا) ---
for row in _fetch_rows(dsn, "SELECT title, desc FROM why_us ORDER BY position"): for row in _fetch_rows(dsn, "SELECT key, title, lead FROM section_headings ORDER BY key"):
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
d = _doc(text, "section", _clean(row["title"]), _SECTION_URL.get(row["key"], "/"))
if d:
docs.append(d)
# --- هیرو صفحه‌های داخلی (about/services/projects/… ) ---
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM page_heroes ORDER BY key"):
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
d = _doc(text, "page_hero", _clean(row["title"]), f"/{row['key']}")
if d:
docs.append(d)
# --- قبل/بعد (بخش صفحه اصلی) ---
ba_rows = _fetch_rows(dsn, "SELECT title, lead FROM before_after LIMIT 1")
if ba_rows:
text = "\n".join(filter(None, [_clean(ba_rows[0]["title"]), _clean(ba_rows[0]["lead"])]))
d = _doc(text, "before_after", _clean(ba_rows[0]["title"]) or "قبل و بعد", "/")
if d:
docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما / فوتر ---
for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'):
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about") d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
if d: if d:
docs.append(d) docs.append(d)
@@ -201,15 +316,26 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d: if d:
docs.append(d) docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"): testimonial_rows = _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position")
for row in testimonial_rows:
d = _doc( d = _doc(
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}", f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
"testimonial", _clean(row["name"]), "/", "testimonial", _clean(row["name"]), "/",
) )
if d: if d:
docs.append(d) docs.append(d)
# سند تجمیعی — پرسش‌های طبیعی مثل «نظرات مشتریان چیست؟» به تک‌نفرها نمی‌رسند
# (بی‌ریشه‌سازی BM25: «نظرات» با «نظر» و «مشتریان» با «کارفرما» هیچ اشتراکی ندارند)
if len(testimonial_rows) >= 2:
agg = "؛ ".join(
f"{_clean(t['name'])} ({_clean(t['role'])}): {_clean(t['quote'])}"
for t in testimonial_rows
)
d = _doc(f"نظرات مشتریان و کارفرمایان پروژه‌ها:\n{agg}", "testimonial", "نظرات مشتریان", "/")
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT year, title, desc FROM timeline ORDER BY position"): for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
d = _doc( d = _doc(
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}", f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"timeline", _clean(row["title"]), "/about", "timeline", _clean(row["title"]), "/about",
@@ -226,15 +352,31 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d: if d:
docs.append(d) docs.append(d)
footer_rows = _fetch_rows(dsn, "SELECT about_text FROM footer LIMIT 1")
if footer_rows and _clean(footer_rows[0]["about_text"]):
d = _doc(
_clean(footer_rows[0]["about_text"]),
"about", "درباره مجموعه (فوتر)", "/",
)
if d:
docs.append(d)
log.info("Fetched %d documents from site database", len(docs)) log.info("Fetched %d documents from site database", len(docs))
return docs return docs
def _url_key(url: str) -> str:
"""URL → کلید یکتا برای doc_id («/projects/x» → «projects-x»)."""
return url.strip("/").replace("/", "-") or "home"
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]: def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
"""متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند. """متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند.
هر قطعه یک doc_id پایدار می‌گیرد («type:ترتیب در همان url:شماره قطعه») تا هر قطعه یک doc_id پایدار می‌گیرد («type:url-key:ترتیب در همان url:شماره قطعه») تا
همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند. همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
URL بخشی از doc_id است — بدون آن همه پروژه‌ها/مقاله‌ها که url یکتا دارند
روی همان id می‌افتند و در جدول بردار یکدیگر را بازنویسی می‌کنند.
""" """
from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_text_splitters import RecursiveCharacterTextSplitter
@@ -257,9 +399,15 @@ def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120)
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]] pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
for i, piece in enumerate(pieces, 1): for i, piece in enumerate(pieces, 1):
if len(pieces) == 1:
# تیتر باید در متن امبدشده باشد تا BM25/معنایی پیدایش کند —
# در سندهای تک‌قطعه‌ای که تیترشان در متن نیست، به ابتدای قطعه می‌چسبد
text = piece if not doc["title"] or doc["title"] in piece[:150] else f"{doc['title']}\n{piece}"
else:
text = f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}"
chunk = { chunk = {
"doc_id": f"{doc['type']}:{occ}:{i}", "doc_id": f"{doc['type']}:{_url_key(doc['url'])}:{occ}:{i}",
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}", "text": text,
"type": doc["type"], "type": doc["type"],
"title": doc["title"], "title": doc["title"],
"url": doc["url"], "url": doc["url"],
@@ -285,7 +433,7 @@ _site_context_cache: tuple[float, str] | None = None
def fetch_site_context(database_url: str, ttl: int = 120) -> str: def fetch_site_context(database_url: str, ttl: int = 120) -> str:
"""نام برند، تماس و فهرست خدمات — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند.""" """نام برند، تماس، خدمات، آمار و دفاتر — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند."""
global _site_context_cache global _site_context_cache
import time as _time import time as _time
@@ -296,6 +444,10 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
dsn = _psycopg_dsn(database_url) dsn = _psycopg_dsn(database_url)
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1") brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position") services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
stats = _format_stat_rows(
_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position")
)
offices = _fetch_rows(dsn, "SELECT city, address FROM offices ORDER BY position")
lines: list[str] = [] lines: list[str] = []
if brand: if brand:
b = brand[0] b = brand[0]
@@ -307,6 +459,12 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
] ]
if services: if services:
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services)) lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
if stats:
lines.append("آمار: " + "؛ ".join(stats))
if offices:
lines.append("دفاتر: " + "؛ ".join(
f"{_clean(o['city'])} ({_clean(o['address'])})" for o in offices
))
ctx = "\n".join(lines) ctx = "\n".join(lines)
_site_context_cache = (now, ctx) _site_context_cache = (now, ctx)
return ctx return ctx
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""اجرای مجموعه طلایی روی سرویس چت‌بات و ساخت گزارش مارک‌داون.
استفاده (داخل کانتینر یا هر جا که به سرویس دسترسی هست):
python eval_run.py --base http://localhost:8000
python eval_run.py --out eval-report.md
هر پرسش از /chat/eval سرویس می‌شود؛ پاسخ با زیررشته‌های انتظار
(پس از نرمال‌سازی فارسی) مقایسه و رتبه‌بندی می‌شود:
pass — همه/یکی از انتظارها برآورده شد
miss — انتظار برآورده نشد (پاسخ احتمالاً نادرست یا بی‌ربط)
manual — انتظار عددی تعریف نشده؛ بازبینی دستی
fallback-expected / unexpected-fallback — پیام جایگزین
"""
import argparse
import json
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
FALLBACK_MESSAGE = (
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
"برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید."
)
def ask(base: str, question: str, timeout: int = 180) -> dict:
url = f"{base}/chat/eval?{urllib.parse.urlencode({'message': question})}"
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.load(r)
def main() -> int:
ap = argparse.ArgumentParser(description="اجرای مجموعه طلایی ارزیابی چت‌بات")
ap.add_argument("--base", default="http://localhost:8000", help="نشانی سرویس چت‌بات")
ap.add_argument("--set", dest="eval_set",
default=str(Path(__file__).with_name("eval_set.json")))
ap.add_argument("--out", default="-", help="فایل خروجی گزارش (- = stdout)")
ap.add_argument("--sleep", type=float, default=0.5, help="مکث بین پرسش‌ها (ثانیه)")
args = ap.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from chatbot.persian import normalize
data = json.loads(Path(args.eval_set).read_text(encoding="utf-8"))
rows: list[dict] = []
for item in data["questions"]:
q = item["q"]
t0 = time.time()
try:
res = ask(args.base, q)
except Exception as e:
rows.append({**item, "answer": f"ERROR: {e}", "sources": [], "seconds": 0.0,
"status": "error", "fallback": False})
print(f" error: {q[:50]}", file=sys.stderr)
continue
seconds = time.time() - t0
answer = str(res.get("answer", ""))
contexts = res.get("contexts") or []
is_fallback = answer.strip() == FALLBACK_MESSAGE or not contexts
if item.get("expect_fallback"):
status = "fallback-expected" if is_fallback else "unexpected-answer"
elif is_fallback:
status = "unexpected-fallback"
else:
needles = [normalize(s) for s in item.get("expect_any", [])]
if not needles:
status = "manual"
elif any(n in normalize(answer) for n in needles):
status = "pass"
else:
status = "miss"
rows.append({**item, "answer": answer, "sources": contexts, "seconds": seconds,
"status": status, "fallback": is_fallback})
print(f" [{status}] {q[:50]}", file=sys.stderr)
time.sleep(args.sleep)
# --- گزارش ---
lines = [f"# گزارش ارزیابی چت‌بات — {time.strftime('%Y-%m-%d %H:%M')}", ""]
counts: dict[str, int] = {}
for r in rows:
counts[r["status"]] = counts.get(r["status"], 0) + 1
graded = [r for r in rows if r["status"] in ("pass", "miss", "unexpected-fallback", "unexpected-answer")]
ok = sum(1 for r in rows if r["status"] in ("pass", "fallback-expected"))
lines += [
f"- مجموع پرسش‌ها: {len(rows)} — درست: {ok} — خطا: {counts.get('miss', 0) + counts.get('unexpected-fallback', 0) + counts.get('unexpected-answer', 0)}",
f"- بازبینی دستی: {counts.get('manual', 0)} — خطای فنی: {counts.get('error', 0)}",
f"- میانگین زمان پاسخ: {sum(r['seconds'] for r in rows) / max(len(rows), 1):.1f}s",
"",
]
for r in rows:
srcs = [c.rsplit("(منبع:", 1)[-1].strip(" ()\n") for c in r["sources"]] if r["sources"] else []
lines += [
f"## [{r['status']}] {r['q']}",
f"*موضوع: {r.get('topic', '?')} — {r['seconds']:.1f}s — منابع: {'، '.join(srcs) or '—'}*",
"",
r["answer"],
"",
]
report = "\n".join(lines)
if args.out == "-":
print(report)
else:
Path(args.out).write_text(report, encoding="utf-8")
print(f"Report written to {args.out}", file=sys.stderr)
return 1 if any(r["status"] in ("miss", "unexpected-answer") for r in rows) else 0
if __name__ == "__main__":
sys.exit(main())
+26
View File
@@ -0,0 +1,26 @@
{
"description": "مجموعه طلایی ارزیابی چت‌بات — پرسش‌ها از واقعیت‌های زنده دیتابیس سایت ساخته شده‌اند. expect_any: حداقل یکی از این زیررشته‌ها (پس از نرمال‌سازی) در پاسخ باشد؛ expect_fallback: انتظار پیام جایگزین (شکاف دانش). رشته‌های خالی = بررسی دستی.",
"questions": [
{"q": "پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟", "topic": "projects", "expect_any": ["8100"]},
{"q": "پروژه آرامش - بابک کی و کجا تحویل شده؟", "topic": "projects", "expect_any": ["1405", "6200"]},
{"q": "وضعیت پروژه میرزاطاهر ۶ چطور است؟", "topic": "projects", "expect_any": ["در حال ساخت"]},
{"q": "کدام پروژه شما در محله گورتان اصفهان است؟", "topic": "projects", "expect_any": ["وحدت"]},
{"q": "پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟", "topic": "projects", "expect_any": ["29"]},
{"q": "گروه ساختمانی آرامش چند سال سابقه دارد؟", "topic": "stats", "expect_any": ["23", "1381"]},
{"q": "تا امروز چند پروژه تحویل داده‌اید؟", "topic": "stats", "expect_any": ["64"]},
{"q": "مجموع متراژ ساخت‌وساز شما چقدر است؟", "topic": "stats", "expect_any": ["111"]},
{"q": "چند خانواده در پروژه‌های شما ساکن هستند؟", "topic": "stats", "expect_any": ["500"]},
{"q": "شماره تماس شما چیست؟", "topic": "contact", "expect_any": ["09133107809"]},
{"q": "دفتر تهران کجاست؟", "topic": "offices", "expect_any": ["ولیعصر"]},
{"q": "آدرس ایمیل مجموعه چیست؟", "topic": "contact", "expect_any": ["arameshsakhtemani"]},
{"q": "دفاتر شما در چه شهرهایی است؟", "topic": "offices", "expect_any": ["نوشهر"]},
{"q": "گروه آرامش چه سالی تأسیس شده؟", "topic": "faqs", "expect_any": ["1381"]},
{"q": "زمین دارم، ساخت را از کجا شروع کنم؟", "topic": "faqs", "expect_any": []},
{"q": "شرایط مشارکت در ساخت چگونه است؟", "topic": "investment", "expect_any": []},
{"q": "مدل صندوق سرمایه‌گذاری چطور کار می‌کند؟", "topic": "investment", "expect_any": ["24"]},
{"q": "پیش‌خرید واحد یعنی چه؟", "topic": "investment", "expect_any": ["پرداخت", "شخصی‌سازی", "تحویل"]},
{"q": "خدمات گروه آرامش چیست؟", "topic": "services", "expect_any": []},
{"q": "نظرات مشتریان قبلی درباره شما چیست؟", "topic": "testimonials", "expect_any": []},
{"q": "هزینه ساخت هر متر مربع چقدر است؟", "topic": "canary", "expect_fallback": true}
]
}
+14 -2
View File
@@ -19,10 +19,22 @@ ids = [c["doc_id"] for c in chunks]
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}" assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq") faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids assert faq_ids == ["faq:faq:0:1", "faq:faq:1:1"], faq_ids
# سندهایی که url یکتا دارند (پروژه/مقاله) نباید روی هم بیفتند — url بخشی از doc_id است
two_projects = [
{"text": "پروژه الف " + "ت" * 60, "type": "project", "title": "الف", "url": "/projects/alpha"},
{"text": "پروژه ب " + "ت" * 60, "type": "project", "title": "ب", "url": "/projects/beta"},
]
pids = sorted(c["doc_id"] for c in chunk_documents(two_projects))
assert pids == ["project:projects-alpha:0:1", "project:projects-beta:0:1"], pids
# تیتر سندهای تک‌قطعه‌ای باید در متن امبدشده باشد تا جست‌وجو پیدایش کند
faq_chunks = [c for c in chunks if c["type"] == "faq"]
assert all(c["text"].startswith(c["title"]) for c in faq_chunks), [c["text"] for c in faq_chunks]
art = [c for c in chunks if c["type"] == "article"] art = [c for c in chunks if c["type"] == "article"]
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art] assert len(art) >= 2 and art[1]["doc_id"] == "article:blog-long:0:2", [x["doc_id"] for x in art]
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"] assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
c = chunks[0] c = chunks[0]
+79 -2
View File
@@ -66,6 +66,22 @@ def ensure_schema() -> None:
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops) ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
""" """
) )
# گزارش گفتگوها — حلقه بازخورد برای کشف پرسش‌های بی‌پاسخ
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_logs (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
thread_id TEXT NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
n_docs INTEGER NOT NULL DEFAULT 0,
is_fallback BOOLEAN NOT NULL DEFAULT FALSE,
latency_ms INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS chat_logs_ts_idx ON chat_logs (ts DESC)")
def sync_chunks(chunks: list[dict], force: bool = False) -> dict: def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
@@ -77,6 +93,7 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
خروجی: آمار عملیات برای لاگ. خروجی: آمار عملیات برای لاگ.
""" """
import psycopg import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from .embeddings import get_embeddings from .embeddings import get_embeddings
@@ -84,7 +101,10 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
model = settings.embedding_model model = settings.embedding_model
wanted = {c["doc_id"]: c for c in chunks} wanted = {c["doc_id"]: c for c in chunks}
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn: # row_factory لازم است: پایین‌تر row["model"]/row["doc_id"] خوانده می‌شود و
# پیش‌فرض psycopg3 تاپل برمی‌گرداند (خطای tuple indices must be integers).
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) as conn:
existing: dict[str, str] = {} existing: dict[str, str] = {}
if not force: if not force:
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall(): for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
@@ -136,8 +156,10 @@ def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
def fetch_all_chunks() -> list[dict]: def fetch_all_chunks() -> list[dict]:
"""همه قطعات ذخیره‌شده (برای ساخت BM25 در حافظه).""" """همه قطعات ذخیره‌شده (برای ساخت BM25 در حافظه)."""
import psycopg import psycopg
from psycopg.rows import dict_row
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn: with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) as conn:
rows = conn.execute( rows = conn.execute(
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id" "SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
).fetchall() ).fetchall()
@@ -162,6 +184,61 @@ def vector_count() -> int:
return 0 return 0
def log_chat(thread_id: str, question: str, answer: str, n_docs: int,
is_fallback: bool, latency_ms: int) -> None:
"""ثبت یک گفتگو در chat_logs — هرگز خطا نمی‌دهد (لاگ نباید جریان پاسخ را بشکند)."""
try:
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
conn.execute(
"""
INSERT INTO chat_logs (thread_id, question, answer, n_docs, is_fallback, latency_ms)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(thread_id, question, answer[:4000], n_docs, is_fallback, latency_ms),
)
except Exception as e:
log.warning("chat_logs insert failed: %s", e)
def chat_stats(days: int = 7) -> dict:
"""آمار تجمعی گفتگوها — بدون متن پرسش‌ها (متن‌ها فقط با توکن در /chat/stats)."""
try:
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
total = int(conn.execute("SELECT count(*) FROM chat_logs").fetchone()[0])
row = conn.execute(
"""
SELECT count(*), count(*) FILTER (WHERE is_fallback)
FROM chat_logs WHERE ts > now() - (%s || ' days')::interval
""",
(str(days),),
).fetchone()
recent_fallbacks: list[str] = []
if row and row[1]:
recent_fallbacks = [
r[0] for r in conn.execute(
"""
SELECT question FROM chat_logs
WHERE is_fallback AND ts > now() - (%s || ' days')::interval
ORDER BY ts DESC LIMIT 20
""",
(str(days),),
).fetchall()
]
return {
"total_logged": total,
"last_7d": int(row[0]) if row else 0,
"fallback_7d": int(row[1]) if row else 0,
"recent_fallback_questions": recent_fallbacks,
}
except Exception as e:
log.warning("chat_stats failed: %s", e)
return {"total_logged": 0, "last_7d": 0, "fallback_7d": 0, "recent_fallback_questions": []}
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]: def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت). """جست‌وجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
+3 -1
View File
@@ -71,7 +71,9 @@ services:
- traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le - traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers - traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)) # سئو: همهٔ صفحات عمومی از بک‌اند سرو می‌شوند تا متا در HTML اولیه تزریق شود —
# مسیرهای استاتیک دقیق‌اند (Path) تا assets/ فونت‌ها از Caddy سرو شوند
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`))
- traefik.http.routers.seo.entrypoints=websecure - traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le - traefik.http.routers.seo.tls.certresolver=le
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers - traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
+14 -6
View File
@@ -7,15 +7,23 @@
root * /srv root * /srv
encode zstd gzip encode zstd gzip
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمی‌گردند # فایل‌های هش‌دار بیلد: کش بلندمدت بی‌رحم — ولی هرگز fallback به index.html نداشته باشند؛
try_files {path} /index.html # پاسخ 200ٔ HTML برای یک مسیر /assets/* نبوده (مثلاً هنگام پنجرهٔ تعویض کانتینر در استقرار)
file_server # توسط CDN (WCDN، سیاست SMART) با همین هدر immutable یک سال کش می‌شود و اسکریپت ماژول
# را برای همه بازدیدکننده‌ها می‌شکند. فایل ناموجود در assets باید 404 بدهد، نه HTML.
# فایل‌های هش‌دار بیلد: کش بلندمدت بی‌رحم
@assets path /assets/* @assets path /assets/*
header @assets Cache-Control "public, immutable, max-age=31536000" handle @assets {
file_server
}
# مسیریابی SPA — مسیرهای ناشناختهٔ غیرasset به index.html برمی‌گردند
handle {
try_files {path} /index.html
file_server
}
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود # بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
@notassets not path /assets/* @notassets not path /assets/*
header @notassets Cache-Control "no-cache" header @notassets Cache-Control "no-cache"
header @assets Cache-Control "public, immutable, max-age=31536000"
} }
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- پیش‌بارگذاری فونت‌های اصلی — بدون این، swap فونت با تأخیر می‌آید (LCP) -->
<link rel="preload" href="/fonts/pinar/Pinar-Light.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/pinar/Pinar-Regular.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/pinar/Pinar-Bold.woff2" as="font" type="font/woff2" crossorigin />
<title>گروه ساختمانی آرامش</title> <title>گروه ساختمانی آرامش</title>
<meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژه‌های ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایه‌گذاری ساخت‌وساز." /> <meta name="description" content="گروه ساختمانی آرامش — طراحی، اجرا و مدیریت پروژه‌های ساختمانی؛ خدمات مهندسی، پیمانکاری و سرمایه‌گذاری ساخت‌وساز." />
<meta property="og:site_name" content="گروه ساختمانی آرامش" /> <meta property="og:site_name" content="گروه ساختمانی آرامش" />
+57
View File
@@ -11,6 +11,10 @@
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3", "@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3", "@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extension-table-cell": "^3.31.3",
"@tiptap/extension-table-header": "^3.31.3",
"@tiptap/extension-table-row": "^3.31.3",
"@tiptap/pm": "^3.31.3", "@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3", "@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3", "@tiptap/starter-kit": "^3.31.3",
@@ -1866,6 +1870,59 @@
"@tiptap/core": "3.31.3" "@tiptap/core": "3.31.3"
} }
}, },
"node_modules/@tiptap/extension-table": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz",
"integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.31.3.tgz",
"integrity": "sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.31.3"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.31.3.tgz",
"integrity": "sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.31.3"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.31.3.tgz",
"integrity": "sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.31.3"
}
},
"node_modules/@tiptap/extension-text": { "node_modules/@tiptap/extension-text": {
"version": "3.31.3", "version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
+4
View File
@@ -14,6 +14,10 @@
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@tiptap/extension-image": "^3.31.3", "@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3", "@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extension-table-cell": "^3.31.3",
"@tiptap/extension-table-header": "^3.31.3",
"@tiptap/extension-table-row": "^3.31.3",
"@tiptap/pm": "^3.31.3", "@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3", "@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3", "@tiptap/starter-kit": "^3.31.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+10 -2
View File
@@ -5,7 +5,8 @@ import { Preloader } from './components/Preloader';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Footer } from './components/Footer'; import { Footer } from './components/Footer';
import { FloatingContact } from './components/FloatingContact'; import { FloatingContact } from './components/FloatingContact';
import { ChatWidget } from './components/ChatWidget'; // چت‌بات موقتاً خاموش است — برای روشن‌کردن، این ایمپورت و خط رندر پایین را برگردانید
// import { ChatWidget } from './components/ChatWidget';
import { Home } from './pages/Home'; import { Home } from './pages/Home';
import { About } from './pages/About'; import { About } from './pages/About';
import { ServicesPage } from './pages/ServicesPage'; import { ServicesPage } from './pages/ServicesPage';
@@ -22,6 +23,7 @@ import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar'; import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage'; import { AdminLoginPage } from './admin/AdminLoginPage';
import { ArticleWritePage } from './admin/ArticleWritePage'; import { ArticleWritePage } from './admin/ArticleWritePage';
import { ProjectWritePage } from './admin/ProjectWritePage';
import { LangProvider, useLang } from './i18n/lang'; import { LangProvider, useLang } from './i18n/lang';
// مسیرهای عمومی یک بار تعریف می‌شوند و دو بار سرو می‌شوند: ریشه فارسی و /en انگلیسی // مسیرهای عمومی یک بار تعریف می‌شوند و دو بار سرو می‌شوند: ریشه فارسی و /en انگلیسی
@@ -69,6 +71,8 @@ function AnimatedRoutes() {
<Route path="/admin/login" element={<AdminLoginPage />} /> <Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} /> <Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} /> <Route path="/admin/write/:id" element={<ArticleWritePage />} />
<Route path="/admin/projects/new" element={<ProjectWritePage />} />
<Route path="/admin/projects/:id" element={<ProjectWritePage />} />
{FA_ROUTES} {FA_ROUTES}
<Route path="*" element={<Home />} /> <Route path="*" element={<Home />} />
{EN_ROUTES} {EN_ROUTES}
@@ -93,11 +97,14 @@ function AppChrome() {
const meta = data.seo_meta?.find((s) => s.page_key === key); const meta = data.seo_meta?.find((s) => s.page_key === key);
const brand = data.brand.name; const brand = data.brand.name;
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label; const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
// alternate انگلیسی فقط وقتی اعلام می‌شود که متای انگلیسی واقعاً ثبت شده باشد
const hasEn = !!(meta && ((meta.title_en ?? '').trim() || (meta.description_en ?? '').trim()));
applySeo({ applySeo({
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand), title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '', description: meta?.description ?? '',
path: pathname, path: pathname,
lang, lang,
hasEn,
}); });
}, [pathname, data, isAdminPage, lang]); }, [pathname, data, isAdminPage, lang]);
@@ -107,7 +114,8 @@ function AppChrome() {
<AnimatedRoutes /> <AnimatedRoutes />
{!isAdminPage && <Footer />} {!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />} {!isAdminPage && <FloatingContact />}
{!isAdminPage && <ChatWidget />} {/* چت‌بات موقتاً خاموش است — برای روشن‌کردن، این خط را از حالت کامنت خارج کنید */}
{/* {!isAdminPage && <ChatWidget />} */}
{isAuthenticated && !isAdminPage && <AdminBar />} {isAuthenticated && !isAdminPage && <AdminBar />}
</> </>
); );
+28 -3
View File
@@ -1,8 +1,10 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Link } from 'react-router-dom'
import { KeyRoundIcon, XIcon } from 'lucide-react' import { KeyRoundIcon, XIcon } from 'lucide-react'
import { useAdmin } from './AdminContext' import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { useLang } from '../i18n/lang'
const STATUS_TEXT: Record<string, string> = { const STATUS_TEXT: Record<string, string> = {
idle: 'همگام‌سازی شد', idle: 'همگام‌سازی شد',
@@ -126,9 +128,18 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */ /** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() { export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin() const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang, syncEditLang, role, canManageSite } = useAdmin()
const { lang } = useLang()
const [pwOpen, setPwOpen] = useState(false) const [pwOpen, setPwOpen] = useState(false)
// زبان ویرایش از زبان صفحهٔ در حال نمایش پیروی می‌کند — ویرایش روی /en به ستون‌های
// انگلیسی می‌رود و روی صفحات فارسی به فارسی؛ وگرنه ویرایشِ دیده‌شده در /en ناخواسته
// متن فارسی را بازنویسی می‌کرد. کلید دستی پایین فقط برای ویرایش ستون زبان دیگر
// روی همین صفحه است و با ناوبری بعدی دوباره همگام می‌شود.
useEffect(() => {
if (isEditMode) syncEditLang(lang)
}, [isEditMode, lang, syncEditLang])
return ( return (
<> <>
<div <div
@@ -140,6 +151,7 @@ export function AdminBar() {
حالت مدیریت حالت مدیریت
</span> </span>
{canManageSite && (
<button <button
type="button" type="button"
role="switch" role="switch"
@@ -160,11 +172,12 @@ export function AdminBar() {
</span> </span>
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'} {isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
</button> </button>
)}
<button <button
type="button" type="button"
onClick={toggleEditLang} onClick={toggleEditLang}
title="تعیین می‌کند ویرایش‌ها روی کدام ستون زبان ذخیره شوند" title="معمولاً خودکار با زبان صفحه همگام می‌شود — این کلید فقط برای ویرایش ستون زبان دیگر روی همین صفحه است"
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${ className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
editLang === 'en' editLang === 'en'
? 'border-copper bg-copper/10 text-copper' ? 'border-copper bg-copper/10 text-copper'
@@ -188,6 +201,16 @@ export function AdminBar() {
رمز رمز
</button> </button>
{canManageSite && (
<>
<Link
to="/admin/write"
data-admin-link
className="border border-copper/50 px-3 py-1.5 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
>
نوشتن مقاله
</Link>
<button <button
type="button" type="button"
onClick={() => void adminApi.downloadCsv('messages').catch((e) => { onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
@@ -209,6 +232,8 @@ export function AdminBar() {
> >
خبرنامه (CSV) خبرنامه (CSV)
</button> </button>
</>
)}
<button <button
type="button" type="button"
+53 -8
View File
@@ -4,17 +4,23 @@ import Swal from 'sweetalert2'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient' import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
import { ENTITIES, isTranslatable } from './entities' import { entityConfig, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error' export type SaveStatus = 'idle' | 'saving' | 'error'
/** زبان ویرایش محتوا — رابط مدیریتی فارسی می‌ماند؛ این فقط تعیین می‌کند کدام ستون زبان ذخیره شود */ /** زبان ویرایش محتوا — رابط مدیریتی فارسی می‌ماند؛ این فقط تعیین می‌کند کدام ستون زبان ذخیره شود */
export type EditLang = 'fa' | 'en' export type EditLang = 'fa' | 'en'
export type AdminRole = 'admin' | 'creator'
interface AdminContextValue { interface AdminContextValue {
isAuthenticated: boolean isAuthenticated: boolean
/** نقش کاربر واردشده — 'creator' فقط نویسندهٔ محتواست */
role: AdminRole | null
/** فقط نقش admin به کل بخش‌های سایت دسترسی دارد */
canManageSite: boolean
isEditMode: boolean isEditMode: boolean
toggleEditMode: () => void toggleEditMode: () => void
login: (username: string, password: string) => Promise<void> login: (username: string, password: string) => Promise<AdminRole>
logout: () => void logout: () => void
saveStatus: SaveStatus saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */ /** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
@@ -22,6 +28,9 @@ interface AdminContextValue {
/** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */ /** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */
editLang: EditLang editLang: EditLang
toggleEditLang: () => void toggleEditLang: () => void
/** همگام‌سازی زبان ویرایش با زبان صفحهٔ در حال نمایش — کلید دستی فقط برای
* ویرایش ستون زبان دیگر روی همان صفحه است؛ در ناوبری دوباره از صفحه پیروی می‌کند */
syncEditLang: (lang: EditLang) => void
} }
const AdminContext = createContext<AdminContextValue | null>(null) const AdminContext = createContext<AdminContextValue | null>(null)
@@ -55,12 +64,26 @@ function showSaveError(error: unknown): void {
}) })
} }
/** نقش را از توکن JWT درمی‌آورد — فال بک sessionStorage */
function roleFromToken(token: string | null): AdminRole | null {
if (!token) return null
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
return payload?.role === 'creator' ? 'creator' : 'admin'
} catch {
return null
}
}
export function AdminProvider({ children }: { children: ReactNode }) { export function AdminProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken()) const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!getToken())
const [role, setRole] = useState<AdminRole | null>(() =>
sessionStorage.getItem('aramesh_role') as AdminRole | null ?? roleFromToken(getToken()),
)
const [isEditMode, setIsEditMode] = useState<boolean>( const [isEditMode, setIsEditMode] = useState<boolean>(
() => sessionStorage.getItem('aramesh_edit_mode') === '1', () => sessionStorage.getItem('aramesh_edit_mode') === '1',
) )
const [editLang, setEditLang] = useState<EditLang>( const [editLang, setEditLangState] = useState<EditLang>(
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'), () => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
) )
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle') const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
@@ -74,24 +97,39 @@ export function AdminProvider({ children }: { children: ReactNode }) {
}, []) }, [])
const toggleEditLang = useCallback(() => { const toggleEditLang = useCallback(() => {
setEditLang((v) => { setEditLangState((v) => {
const next: EditLang = v === 'fa' ? 'en' : 'fa' const next: EditLang = v === 'fa' ? 'en' : 'fa'
sessionStorage.setItem('aramesh_edit_lang', next) sessionStorage.setItem('aramesh_edit_lang', next)
return next return next
}) })
}, []) }, [])
const login = useCallback(async (username: string, password: string) => { // بدون بازنویسی انتخاب دستی وقتی مقدار همان است — تا اثر همگام‌سازی بی‌دلیل رندر نکند
const syncEditLang = useCallback((next: EditLang) => {
setEditLangState((v) => {
if (v === next) return v
sessionStorage.setItem('aramesh_edit_lang', next)
return next
})
}, [])
const login = useCallback(async (username: string, password: string): Promise<AdminRole> => {
await adminApi.login(username, password) await adminApi.login(username, password)
const nextRole = roleFromToken(getToken()) ?? 'admin'
sessionStorage.setItem('aramesh_role', nextRole)
setRole(nextRole)
setIsAuthenticated(true) setIsAuthenticated(true)
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس مقاله‌ها هم ادغام شوند
reload() reload()
return nextRole
}, [reload]) }, [reload])
const logout = useCallback(() => { const logout = useCallback(() => {
setToken(null) setToken(null)
setIsAuthenticated(false) setIsAuthenticated(false)
setIsEditMode(false) setIsEditMode(false)
setRole(null)
sessionStorage.removeItem('aramesh_role')
sessionStorage.removeItem('aramesh_edit_mode') sessionStorage.removeItem('aramesh_edit_mode')
// داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند // داده سایت دوباره بارگذاری می‌شود تا پیش‌نویس‌ها از درخت داده حذف شوند
reload() reload()
@@ -132,7 +170,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
// توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه // توکن بیرون از این provider هم پاک می‌شود (پاسخ 401 بارگذاری سایت، خروج در زبانه
// دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند // دیگر) — وضعیت ورود همگام می‌ماند و نوار ادمین برای نشست مرده باقی نمی‌ماند
useEffect(() => { useEffect(() => {
const syncAuth = () => setIsAuthenticated(!!getToken()) const syncAuth = () => {
setIsAuthenticated(!!getToken())
setRole(roleFromToken(getToken()))
}
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth) window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
window.addEventListener('storage', syncAuth) window.addEventListener('storage', syncAuth)
return () => { return () => {
@@ -151,9 +192,12 @@ export function AdminProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('beforeunload', guard) return () => window.removeEventListener('beforeunload', guard)
}, [saveStatus]) }, [saveStatus])
const canManageSite = role === 'admin'
const value = useMemo<AdminContextValue>( const value = useMemo<AdminContextValue>(
() => ({ () => ({
isAuthenticated, isAuthenticated,
role,
canManageSite,
isEditMode, isEditMode,
toggleEditMode, toggleEditMode,
login, login,
@@ -162,8 +206,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
runSave, runSave,
editLang, editLang,
toggleEditLang, toggleEditLang,
syncEditLang,
}), }),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang], [isAuthenticated, role, canManageSite, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang, syncEditLang],
) )
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider> return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
@@ -201,7 +246,7 @@ export function useFieldSaver() {
} }
await adminApi.patch(resource, id, changes) await adminApi.patch(resource, id, changes)
updateSite((d) => { updateSite((d) => {
const config = ENTITIES[resource] const config = entityConfig(resource)
let node: Record<string, unknown> | undefined let node: Record<string, unknown> | undefined
if (config) { if (config) {
const target = d[config.dataKey as keyof SiteData] as unknown const target = d[config.dataKey as keyof SiteData] as unknown
+2 -2
View File
@@ -20,8 +20,8 @@ export function AdminLoginPage() {
setError('') setError('')
setSubmitting(true) setSubmitting(true)
try { try {
await login(username.trim(), password) const nextRole = await login(username.trim(), password)
navigate('/') navigate(nextRole === 'creator' ? '/admin/write' : '/')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'ورود ناموفق بود') setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
} finally { } finally {
+91 -40
View File
@@ -14,7 +14,7 @@ import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect'
import type { CategoryOption } from '../components/admin/LuxeSelect' import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities' import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime' import { formatReadingTime, readingTimeMinutes } from '../utils/readingTime'
import { toArticleBodyDoc } from '../utils/tiptapDoc' import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { Article, ArticleBodyDoc } from '../data/site' import type { Article, ArticleBodyDoc } from '../data/site'
@@ -134,7 +134,7 @@ const STATUS_TEXT: Record<string, string> = {
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش). * مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/ */
export function ArticleWritePage() { export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin() const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang, syncEditLang, role } = useAdmin()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { data, updateSite } = useSiteData() const { data, updateSite } = useSiteData()
@@ -219,6 +219,23 @@ export function ArticleWritePage() {
const article = existing ?? fetched const article = existing ?? fetched
// زبان ویرایش این مقاله به‌ازای خودش به‌خاطر سپرده می‌شود — رفرش تب را عوض نکند
useEffect(() => {
if (!id) return
const saved = localStorage.getItem(`aramesh_write_lang_${id}`)
if (saved === 'en' || saved === 'fa') {
syncEditLang(saved)
return
}
const art = existing ?? null
if (art) {
const hasEn = Boolean((art as Draft).title_en?.trim()) || Boolean((art as Draft).body_en as unknown)
const hasFa = Boolean((art as Draft).title?.trim())
if (hasEn && !hasFa) syncEditLang('en')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, Boolean(article)])
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود // وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => { useEffect(() => {
if (!article || createdRef.current) return if (!article || createdRef.current) return
@@ -327,7 +344,7 @@ export function ArticleWritePage() {
setDeleting(true) setDeleting(true)
await runSave(async () => { await runSave(async () => {
await adminApi.remove('articles', createdId) await adminApi.remove('articles', createdId)
navigate('/blog') navigate(`${editLang === 'en' ? '/en' : ''}/blog`)
}) })
setDeleting(false) setDeleting(false)
} }
@@ -338,9 +355,21 @@ export function ArticleWritePage() {
queueSave({ [name]: e.target.value }), queueSave({ [name]: e.target.value }),
}) })
const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند // در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
// مقدار فعال فیلد دوزبانه — برای value ورودی‌ها و چک‌لیست سئو
// جهت ویرایشگر محتوایی با زبان ویرایش همگام می‌شود — صفحهٔ انگلیسی LTR بماند
useEffect(() => {
const el = document.querySelector('.article-body[data-wysiwyg="true"]')
if (el) {
el.setAttribute('dir', editLang === 'en' ? 'ltr' : 'rtl')
el.setAttribute('lang', editLang)
}
}, [editLang, createdId])
const activeVal = (name: 'title' | 'slug' | 'author' | 'seo_keyphrase' | 'meta_description' | 'excerpt'): string =>
String((editLang === 'en' ? draft[`${name}_en` as keyof Draft] : draft[name]) ?? '')
const metaLength = activeVal('meta_description').trim().length
// متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود // متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX] const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
@@ -348,12 +377,12 @@ export function ArticleWritePage() {
.join(' - ') .join(' - ')
return ( return (
<div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory"> <div dir={editLang === 'en' ? 'ltr' : '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"> <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"> <div className="mx-auto flex max-w-4xl items-center justify-between gap-4 px-6 py-3">
<Link <Link
to="/blog" to={`${editLang === 'en' ? '/en' : ''}/blog`}
data-admin-link data-admin-link
className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper" className="flex items-center gap-2 text-sm font-light text-ivory/70 transition-colors hover:text-copper"
> >
@@ -364,7 +393,11 @@ export function ArticleWritePage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
type="button" type="button"
onClick={toggleEditLang} onClick={() => {
const next = editLang === 'en' ? 'fa' : 'en'
toggleEditLang()
if (createdId) localStorage.setItem(`aramesh_write_lang_${createdId}`, next)
}}
title="تعیین می‌کند متن مقاله روی کدام زبان ذخیره شود" title="تعیین می‌کند متن مقاله روی کدام زبان ذخیره شود"
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${ className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
editLang === 'en' editLang === 'en'
@@ -396,6 +429,7 @@ export function ArticleWritePage() {
</span> </span>
{createdId && ( {createdId && (
<> <>
{role === 'admin' && (
<button <button
type="button" type="button"
onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })} onClick={() => queueSave({ status: draft.status === 'draft' ? 'published' : 'draft' })}
@@ -407,14 +441,16 @@ export function ArticleWritePage() {
> >
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'} {draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button> </button>
)}
<Link <Link
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`} to={`${editLang === 'en' ? '/en' : ''}/blog/${editLang === 'en' ? draft.slug_en || draft.slug : draft.slug}`}
data-admin-link 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" 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" /> <EyeIcon className="h-3.5 w-3.5" />
دیدن در سایت دیدن در سایت
</Link> </Link>
{role === 'admin' && (
<button <button
type="button" type="button"
onClick={remove} onClick={remove}
@@ -424,6 +460,7 @@ export function ArticleWritePage() {
> >
<Trash2Icon className="h-3.5 w-3.5" /> <Trash2Icon className="h-3.5 w-3.5" />
</button> </button>
)}
</> </>
)} )}
</div> </div>
@@ -447,7 +484,7 @@ export function ArticleWritePage() {
render={({ field }) => ( render={({ field }) => (
<input <input
type="text" type="text"
{...field} value={editLang === 'en' ? String(draft.title_en ?? '') : field.value}
onChange={(event) => { onChange={(event) => {
field.onChange(event.target.value) field.onChange(event.target.value)
queueSave({ [suf('title')]: event.target.value }) queueSave({ [suf('title')]: event.target.value })
@@ -489,14 +526,16 @@ export function ArticleWritePage() {
<div className="mt-8"> <div className="mt-8">
<ArticleEditor <ArticleEditor
contentKey={loadedContentKey} contentKey={loadedContentKey}
contentLang={editLang}
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>} doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
onChange={(json) => { onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری // زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری
const minutes = formatReadingTime(json) const minutes = readingTimeMinutes(json)
if (editLang === 'en') { if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` }) queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else { } else {
queueSave({ body: json, read: `${minutes} دقیقه` }) // formatReadingTime خودش برچسب «مطالعه» را دارد — پسوند اضافه ممنوع
queueSave({ body: json, read: formatReadingTime(json) })
} }
}} }}
/> />
@@ -520,8 +559,8 @@ export function ArticleWritePage() {
<textarea <textarea
id="meta-description" id="meta-description"
rows={3} rows={3}
value={draft.meta_description ?? ''} value={activeVal('meta_description')}
onChange={(e) => queueSave({ meta_description: e.target.value })} onChange={(e) => queueSave({ [suf('meta_description')]: e.target.value })}
placeholder="توضیح جذابی برای گوگل بنویسید که شامل کلمه کلیدی باشد…" 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" 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"
/> />
@@ -544,11 +583,12 @@ export function ArticleWritePage() {
{/* چکیده و متادیتا */} {/* چکیده و متادیتا */}
<SeoChecklist <SeoChecklist
input={{ input={{
title: draft.title, title: activeVal('title'),
slug: draft.slug, slug: activeVal('slug'),
excerpt: draft.meta_description || draft.excerpt, excerpt: activeVal('meta_description') || activeVal('excerpt'),
body: draft.body, body: (editLang === 'en' ? draft.body_en : draft.body) ?? draft.body,
keyphrase: draft.seo_keyphrase, keyphrase: activeVal('seo_keyphrase'),
author: draft.author,
}} }}
/> />
@@ -560,8 +600,8 @@ export function ArticleWritePage() {
<input <input
id="seo-keyphrase" id="seo-keyphrase"
type="text" type="text"
value={draft.seo_keyphrase} value={activeVal('seo_keyphrase')}
onChange={(e) => queueSave({ seo_keyphrase: e.target.value })} onChange={(e) => queueSave({ [suf('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" 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>
@@ -581,7 +621,7 @@ export function ArticleWritePage() {
<input <input
id="article-author" id="article-author"
type="text" type="text"
{...textField('author')} {...textField(suf('author'))}
placeholder="نام و نام خانوادگی نویسنده" 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" 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"
/> />
@@ -590,31 +630,42 @@ export function ArticleWritePage() {
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div> <div>
<label className="text-xs font-light text-ivory/50">دسته‌بندی مقاله (Category)</label> <label className="text-xs font-light text-ivory/50">
دسته‌بندی مقاله (Category){editLang === 'en' ? ' — نسخه انگلیسی' : ''}
</label>
<div className="mt-2"> <div className="mt-2">
{/* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */} {editLang === 'en' ? (
<Controller <input
name="category" type="text"
control={control} {...textField('category_en')}
render={({ field }) => ( placeholder={draft.category ? `English category (فارسی: ${draft.category})…` : 'English category…'}
<CategorySelect className="w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
value={field.value} />
options={ARTICLE_CATEGORIES} ) : (
onChange={(option) => { /* تک‌انتخابی: ورودی/خروجی Controller یک {value,label} یا null است */
field.onChange(option) <Controller
queueSave({ category: categoryValue(option) }) 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> </div>
<div> <div>
<label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label> <label className="text-xs font-light text-ivory/50">هشتگ‌ها / برچسب‌ها</label>
<div className="mt-2"> <div className="mt-2">
<LuxeTagsSelect <LuxeTagsSelect
value={normalizeTags(draft.tags)} value={normalizeTags(editLang === 'en' ? draft.tags_en : draft.tags)}
onChange={(tags) => queueSave({ tags })} onChange={(tags) => queueSave({ [suf('tags')]: tags })}
/> />
</div> </div>
</div> </div>
@@ -649,7 +700,7 @@ export function ArticleWritePage() {
<input <input
type="text" type="text"
dir="ltr" dir="ltr"
{...textField('slug')} {...textField(suf('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" 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>
+20 -11
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react' import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
@@ -7,14 +8,14 @@ import { useSiteData } from '../api'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { useAdmin } from './AdminContext' import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { ENTITIES } from './entities' import { entityConfig } from './entities'
import { EntityDrawer } from './EntityDrawer' import { EntityDrawer } from './EntityDrawer'
type ItemWithId = Record<string, unknown> & { id: number } type ItemWithId = Record<string, unknown> & { id: number }
function useEntityList(resource: string): ItemWithId[] { function useEntityList(resource: string): ItemWithId[] {
const { data } = useSiteData() const { data } = useSiteData()
const config = ENTITIES[resource] const config = entityConfig(resource)
if (!config) throw new Error(`منبع ناشناخته: ${resource}`) if (!config) throw new Error(`منبع ناشناخته: ${resource}`)
return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[] return (data?.[config.dataKey as keyof NonNullable<typeof data>] ?? []) as ItemWithId[]
} }
@@ -69,14 +70,14 @@ export function EditableItem({
/** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */ /** در صورت ارسال، دکمه ویرایش به این مسیر هدایت می‌کند (مثل صفحه نوشتن مقاله) */
editHref?: string editHref?: string
}) { }) {
const { isAuthenticated, isEditMode, runSave } = useAdmin() const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
const { reload } = useSiteData() const { reload } = useSiteData()
const config = ENTITIES[resource] const config = entityConfig(resource)
const list = useEntityList(resource) const list = useEntityList(resource)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
if (!config) return <>{children}</> if (!config) return <>{children}</>
if (!isAuthenticated || !isEditMode) return <>{children}</> if (!isAuthenticated || !isEditMode || !canManageSite) return <>{children}</>
const index = list.findIndex((x) => x.id === item.id) const index = list.findIndex((x) => x.id === item.id)
@@ -163,17 +164,21 @@ export function AddItemButton({
resource, resource,
label, label,
variant = 'cell', variant = 'cell',
/** در صورت ارسال، پس از ساخته‌شدن رکورد به صفحهٔ ویرایش اختصاصی هدایت می‌شود (کشو باز نمی‌شود) */
editHrefBase,
}: { }: {
resource: string resource: string
label?: string label?: string
variant?: 'cell' | 'button' variant?: 'cell' | 'button'
editHrefBase?: string
}) { }) {
const { isAuthenticated, isEditMode, runSave } = useAdmin() const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
const navigate = useNavigate()
const { updateSite } = useSiteData() const { updateSite } = useSiteData()
const config = ENTITIES[resource] const config = entityConfig(resource)
const [newItem, setNewItem] = useState<ItemWithId | null>(null) const [newItem, setNewItem] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
const create = () => { const create = () => {
void runSave(async () => { void runSave(async () => {
@@ -183,6 +188,10 @@ export function AddItemButton({
const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[] const list = d[config.dataKey as keyof SiteData] as unknown as ItemWithId[]
if (Array.isArray(list)) list.push(created) if (Array.isArray(list)) list.push(created)
}) })
if (editHrefBase) {
navigate(`${editHrefBase}/${created.id}`)
return
}
setNewItem(created) setNewItem(created)
}) })
} }
@@ -240,13 +249,13 @@ export function SingletonPill({
/** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */ /** مقادیر ساخت رکورد جدید وقتی هنوز وجود ندارد */
createDefaults?: Record<string, unknown> | (() => Record<string, unknown>) createDefaults?: Record<string, unknown> | (() => Record<string, unknown>)
}) { }) {
const { isAuthenticated, isEditMode, runSave } = useAdmin() const { isAuthenticated, isEditMode, runSave, canManageSite } = useAdmin()
const config = ENTITIES[resource] const config = entityConfig(resource)
const { data, updateSite } = useSiteData() const { data, updateSite } = useSiteData()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [created, setCreated] = useState<ItemWithId | null>(null) const [created, setCreated] = useState<ItemWithId | null>(null)
if (!config || !isAuthenticated || !isEditMode) return null if (!config || !isAuthenticated || !isEditMode || !canManageSite) return null
const item = const item =
created ?? created ??
(select (select
+354
View File
@@ -0,0 +1,354 @@
import { useEffect, useState } from 'react'
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { ArrowRightIcon, Trash2Icon } from 'lucide-react'
import Swal from 'sweetalert2'
import { useSiteData } from '../api'
import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient'
import { ImageField } from '../components/admin/ImageField'
import { GalleryField } from '../components/admin/GalleryField'
type ProjectDraft = {
id: number
slug: string
title: string
title_en: string
location: string
location_en: string
year: string
year_en: string
status: 'done' | 'progress'
project_type: string
project_type_en: string
usage: string
usage_en: string
area: string
area_en: string
floors: string
floors_en: string
units: string
units_en: string
client: string
client_en: string
summary: string
summary_en: string
body: string[]
body_en: string[]
image: string
gallery: string[]
}
const EMPTY: ProjectDraft = {
id: 0,
slug: `project-${Date.now()}`,
title: '', title_en: '',
location: '', location_en: '',
year: '', year_en: '',
status: 'done',
project_type: '', project_type_en: '',
usage: '', usage_en: '',
area: '', area_en: '',
floors: '', floors_en: '',
units: '', units_en: '',
client: '', client_en: '',
summary: '', summary_en: '',
body: [], body_en: [],
image: '', gallery: [],
}
/** body ممکن است از ردیف‌های قدیمی/ناقص غیرآرایه باشد (مثلاً {} یا رشته) — همیشه آرایهٔ رشته */
function asLines(value: unknown): string[] {
if (Array.isArray(value)) return value.map((line) => String(line))
if (typeof value === 'string') return value.split('\n')
return []
}
const INPUT =
'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'
/** جفت دوزبانه — فارسی و English همیشه هر دو دیده می‌شوند و با یک ذخیره ثبت می‌شوند */
function Pair({
label,
fa,
en,
textarea = false,
}: {
label: string
fa: { value: string; onChange: (v: string) => void }
en: { value: string; onChange: (v: string) => void }
textarea?: boolean
}) {
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
<div className="mt-1.5 grid gap-3 sm:grid-cols-2">
<div>
<span className="text-[10px] font-light text-ivory/40">فارسی</span>
{textarea ? (
<textarea dir="rtl" rows={4} className={`${INPUT} mt-1 leading-8`} {...fa} />
) : (
<input dir="rtl" type="text" className={`${INPUT} mt-1`} {...fa} />
)}
</div>
<div>
<span className="text-[10px] font-light text-ivory/40">English</span>
{textarea ? (
<textarea dir="ltr" rows={4} className={`${INPUT} mt-1 leading-8`} {...en} />
) : (
<input dir="ltr" type="text" className={`${INPUT} mt-1`} {...en} />
)}
</div>
</div>
</div>
)
}
/** صفحهٔ اختصاصی ویرایش/ساخت پروژه — به‌جای کشو؛ تصاویر فقط با آپلود به MinIO */
export function ProjectWritePage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const numericId = Number(id)
const navigate = useNavigate()
const { data, updateSite } = useSiteData()
const { isAuthenticated, runSave } = useAdmin()
const existing = !isNew && data?.projects.find((p) => p.id === numericId)
const [fetched, setFetched] = useState<ProjectDraft | null>(null)
const [draft, setDraft] = useState<ProjectDraft>(EMPTY)
const [saving, setSaving] = useState(false)
// اگر پروژه در دادهٔ سایت نبود (پیش‌نویس تازه و… ) مستقیم از مسیر مدیریتی خوانده می‌شود
useEffect(() => {
if (isNew || existing) return
let cancelled = false
adminApi
.get<ProjectDraft>('projects', numericId)
.then((p) => {
if (!cancelled) setFetched(p)
})
.catch(() => {
/* پروژه در دادهٔ سایت نبود — از مسیر مدیریتی خوانده می‌شود */
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, existing])
const project = existing ?? fetched
useEffect(() => {
if (!project) return
setDraft({ ...EMPTY, ...(project as unknown as ProjectDraft) })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [(project as unknown as ProjectDraft | undefined)?.id])
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
const set = <K extends keyof ProjectDraft>(key: K, value: ProjectDraft[K]) =>
setDraft((d) => ({ ...d, [key]: value }))
const fa = (key: keyof ProjectDraft) => ({
value: String(draft[key] ?? ''),
onChange: (v: string) => set(key, v as ProjectDraft[typeof key]),
})
const en = (key: keyof ProjectDraft) => ({
value: String(draft[`${String(key)}_en` as keyof ProjectDraft] ?? ''),
onChange: (v: string) => set(`${String(key)}_en` as keyof ProjectDraft, v as ProjectDraft[typeof key]),
})
const doSave = async () => {
if (!draft.title.trim() || !draft.slug.trim()) {
void Swal.fire({
icon: 'warning',
title: 'عنوان و نشانی (slug) لازم است',
background: '#1e1a16',
color: '#F5F3EF',
confirmButtonText: 'باشه',
buttonsStyling: false,
customClass: { popup: 'font-pinar border border-ivory/10' },
})
return
}
setSaving(true)
const ok = await runSave(async () => {
if (isNew || draft.id === 0) {
const created = await adminApi.create<ProjectDraft>('projects', { ...draft })
updateSite((d) => {
const list = d.projects as unknown as ProjectDraft[]
if (Array.isArray(list)) list.push(created)
})
navigate(`/admin/projects/${created.id}`, { replace: true })
} else {
await adminApi.patch('projects', draft.id, { ...draft })
updateSite((d) => {
const list = d.projects as unknown as ProjectDraft[]
const index = list.findIndex((x) => x.id === draft.id)
if (index >= 0) list[index] = { ...list[index], ...draft }
})
}
})
setSaving(false)
// ذخیرهٔ موفق پروژهٔ موجود → بازگشت به فهرست؛ بی‌صدا ماندن صفحه «ذخیره نشد» القا می‌کرد
if (ok && !isNew && draft.id !== 0) navigate('/projects', { replace: true })
}
const doDelete = async () => {
if (isNew || draft.id === 0) return
const result = await Swal.fire({
icon: 'warning',
title: 'حذف پروژه',
text: `«${draft.title}» برای همیشه حذف شود؟`,
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
await runSave(async () => {
await adminApi.remove('projects', draft.id)
updateSite((d) => {
const list = d.projects as unknown as ProjectDraft[]
const index = list.findIndex((x) => x.id === draft.id)
if (index >= 0) list.splice(index, 1)
})
navigate('/projects', { replace: true })
})
}
/** تصویر شاخص و گالری بلافاصله ذخیره می‌شوند (پروژهٔ موجود)؛ در پروژهٔ تازه تا اولین ذخیره در فرم می‌مانند */
const persistImage = async (url: string) => {
setDraft((d) => ({ ...d, image: url }))
if (!isNew && draft.id !== 0) {
await adminApi.patch('projects', draft.id, { image: url })
}
return true
}
const persistGallery = async (urls: string[]) => {
setDraft((d) => ({ ...d, gallery: urls }))
if (!isNew && draft.id !== 0) {
await adminApi.patch('projects', draft.id, { gallery: urls })
}
return true
}
return (
<div dir="rtl" className="min-h-screen bg-ink pb-24 pt-24">
<div className="mx-auto max-w-4xl px-6 lg:px-10">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<Link
to="/projects"
data-admin-link
className="inline-flex items-center gap-2 text-xs font-light text-ivory/50 transition-colors hover:text-copper"
>
<ArrowRightIcon className="h-3.5 w-3.5" />
بازگشت به پروژه‌ها
</Link>
<h1 className="mt-3 text-3xl font-bold text-ivory">
{isNew ? 'پروژهٔ جدید' : `ویرایش «${draft.title}»`}
</h1>
</div>
<div className="flex items-center gap-3">
{!isNew && draft.id !== 0 && (
<button
type="button"
onClick={() => void doDelete()}
className="flex items-center gap-2 border border-red-400/40 px-5 py-2.5 text-sm font-light text-red-300 transition-colors duration-200 hover:bg-red-500/20"
>
<Trash2Icon className="h-4 w-4" />
حذف پروژه
</button>
)}
<button
type="button"
onClick={() => void doSave()}
disabled={saving}
className="flex items-center gap-2 bg-copper px-8 py-2.5 text-sm font-bold text-char transition-transform duration-200 hover:scale-105 disabled:opacity-60"
>
ذخیرهٔ پروژه
</button>
</div>
</div>
<div className="mt-10 space-y-8">
<Pair label="عنوان پروژه" fa={fa('title')} en={en('title')} />
<div className="grid gap-6 sm:grid-cols-2">
<div>
<p className="text-xs font-bold text-ivory/80">
نشانی (slug) <span className="font-light text-ivory/40">— در نشانی صفحه: /projects/…</span>
</p>
<input
dir="ltr"
type="text"
className={`${INPUT} mt-1.5`}
value={draft.slug}
onChange={(e) => set('slug', e.target.value)}
/>
</div>
<div>
<p className="text-xs font-bold text-ivory/80">وضعیت</p>
<select
className={`${INPUT} mt-1.5`}
value={draft.status}
onChange={(e) => set('status', e.target.value as ProjectDraft['status'])}
>
<option value="done">انجام‌شده (done)</option>
<option value="progress">در حال اجرا (progress)</option>
</select>
</div>
</div>
<Pair label="موقعیت" fa={fa('location')} en={en('location')} />
<Pair label="سال" fa={fa('year')} en={en('year')} />
<Pair label="نوع پروژه" fa={fa('project_type')} en={en('project_type')} />
<Pair label="کاربری" fa={fa('usage')} en={en('usage')} />
<Pair label="زیربنا" fa={fa('area')} en={en('area')} />
<Pair label="تعداد طبقات" fa={fa('floors')} en={en('floors')} />
<Pair label="واحدها" fa={fa('units')} en={en('units')} />
<Pair label="کارفرما" fa={fa('client')} en={en('client')} />
<Pair label="خلاصه" fa={fa('summary')} en={en('summary')} textarea />
<Pair
label="متن پروژه — هر پاراگراف یک خط"
fa={{
value: asLines(draft.body).join('\n'),
onChange: (v) => set('body', v.split('\n')),
}}
en={{
value: asLines(draft.body_en).join('\n'),
onChange: (v) => set('body_en', v.split('\n')),
}}
textarea
/>
<div className="border-t border-ivory/10 pt-8">
<ImageField
label="تصویر شاخص"
value={draft.image}
onSave={persistImage}
altFallback={`پروژه ${draft.title}`}
/>
</div>
<div className="border-t border-ivory/10 pt-8">
<GalleryField
label="گالری تصاویر پروژه — فقط آپلود به MinIO"
value={asLines(draft.gallery)}
onSave={persistGallery}
namePrefix={draft.slug || 'project'}
/>
</div>
</div>
</div>
</div>
)
}
+12 -3
View File
@@ -3,6 +3,9 @@ import { API_URL } from '../config'
const TOKEN_KEY = 'aramesh_admin_token' const TOKEN_KEY = 'aramesh_admin_token'
/** سقف آپلود — هم‌اندازهٔ سقف کاربر در بک‌اند (media_max_upload_mb = ۳۵ با احتساب سربار multipart) */
const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
/** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */ /** رویداد تغییر وضعیت ورود — لایه‌های دیگر (مثل نوار ادمین) با آن همگام می‌مانند */
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change' export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
@@ -43,9 +46,11 @@ async function authFetch<T>(path: string, options: RequestInit = {}): Promise<T>
const detail = const detail =
typeof data?.detail === 'string' typeof data?.detail === 'string'
? data.detail ? data.detail
: res.status === 409 : res.status === 413
? 'رکوردی با این مقدار تکراری وجود دارد' ? 'حجم فایل بیش از حد مجاز است'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید' : res.status === 409
? 'رکوردی با این مقدار تکراری وجود دارد'
: 'خطا در ذخیره‌سازی — دوباره تلاش کنید'
throw new HttpError(res.status, detail) throw new HttpError(res.status, detail)
} }
return res.status === 204 ? (null as T) : ((await res.json()) as T) return res.status === 204 ? (null as T) : ((await res.json()) as T)
@@ -88,6 +93,10 @@ export const adminApi = {
authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }), authFetch<T[]>(`/api/admin/${resource}/reorder`, { method: 'POST', body: JSON.stringify({ ids }) }),
upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => { upload: async (file: File, name?: string): Promise<{ url: string; key: string }> => {
// پیش‌چک سمت کاربر — بازخورد فوری به‌جای ۴۱۳ لبه/CDN که به‌صورت HTML می‌رسد
if (file.size > MAX_UPLOAD_BYTES) {
throw new HttpError(413, 'حجم فایل بیش از ۳۰ مگابایت است — فایل را کوچک‌تر کنید')
}
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
if (name && name.trim()) fd.append('name', name.trim()) if (name && name.trim()) fd.append('name', name.trim())
+49 -5
View File
@@ -69,7 +69,10 @@ export const ENTITIES: Record<string, EntityConfig> = {
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'), F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true), F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true), F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'), F('land_area', 'مساحت عرصه (زمین)'), F('area', 'مساحت زیربنا (اعیان)'),
F('usage', 'کاربری ساختمان'), F('pilot_floors', 'طبقات روی همکف (پیلوت)'), F('floors', 'تعداد طبقات'),
F('units_residential', 'تعداد واحد مسکونی'), F('units_commercial', 'تعداد واحد تجاری'), F('units_office', 'تعداد واحد اداری'),
F('project_type', 'نوع پروژه'),
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'), F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
], ],
defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] }, defaults: { title: 'پروژه جدید', slug: `project-${Date.now()}`, location: '', year: '', status: 'done', image: '', gallery: [], area: '', floors: '', units: '', client: '', summary: '', body: [] },
@@ -81,7 +84,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
F('title', 'عنوان'), F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'), F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'), F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true), F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود'),
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }), F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }), F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }), F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
@@ -101,6 +104,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
image_title: '', image_title: '',
author: '', author: '',
meta_description: '', meta_description: '',
meta_description_en: '',
read: '۵ دقیقه', read: '۵ دقیقه',
excerpt: '', excerpt: '',
seo_keyphrase: '', seo_keyphrase: '',
@@ -173,6 +177,27 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)], fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
defaults: { label: 'آیتم جدید', href: '/' }, defaults: { label: 'آیتم جدید', href: '/' },
}, },
'status-labels': {
resource: 'status-labels', dataKey: 'status_labels', title: 'برچسب‌های وضعیت پروژه', kind: 'collection',
titleKey: 'label',
// نبودِ این تعریف یعنی isTranslatable همیشه false برمی‌گشت و ویرایشِ برچسب روی
// صفحهٔ انگلیسی به‌جای ستون label_en ستون فارسی را بازنویسی می‌کرد
fields: [F('label', 'برچسب'), F('key', 'کلید وضعیت', 'text', 'done یا progress', true)],
defaults: { key: 'done', label: 'برچسب جدید' },
},
'section-headings': {
resource: 'section-headings', dataKey: 'section_headings', title: 'تیتر بخش‌ها', kind: 'collection',
titleKey: 'title',
// تیتر/توضیح بخش‌ها فقط درجا (InlineEdit) ویرایش می‌شوند — نبودِ تعریف یعنی
// ویرایش روی صفحهٔ انگلیسی به‌جای ستون‌های _en ستون فارسی را بازنویسی می‌کرد
fields: [
F('key', 'کلید بخش', 'text', 'services / featured_projects / gallery / timeline / why_us / testimonials / team / articles', true),
F('label', 'برچسب بالای تیتر'), F('title', 'تیتر'), F('lead', 'توضیح', 'textarea'),
F('image', 'تصویر', 'image', undefined, true),
F('cta_label', 'متن دکمه'), F('cta_href', 'لینک دکمه', 'text', undefined, true),
],
defaults: { key: '', title: '', lead: '' },
},
seo_meta: { seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection', resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key', titleKey: 'page_key',
@@ -186,15 +211,26 @@ export const ENTITIES: Record<string, EntityConfig> = {
brand: { brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton', resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name', 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')], fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), 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: {}, defaults: {},
}, },
hero: { hero: {
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton', resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
titleKey: 'title', 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')], // title و subtitle باید در فهرست باشند — تیتر و زیرتیتر درجا ویرایش می‌شوند و
// نبودشان یعنی isTranslatable false برمی‌گرداند و ویرایش روی /en متن فارسی را بازنویسی می‌کرد
fields: [F('title', 'تیتر اصلی'), F('title_highlight', 'واژه مسیِ تیتر'), F('subtitle', 'زیرتیتر', 'textarea'), F('kicker', 'متن بالای تیتر', 'textarea'), 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', 'متن اسکرول')],
defaults: {}, defaults: {},
}, },
'page-heroes': {
resource: 'page-heroes', dataKey: 'page_heroes', title: 'هیروی صفحه‌ها', kind: 'collection',
titleKey: 'title',
fields: [
F('key', 'کلید صفحه', 'text', 'about / services / projects / investment / blog / faq / contact', true),
F('title', 'عنوان'), F('lead', 'سرآغاز', 'textarea'), F('image', 'تصویر', 'image', undefined, true),
],
defaults: { key: 'about', title: 'عنوان صفحه', lead: '', image: '' },
},
story: { story: {
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton', resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
titleKey: 'title', titleKey: 'title',
@@ -235,10 +271,18 @@ export const ENTITIES: Record<string, EntityConfig> = {
}, },
} }
/** تعریف موجودیت بر اساس resource (نشانی API) یا کلید فهرست — هر دو سبک در کد
* استفاده می‌شوند: save()/isTranslatable با resource (why-us) و رجکس‌های JSX با کلید
* (why_us، contact_section، process_steps و…) — هر دو باید پیدا شوند وگرنه رندر
* با «منبع ناشناخته» می‌شکند. */
export function entityConfig(resource: string): EntityConfig | undefined {
return ENTITIES[resource] ?? Object.values(ENTITIES).find((c) => c.resource === resource)
}
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */ /** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
export function isTranslatable(resource: string, name: string): boolean { export function isTranslatable(resource: string, name: string): boolean {
if (name.endsWith('_en')) return false if (name.endsWith('_en')) return false
const config = ENTITIES[resource] const config = entityConfig(resource)
return !!config?.fields.some((f) => f.name === name && !f.shared) return !!config?.fields.some((f) => f.name === name && !f.shared)
} }
+53 -5
View File
@@ -1,17 +1,65 @@
// لایهٔ نازک تحلیل رفتار (Umami) — اگر اسکریپت ردیاب بارگیری نشده باشد // لایهٔ نازک تحلیل رفتار — OpenPanel.
// (VITE_UMAMI_SRC خالی)، همهٔ فراخوانی‌ها بی‌اثر و ارزان‌اند. //
// نمونهٔ OpenPanel از قبل روی این سرور بالا است (stats.bennu-api-bahrestan.ir) و
// پروژهٔ ERP هم از همان استفاده می‌کند؛ به‌جای اجرای یک ابزار تحلیل دوم (Umami)،
// همین یک نمونه با یک «پروژهٔ» دوم برای این سایت به کار می‌رود.
//
// اگر VITE_OPENPANEL_URL و VITE_OPENPANEL_CLIENT_ID در زمان بیلد تنظیم نشده باشند،
// همهٔ فراخوانی‌ها بی‌اثر و ارزان‌اند (مثل حالت فعلی Umami).
//
// مسیر Umami دست‌نخورده مانده تا اگر اسکریپت قدیمی جایی فعال بود از کار نیفتد.
declare global { declare global {
interface Window { interface Window {
op?: (...args: unknown[]) => void
umami?: { track: (event?: string, data?: Record<string, unknown>) => void } umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
} }
} }
/** رخداد سفارشی — نام رخداد در داشبورد Umami نمایش داده می‌شود. */ const OP_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim()
export function track(event: string, data?: Record<string, unknown>) { const OP_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim()
const OP_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim()
// شیم صف OpenPanel — تا رسیدن op1.js، فراخوانی‌ها در صف می‌مانند و بعد تخلیه می‌شوند.
function loadOpenPanelScript(src: string): void {
if (document.querySelector('script[data-openpanel]')) return
const shim = document.createElement('script')
shim.textContent =
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();'
document.head.appendChild(shim)
const script = document.createElement('script')
script.src = src
script.async = true
script.defer = true
script.dataset.openpanel = '1'
document.head.appendChild(script)
}
/** یک‌بار در شروع اپ صدا زده می‌شود؛ بدون تنظیمات، هیچ کاری نمی‌کند. */
export function initAnalytics(): void {
if (!OP_URL || !OP_CLIENT_ID) return
try { try {
window.umami?.track(event, data) if (OP_SCRIPT_URL) loadOpenPanelScript(OP_SCRIPT_URL)
window.op?.('init', {
apiUrl: OP_URL,
clientId: OP_CLIENT_ID,
trackScreenViews: true, // بازدید صفحه → کدام صفحات دیده می‌شوند
})
} catch { } catch {
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند // تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
} }
} }
/** رخداد سفارشی — در داشبورد OpenPanel نمایش داده می‌شود. */
export function track(event: string, data?: Record<string, unknown>) {
try {
window.op?.('track', event, data ?? {})
} catch {
/* ignore */
}
try {
window.umami?.track(event, data)
} catch {
/* ignore */
}
}
+25 -19
View File
@@ -12,6 +12,7 @@ export type BrandInfo = {
name: string name: string
name_en?: string name_en?: string
logo: string logo: string
logo_en?: string
hero: string hero: string
contact_bg: string contact_bg: string
phone: string phone: string
@@ -249,6 +250,24 @@ export function SiteProvider({ children }: { children: ReactNode }) {
setError(false) setError(false)
const controller = new AbortController() const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS) const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
// درخواست‌های سایت و پیش‌نویس‌های ادمین «همزمان» شروع می‌شوند —
// پشت سر هم بودنشان یعنی ادمین دو برابر دیرتر محتوا را می‌بیند.
const draftsPromise: Promise<Article[] | null> = getToken()
? fetch(`${API_URL}/api/admin/articles`, {
headers: { Authorization: `Bearer ${getToken()}` },
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 401) {
// توکن منقضی/نامعتبر — پاک می‌شود تا بارگذاری‌های بعدی درخواست بی‌فایده نفرستند
setToken(null)
return null
}
return res.ok ? ((await res.json()) as Article[]) : null
})
.catch(() => null)
: Promise.resolve(null)
fetch(`${API_URL}/api/site`, { signal: controller.signal }) fetch(`${API_URL}/api/site`, { signal: controller.signal })
.then((res) => { .then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -256,25 +275,12 @@ export function SiteProvider({ children }: { children: ReactNode }) {
}) })
.then(async (json) => { .then(async (json) => {
if (cancelled) return if (cancelled) return
// برای ادمین واردشده، پیش‌نویس مقاله‌ها از انتهای مدیریتی خوانده و ادغام می‌شوند — // بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند —
// بک‌اند در GET /api/site فقط مقاله‌های منتشرشده را برمی‌گرداند // پیش‌نویس‌ها (برای ادمین) از درخواست موازی ادغام می‌شوند
if (getToken()) { const drafts = await draftsPromise
try { if (drafts) {
const res = await fetch(`${API_URL}/api/admin/articles`, { const published = new Set(json.articles.map((a) => a.id))
headers: { Authorization: `Bearer ${getToken()}` }, json.articles = [...json.articles, ...drafts.filter((a) => a.status === 'draft' && !published.has(a.id))]
})
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 (!cancelled) {
if (json.brand && json.hero && json.footer && json.nav) setData(json) if (json.brand && json.hero && json.footer && json.nav) setData(json)
+7 -5
View File
@@ -1,12 +1,12 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react' import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { formatJalali } from '../utils/date' import { formatJalali, formatEnDate } from '../utils/date'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits' import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) { export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang() const { t, href, lang } = useLang()
return ( return (
<Link <Link
to={href(`/blog/${article.slug}`)} to={href(`/blog/${article.slug}`)}
@@ -17,7 +17,9 @@ export function ArticleCard({ article }: { article: Article }) {
<img <img
src={article.image} src={article.image}
alt={`${t('articleImagePrefix')} ${article.title}`} alt={`${t('articleImagePrefix')} ${article.title}`}
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105" className="h-52 md:h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
loading="lazy"
decoding="async"
/> />
{/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */} {/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */}
<div className="absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-2.5 py-0.5 md:px-3.5 md:py-1 text-[11px] md:text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]"> <div className="absolute top-3 ltr:left-3 rtl:right-3 md:top-4 md:ltr:left-4 md:rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-2.5 py-0.5 md:px-3.5 md:py-1 text-[11px] md:text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
@@ -36,10 +38,10 @@ export function ArticleCard({ article }: { article: Article }) {
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-2.5 md:pt-8 text-[11px] md:text-xs font-light text-ivory/50"> <div className="mt-auto flex items-center justify-between border-t border-white/10 pt-2.5 md:pt-8 text-[11px] md:text-xs font-light text-ivory/50">
<span className="flex items-center gap-2.5 md:gap-4"> <span className="flex items-center gap-2.5 md:gap-4">
<span>{formatJalali(article.date)}</span> <span>{lang === 'en' ? formatEnDate(article.date) : formatJalali(article.date)}</span>
<span className="flex items-center gap-1.5 md:gap-2"> <span className="flex items-center gap-1.5 md:gap-2">
<ClockIcon className="h-3 w-3 md:h-3.5 md:w-3.5 text-copper/80" /> <ClockIcon className="h-3 w-3 md:h-3.5 md:w-3.5 text-copper/80" />
{toPersianDigits(article.read)} {lang === 'en' ? article.read : toPersianDigits(article.read)}
</span> </span>
</span> </span>
+11 -11
View File
@@ -1,7 +1,8 @@
import { CalendarIcon, ClockIcon } from 'lucide-react' import { CalendarIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { JALALI_TIME_FORMATTER, formatJalali } from '../utils/date' import { JALALI_TIME_FORMATTER, formatJalali, formatEnDate } from '../utils/date'
import { toPersianDigits } from '../utils/digits' import { toPersianDigits } from '../utils/digits'
import { useLang } from '../i18n/lang'
type ArticleMetaProps = { type ArticleMetaProps = {
article: Article article: Article
@@ -13,24 +14,23 @@ type ArticleMetaProps = {
* آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه. * آواتار حرف اول نویسنده، نام نویسنده، تاریخ جلالی و زمان مطالعه.
*/ */
export function ArticleMeta({ article, className = '' }: ArticleMetaProps) { export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
const { t, lang } = useLang()
const rawDate = (article.date ?? '').trim() const rawDate = (article.date ?? '').trim()
const author = (article.author ?? '').trim() || 'گروه ساختمانی آرامش' const author = (article.author ?? '').trim() || t('brandFallback')
const initial = author.charAt(0)
// تاریخ ذخیره‌شده (میلادی) به شمسی تبدیل می‌شود؛ در نبود تاریخ، امروز با ساعت نمایش داده می‌شود // فارسی: تاریخ شمسی با ارقام فارسی؛ انگلیسی: تاریخ میلادی لاتین
const enDateLabel = formatEnDate(rawDate)
|| new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date())
const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}` const todayLabel = `${formatJalali(new Date())} — ساعت ${JALALI_TIME_FORMATTER.format(new Date())}`
const dateLabel = formatJalali(rawDate, todayLabel) const dateLabel = lang === 'en' ? enDateLabel : formatJalali(rawDate, todayLabel)
return ( return (
<div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}> <div className={`flex flex-wrap items-center gap-x-6 gap-y-4 ${className}`}>
{/* آواتار و نام نویسنده */} {/* نام نویسنده — بدون دایرهٔ حرف اول */}
<span className="flex items-center gap-3"> <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="flex flex-col">
<span className="text-sm font-bold text-ivory">{author}</span> <span className="text-sm font-bold text-ivory">{author}</span>
<span className="text-[11px] font-light text-ivory/45">نویسنده</span> <span className="text-[11px] font-light text-ivory/45">{t('author')}</span>
</span> </span>
</span> </span>
@@ -46,7 +46,7 @@ export function ArticleMeta({ article, className = '' }: ArticleMetaProps) {
{article.read && ( {article.read && (
<span className="flex items-center gap-2 text-xs font-light text-ivory/60"> <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" /> <ClockIcon className="h-3.5 w-3.5 text-copper" aria-hidden="true" />
{toPersianDigits(article.read)} {lang === 'fa' ? toPersianDigits(article.read) : article.read}
</span> </span>
)} )}
</div> </div>
+110
View File
@@ -0,0 +1,110 @@
import React from 'react'
import { Link } from 'react-router-dom'
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
type BaseProps = {
children: React.ReactNode
variant?: Variant
className?: string
}
// استایل‌های فوق‌لوکس شیشه‌ای (Glassmorphic) با رنگ‌های اختصاصی پروژه
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
copper: {
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
text: 'group-hover:text-char font-semibold',
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
},
outline: {
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبه‌ها
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
fill: 'bg-white/10',
text: 'group-hover:text-copper',
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
},
dark: {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-copper',
text: 'group-hover:text-char',
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
},
'dark-ivory': {
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
fill: 'bg-ivory',
text: 'group-hover:text-char',
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
},
}
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
const style = STYLES[variant]
return (
<>
{/* خط انعکاس نوری شیشه در بالای دکمه */}
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
{/* انیمیشن پر شدن موجی و نرم */}
<span
aria-hidden="true"
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
/>
{/* متن و آیکون دکمه */}
<span
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
>
{children}
</span>
</>
)
}
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشه‌ای
const SHELL =
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
export function ButtonLink({
to,
children,
variant = 'copper',
className = '',
}: BaseProps & { to: string }) {
const style = STYLES[variant]
return (
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
<Inner variant={variant}>{children}</Inner>
</Link>
)
}
export function ButtonAnchor({
href,
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const style = STYLES[variant]
return (
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</a>
)
}
export function Button({
children,
variant = 'copper',
className = '',
...rest
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const style = STYLES[variant]
return (
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
<Inner variant={variant}>{children}</Inner>
</button>
)
}
+12 -2
View File
@@ -13,6 +13,8 @@ type SseFrame = {
content?: string; content?: string;
error?: string; error?: string;
done?: boolean; done?: boolean;
/** true یعنی دستیار چیزی پیدا نکرده و پیام جایگزین داده */
fallback?: boolean;
}; };
/** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */ /** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */
@@ -78,7 +80,9 @@ export function ChatWidget() {
const sendMessage = async (text: string) => { const sendMessage = async (text: string) => {
const question = text.trim(); const question = text.trim();
if (!question || isLoading) return; if (!question || isLoading) return;
track('chat-question') // نتیجهٔ هر پرسش برای تحلیل گزارش می‌شود — پرسش‌های بی‌پاسخ = شکاف دانش
let hadError = false;
let wasFallback = false;
setInput(''); setInput('');
setIsLoading(true); setIsLoading(true);
setMessages((prev) => { setMessages((prev) => {
@@ -111,6 +115,7 @@ export function ChatWidget() {
let accumulated = ''; let accumulated = '';
await readSse(response, (frame) => { await readSse(response, (frame) => {
if (frame.error) { if (frame.error) {
hadError = true;
patchAssistant({ content: frame.error, streaming: false, error: true }); patchAssistant({ content: frame.error, streaming: false, error: true });
return; return;
} }
@@ -118,16 +123,21 @@ export function ChatWidget() {
accumulated += frame.content; accumulated += frame.content;
patchAssistant({ content: accumulated }); patchAssistant({ content: accumulated });
} }
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false }); if (frame.done) {
wasFallback = frame.fallback === true;
patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
}
}); });
patchAssistant({ streaming: false }); patchAssistant({ streaming: false });
} catch (err) { } catch (err) {
hadError = true;
patchAssistant({ patchAssistant({
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.', content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
streaming: false, streaming: false,
error: true, error: true,
}); });
} finally { } finally {
track('chat-question', { fallback: wasFallback, error: hadError });
setIsLoading(false); setIsLoading(false);
inputRef.current?.focus(); inputRef.current?.focus();
} }
+29 -42
View File
@@ -1,17 +1,16 @@
import { useState } from 'react' import React, { useState } from 'react'
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react' import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Reveal } from './Reveal' import { Reveal } from './Reveal'
import { PremiumButton } from './PremiumButton' import { Button } from './Button'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext' import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit' import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable' import { SingletonPill } from '../admin/Editable'
import { API_URL } from '../config' import { API_URL } from '../config'
import { track } from '../analytics'
export function ContactSection() { export function ContactSection({ bare = false }: { bare?: boolean } = {}) {
const { brand, contact_section: content } = useSite() const { brand, contact_section: content } = useSite()
const { t } = useLang() const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
@@ -80,7 +79,7 @@ export function ContactSection() {
const res = await fetch(`${API_URL}/api/contact`, { const res = await fetch(`${API_URL}/api/contact`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim(), website: form.website }), body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
}) })
if (!res.ok) { if (!res.ok) {
@@ -91,7 +90,6 @@ export function ContactSection() {
return return
} }
track('contact-submit')
setSent(true) setSent(true)
} catch { } catch {
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText')) showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
@@ -103,14 +101,18 @@ export function ContactSection() {
const cleanOptionalLabel = `(${content.note_optional_label?.replace(/^\(+|\)+$/g, '') || 'اختیاری'})` const cleanOptionalLabel = `(${content.note_optional_label?.replace(/^\(+|\)+$/g, '') || 'اختیاری'})`
return ( return (
<section id="contact" className="relative w-full max-w-full overflow-x-clip border-t border-copper/15 py-16 md:py-24 lg:py-32" aria-labelledby="contact-title"> <section id="contact" className="relative overflow-hidden py-24 lg:py-32" aria-labelledby="contact-title">
<img {!bare && (
src={brand.contact_bg} <>
alt="" <img
aria-hidden="true" src={brand.contact_bg}
className="absolute inset-0 h-full w-full object-cover object-center" alt=""
/> aria-hidden="true"
<div className="absolute inset-0 bg-gradient-to-b from-ink/90 via-ink/75 to-ink backdrop-blur-[2px]" /> className="absolute inset-0 h-full w-full object-cover" loading="lazy" decoding="async"
/>
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
</>
)}
<div className="relative mx-auto max-w-site px-4 md:px-6 lg:px-10"> <div className="relative mx-auto max-w-site px-4 md:px-6 lg:px-10">
@@ -217,15 +219,6 @@ export function ContactSection() {
{sending ? content.sending_label : content.submit_label} {sending ? content.sending_label : content.submit_label}
</button> </button>
{/* Direct Call Fallback */}
<a
href={brand.phone_href}
onClick={() => track('phone-click', { from: 'contact-section-mobile-hotline' })}
className="flex items-center justify-center gap-1.5 pt-2 text-[11px] text-ivory/60 hover:text-copper transition-colors"
>
<span>نیاز به گفتگوی فوری دارید؟</span>
<span className="font-bold text-copper dir-ltr" dir="ltr">{brand.phone}</span>
</a>
</form> </form>
)} )}
</div> </div>
@@ -275,7 +268,6 @@ export function ContactSection() {
<a <a
href={brand.phone_href} href={brand.phone_href}
dir="ltr" dir="ltr"
onClick={() => track('phone-click', { from: 'contact-section' })}
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper" className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
> >
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} /> <InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
@@ -318,29 +310,20 @@ export function ContactSection() {
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70"> <p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} /> <InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
</p> </p>
<PremiumButton <Button
type="button"
variant="outline"
className="mt-8 font-light"
onClick={() => { onClick={() => {
setSent(false) setSent(false)
setForm({ name: '', phone: '', note: '', website: '' }) setForm({ name: '', phone: '', note: '', website: '' })
}} }}
className="mt-8 font-light"
> >
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} /> <InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
</PremiumButton> </Button>
</div> </div>
) : ( ) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2"> <form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
{/* honeypot — خارج از دید؛ فقط ربات‌ها آن را پر می‌کنند */}
<input
type="text"
name="website"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div> <div>
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90"> <label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} /> <InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
@@ -386,17 +369,21 @@ export function ContactSection() {
</div> </div>
<div className="md:col-span-2 flex justify-start"> <div className="md:col-span-2 flex justify-start">
<PremiumButton <Button
type="submit" type="submit"
variant="copper"
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3" className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
disabled={sending} disabled={sending}
> >
{sending ? ( {sending ? (
content.sending_label content.sending_label
) : ( ) : (
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} /> <>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
</>
)} )}
</PremiumButton> </Button>
</div> </div>
</form> </form>
)} )}
+4 -3
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'; import { ArrowUpIcon, CheckIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
@@ -11,6 +11,7 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
instagram: InstagramIcon, instagram: InstagramIcon,
linkedin: LinkedinIcon, linkedin: LinkedinIcon,
telegram: SendIcon, telegram: SendIcon,
bale: CheckIcon,
}; };
export function Footer() { export function Footer() {
@@ -43,8 +44,8 @@ export function Footer() {
{/* Top Brand Block */} {/* Top Brand Block */}
<div className="mb-6 flex flex-col items-center text-center"> <div className="mb-6 flex flex-col items-center text-center">
<div className="relative w-fit"> <div className="relative w-fit">
<img src={brand.logo} alt={brand.name} className="h-16 w-auto object-contain" /> <img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" loading="lazy" decoding="async" />
<SingletonPill resource="brand" label="ویرایش برند" className="absolute top-1/2 -translate-y-1/2 ltr:-right-8 rtl:-left-8" /> <SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
</div> </div>
<p className="mt-4 text-xs font-light leading-relaxed text-ivory/60 max-w-xs"> <p className="mt-4 text-xs font-light leading-relaxed text-ivory/60 max-w-xs">
{footer.about_text} {footer.about_text}
+6 -71
View File
@@ -1,18 +1,6 @@
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang' import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
import { track } from '../analytics'
/**
* تنظیمات ثابت هر زبان.
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
*/
const LANGS = {
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
en: { label: 'English', sliderTranslate: 'translate-x-full' },
} as const
type LangKey = keyof typeof LANGS
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */ /** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) { export function LangToggle({ className = '' }: { className?: string }) {
@@ -22,9 +10,7 @@ export function LangToggle({ className = '' }: { className?: string }) {
const target = counterpartPath(pathname, lang) const target = counterpartPath(pathname, lang)
const switchLang = () => { const switchLang = () => {
const next = lang === 'fa' ? 'en' : 'fa' writeLangCookie(lang === 'fa' ? 'en' : 'fa')
track('lang-switch', { to: next })
writeLangCookie(next)
// پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد // پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد
sessionStorage.setItem('langAutoRedirect', '1') sessionStorage.setItem('langAutoRedirect', '1')
navigate(target) navigate(target)
@@ -36,61 +22,10 @@ export function LangToggle({ className = '' }: { className?: string }) {
onClick={switchLang} onClick={switchLang}
aria-label={t('switchLangAria')} aria-label={t('switchLangAria')}
title={t('switchLangAria')} title={t('switchLangAria')}
dir="ltr" className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
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 ─────────────────────────────────────────────── <LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
این دیو داخلی باعث می‌شود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد <span>{t('switchLang')}</span>
*/}
<div className="relative w-full h-full flex items-center">
{/* ── Slider pill ─────────────────────────────────────────────── */}
<span
aria-hidden="true"
className={[
'absolute top-0 left-0', // چسبیده به لبه‌های Track داخلی
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
'bg-[#27221c] border border-[#d5874c]',
// subtle raised shadow — tactile without being flashy
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
'transition-transform duration-[380ms] ease-lux',
LANGS[lang as LangKey].sliderTranslate,
].join(' ')}
/>
{/* ── Labels ──────────────────────────────────────────────────── */}
<span className="relative z-10 flex w-full h-full">
{(Object.keys(LANGS) as LangKey[]).map((key) => {
const isActive = lang === key
return (
<span
key={key}
className={[
// each label occupies exactly half the capsule
'w-1/2 h-full flex justify-center items-center',
'text-[11.5px] font-semibold tracking-wide leading-none',
'transition-colors duration-[380ms] ease-lux',
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
].join(' ')}
>
{LANGS[key].label}
</span>
)
})}
</span>
</div>
</button> </button>
) )
} }
+22 -9
View File
@@ -12,9 +12,11 @@ import { ImageField } from './admin/ImageField'
type PageHeroProps = { type PageHeroProps = {
/** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایش‌پذیر می‌شوند */ /** کلید هیرو صفحه در دیتابیس — عنوان/توضیح درجا ویرایش‌پذیر می‌شوند */
pageKey: string pageKey: string
/** تصویر پس‌زمینه را رندر نکن — صفحه تصویر پیوستهٔ خودش را دارد */
hideImage?: boolean
} }
export function PageHero({ pageKey }: PageHeroProps) { export function PageHero({ pageKey, hideImage = false }: PageHeroProps) {
const hero = usePageHero(pageKey) const hero = usePageHero(pageKey)
const { t, href } = useLang() const { t, href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
@@ -27,15 +29,19 @@ export function PageHero({ pageKey }: PageHeroProps) {
return ( return (
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden"> <section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />} {hero.image && !hideImage && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />}
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" /> {!hideImage && (
<>
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }} style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.1) 70%)' }}
aria-hidden="true" aria-hidden="true"
/> />
</>
)}
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10"> <div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50"> <nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
@@ -74,8 +80,15 @@ export function PageHero({ pageKey }: PageHeroProps) {
</motion.p> </motion.p>
<div className="mt-8 h-px w-28 bg-copper" /> <div className="mt-8 h-px w-28 bg-copper" />
{/* ویرایش تصویر پس‌زمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */} {/* ویرایش هیروی صفحه (عنوان/سرآغاز/تصویر)، تصویر پس‌زمینه و سئو — فقط در حالت ویرایش ادمین */}
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0"> <div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
<SingletonPill
resource="page-heroes"
label="عنوان، سرآغاز و تصویر هیروی این صفحه"
select={(d) => d.page_heroes.find((h) => h.key === pageKey)}
// اگر ردیف هیروی این صفحه هنوز ساخته نشده باشد، همین‌جا ساخته می‌شود
createDefaults={() => ({ key: pageKey, title: '', lead: '', image: '' })}
/>
<SingletonPill <SingletonPill
resource="seo-meta" resource="seo-meta"
label="عنوان و توضیح سئوی این صفحه" label="عنوان و توضیح سئوی این صفحه"
+2 -8
View File
@@ -17,7 +17,8 @@ export function Preloader() {
useEffect(() => { useEffect(() => {
const start = Date.now() const start = Date.now()
const total = 1800 // کوتاه — فقط حس برند؛ تأخیر مصنوعی طولانی LCP/INP را خراب می‌کند
const total = 700
const id = window.setInterval(() => { const id = window.setInterval(() => {
const p = Math.min(100, ((Date.now() - start) / total) * 100) const p = Math.min(100, ((Date.now() - start) / total) * 100)
setProgress(p) setProgress(p)
@@ -29,13 +30,6 @@ export function Preloader() {
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, []) }, [])
useEffect(() => {
document.body.style.overflow = done ? '' : 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [done])
return ( return (
<AnimatePresence> <AnimatePresence>
{!done && ( {!done && (
+10 -1
View File
@@ -2,7 +2,14 @@ import { Link } from 'react-router-dom'
import { useStatusLabel } from '../api' import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { PremiumButton } from './PremiumButton' import { PremiumButton } from './PremiumButton'
import type { StaticProject } from '../data/projectsData' export type StaticProject = {
id: string
slug: string
name: { en: string; fa: string }
location: { en: string; fa: string }
area: { en: string; fa: string }
status: 'done' | 'progress'
}
import type { ProjectCardItem } from '../data/site' import type { ProjectCardItem } from '../data/site'
type ProjectCardProps = { type ProjectCardProps = {
@@ -46,6 +53,8 @@ export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps)
src={finalImageUrl} src={finalImageUrl}
alt={title} alt={title}
className="w-full h-44 md:h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110" className="w-full h-44 md:h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
loading="lazy"
decoding="async"
/> />
)} )}
+4 -2
View File
@@ -110,7 +110,7 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
</p> </p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" /> <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)]"> <ul className="no-scrollbar 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) => { {items.map((item) => {
const isActive = item.id === activeId const isActive = item.id === activeId
return ( return (
@@ -120,7 +120,9 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
onClick={(event) => go(event, item.id)} onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined} aria-current={isActive ? 'location' : undefined}
className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${ 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]' : '' // H3 زیرمجموعهٔ H2 است: تورفتگی واضح‌تر و یک پله کوچک‌تر؛ با ps-4
// تفاوتش با H2 فقط ۴ پیکسل بود و به چشم نمی‌آمد.
item.level === 3 ? 'ps-6 text-[13px]' : ''
} ${ } ${
isActive isActive
? 'font-normal text-copper' ? 'font-normal text-copper'
@@ -1,11 +1,18 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { EditorContent, useEditor } from '@tiptap/react' import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { Table } from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import { Mark, mergeAttributes } from '@tiptap/core' import { Mark, mergeAttributes } from '@tiptap/core'
import { import {
AlignLeftIcon, AlignLeftIcon,
AlignRightIcon, AlignRightIcon,
Columns2Icon,
Rows2Icon,
PanelTopIcon,
BoldIcon, BoldIcon,
Heading1Icon, Heading1Icon,
Heading2Icon, Heading2Icon,
@@ -20,6 +27,7 @@ import {
QuoteIcon, QuoteIcon,
Redo2Icon, Redo2Icon,
StrikethroughIcon, StrikethroughIcon,
TableIcon,
UnderlineIcon, UnderlineIcon,
Undo2Icon, Undo2Icon,
} from 'lucide-react' } from 'lucide-react'
@@ -60,6 +68,10 @@ export const ARTICLE_EXTENSIONS = [
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
isAllowedUri: (uri: string) => isAllowedHref(uri), isAllowedUri: (uri: string) => isAllowedHref(uri),
}), }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
LegacySmallSizeMark, LegacySmallSizeMark,
EnglishText, EnglishText,
BidiTypography, BidiTypography,
@@ -73,6 +85,8 @@ type ArticleEditorProps = {
* (مثلاً وقتی مقاله از سرور می‌رسد). بدون این prop محتوا فقط یک‌بار مقداردهی می‌شود. * (مثلاً وقتی مقاله از سرور می‌رسد). بدون این prop محتوا فقط یک‌بار مقداردهی می‌شود.
*/ */
contentKey?: string | number contentKey?: string | number
/** زبان محتوای ویرایشگر — جهت و زبان عنصر ویرایش با آن همگام می‌شود */
contentLang?: 'fa' | 'en'
} }
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمی‌گرداند */ /** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمی‌گرداند */
@@ -174,7 +188,7 @@ const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="t
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور، * ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور،
* کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO. * کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
*/ */
export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) { export function ArticleEditor({ doc, onChange, contentKey, contentLang = 'fa' }: ArticleEditorProps) {
const { runSave } = useAdmin() const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@@ -187,10 +201,13 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
extensions: ARTICLE_EXTENSIONS, extensions: ARTICLE_EXTENSIONS,
content: doc && Object.keys(doc).length ? doc : undefined, content: doc && Object.keys(doc).length ? doc : undefined,
immediatelyRender: false, immediatelyRender: false,
// نوار ابزار فقط با رندر دوباره روی هر تراکنش زنده می‌ماند (پیش‌فرض v3 خاموش است)
shouldRerenderOnTransaction: true,
editorProps: { editorProps: {
attributes: { attributes: {
dir: 'rtl', dir: 'rtl',
lang: 'fa', lang: 'fa',
'data-base-dir': 'rtl',
class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none', class: 'article-body min-h-[300px] px-4 py-4 focus:outline-none',
'data-wysiwyg': 'true', 'data-wysiwyg': 'true',
}, },
@@ -279,6 +296,11 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()), onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
}) })
// جهت متن از زبان محتوای در حال ویرایش پیروی می‌کند — انگلیسی LTR، فارسی RTL
useEffect(() => {
setDirection(contentLang === 'en' ? 'ltr' : 'rtl')
}, [contentLang])
// جهت متن فقط روی ناحیه ویرایش اعمال می‌شود؛ نوار ابزار همیشه راست‌چین می‌ماند // جهت متن فقط روی ناحیه ویرایش اعمال می‌شود؛ نوار ابزار همیشه راست‌چین می‌ماند
useEffect(() => { useEffect(() => {
if (!editor) return if (!editor) return
@@ -311,12 +333,15 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
setUploading(true) setUploading(true)
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file, name) const { url } = await adminApi.upload(file, name)
// نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند // «نام تصویر»ای که کاربر پیش از بارگذاری نوشته، همان Alt تصویر می‌شود؛
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() // در نبود آن نام فایل و در نبود آن مقدار پیش‌فرض — تا نیازی به اصلاح دستی
// در جعبهٔ زیر تصویر نباشد.
const typedName = (name ?? '').trim()
const fileAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor editor
.chain() .chain()
.focus() .focus()
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT }) .setImage({ src: url, alt: typedName || fileAlt || DEFAULT_IMAGE_ALT })
.run() .run()
}) })
setUploading(false) setUploading(false)
@@ -426,6 +451,45 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
<Divider /> <Divider />
<ToolButton
label="درج جدول"
active={editor.isActive('table')}
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
>
<TableIcon className="h-4 w-4" />
</ToolButton>
<ToolButton label="حذف جدول" onClick={() => editor.chain().focus().deleteTable().run()}>
<TableIcon className="h-4 w-4 rotate-45" />
</ToolButton>
{editor.isActive('table') && (
<>
<ToolButton label="ستون قبل" onClick={() => editor.chain().focus().addColumnBefore().run()}>
<Columns2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ستون بعد" onClick={() => editor.chain().focus().addColumnAfter().run()}>
<Columns2Icon className="h-4 w-4 rotate-180" />
</ToolButton>
<ToolButton label="حذف ستون" onClick={() => editor.chain().focus().deleteColumn().run()}>
<Columns2Icon className="h-4 w-4 opacity-60" />
</ToolButton>
<ToolButton label="ردیف بالا" onClick={() => editor.chain().focus().addRowBefore().run()}>
<Rows2Icon className="h-4 w-4" />
</ToolButton>
<ToolButton label="ردیف پایین" onClick={() => editor.chain().focus().addRowAfter().run()}>
<Rows2Icon className="h-4 w-4 rotate-180" />
</ToolButton>
<ToolButton label="حذف ردیف" onClick={() => editor.chain().focus().deleteRow().run()}>
<Rows2Icon className="h-4 w-4 opacity-60" />
</ToolButton>
<ToolButton
label="سرستون"
active={editor.isActive('table')}
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
>
<PanelTopIcon className="h-4 w-4" />
</ToolButton>
</>
)}
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}> <ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
<BoldIcon className="h-4 w-4" /> <BoldIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
@@ -476,7 +540,7 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
ref={fileRef} ref={fileRef}
type="file" type="file"
data-editor-file-input data-editor-file-input
accept="image/jpeg,image/png,image/webp,image/gif,image/avif" accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
+14 -1
View File
@@ -18,6 +18,12 @@ export const ArticleImage = Image.extend({
parseHTML: (element) => parseHTML: (element) =>
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '', element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
}, },
// پیوند کپشن — کل متن figcaption به این نشانی لینک می‌شود
caption_href: {
default: '',
parseHTML: (element) =>
element.closest('figure')?.querySelector('figcaption a')?.getAttribute('href') ?? '',
},
} }
}, },
@@ -35,6 +41,7 @@ export const ArticleImage = Image.extend({
alt: img.getAttribute('alt'), alt: img.getAttribute('alt'),
title: img.getAttribute('title'), title: img.getAttribute('title'),
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '', caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
caption_href: element.querySelector('figcaption a')?.getAttribute('href') ?? '',
} }
}, },
}, },
@@ -51,12 +58,18 @@ export const ArticleImage = Image.extend({
] ]
// کپشن خالی → عنصر figcaption اصلاً رندر نمی‌شود (فقط تصویر می‌ماند) // کپشن خالی → عنصر figcaption اصلاً رندر نمی‌شود (فقط تصویر می‌ماند)
const captionHref = (HTMLAttributes.caption_href as string | undefined)?.trim() ?? ''
// توجه: آرایهٔ درونی به‌صورت تو در تو پاس می‌شود — اسپرد آن اسپک را تخت می‌کند
// و رندرر استاتیک با «Unsupported DomOutputSpec» می‌میرد.
const captionSpec = captionHref
? ['a', { href: captionHref, target: '_blank', rel: 'noopener noreferrer nofollow' }, caption]
: caption
return caption return caption
? [ ? [
'figure', 'figure',
{ class: 'article-figure' }, { class: 'article-figure' },
image, image,
['figcaption', { class: 'article-figure__caption' }, caption], ['figcaption', { class: 'article-figure__caption' }, captionSpec],
] ]
: ['figure', { class: 'article-figure' }, image] : ['figure', { class: 'article-figure' }, image]
}, },
@@ -0,0 +1,153 @@
import { useRef, useState } from 'react'
import { ArrowDownIcon, ArrowUpIcon, ImagePlusIcon, Loader2Icon, XIcon } from 'lucide-react'
import { adminApi } from '../../admin/adminClient'
type GalleryFieldProps = {
label: string
value: string[]
/** فهرست کامل نشانی‌ها را ذخیره می‌کند — همه از MinIO */
onSave: (urls: string[]) => Promise<boolean>
/** پیشوند نام ذخیره‌سازی فایل‌ها — مثلاً اسلاگ پروژه */
namePrefix?: string
}
/**
* گالری چندتصویری — فقط آپلود به MinIO.
* هیچ ورودی URL وجود ندارد؛ نشانی‌ها فقط از آپلود ساخته می‌شوند.
* هر تصویر: حذف، جابه‌جایی بالا/پایین. چند فایل همزمان قابل آپلود است.
*/
export function GalleryField({ label, value, onSave, namePrefix = 'image' }: GalleryFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(0)
const [error, setError] = useState('')
const commit = async (urls: string[]) => {
if (!(await onSave(urls))) setError('ذخیرهٔ گالری ناموفق بود — دوباره تلاش کنید')
}
const addFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return
setError('')
setUploading(files.length)
const next = [...value]
try {
let i = 0
for (const file of Array.from(files)) {
i += 1
const base = file.name.replace(/\.[^.]+$/, '')
const name = `${namePrefix}-${Date.now()}-${i}`
try {
const { url } = await adminApi.upload(file, `${name}-${base}`)
next.push(url)
} catch {
setError(`آپلود «${file.name}» ناموفق بود`)
} finally {
setUploading((n) => n - 1)
}
}
if (next.length !== value.length) await commit(next)
} finally {
setUploading(0)
if (inputRef.current) inputRef.current.value = ''
}
}
const removeAt = async (index: number) => {
const next = value.filter((_, i) => i !== index)
await commit(next)
}
const move = async (index: number, delta: number) => {
const target = index + delta
if (target < 0 || target >= value.length) return
const next = [...value]
;[next[index], next[target]] = [next[target], next[index]]
await commit(next)
}
return (
<div>
<p className="text-xs font-bold text-ivory/80">{label}</p>
{value.length === 0 ? (
<div className="mt-2 flex h-24 w-full flex-col items-center justify-center gap-1 border border-dashed border-ivory/20 text-ivory/30">
<ImagePlusIcon className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] font-light">تصویری در گالری نیست</span>
</div>
) : (
<ul className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{value.map((url, index) => (
<li key={`${url}-${index}`} className="group relative overflow-hidden rounded-lg border border-ivory/15 bg-char">
<img src={url} alt="" className="h-24 w-full object-cover" />
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-char/80 px-1.5 py-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
<div className="flex gap-1">
<button
type="button"
aria-label="انتقال به قبل"
title="انتقال به قبل"
disabled={index === 0}
onClick={() => void move(index, -1)}
className="flex h-6 w-6 items-center justify-center border border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char disabled:opacity-40"
>
<ArrowUpIcon className="h-3 w-3" />
</button>
<button
type="button"
aria-label="انتقال به بعد"
title="انتقال به بعد"
disabled={index === value.length - 1}
onClick={() => void move(index, 1)}
className="flex h-6 w-6 items-center justify-center border border-copper/50 bg-ink/90 text-copper hover:bg-copper hover:text-char disabled:opacity-40"
>
<ArrowDownIcon className="h-3 w-3" />
</button>
</div>
<button
type="button"
aria-label="حذف تصویر"
title="حذف تصویر"
onClick={() => void removeAt(index)}
className="flex h-6 w-6 items-center justify-center border border-red-400/40 bg-ink/90 text-red-300 hover:bg-red-500/20"
>
<XIcon className="h-3 w-3" />
</button>
</div>
</li>
))}
</ul>
)}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading > 0}
className="mt-3 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 > 0 ? (
<>
<Loader2Icon className="h-3.5 w-3.5 animate-spin" />
در حال آپلود {uploading} تصویر…
</>
) : (
<>
<ImagePlusIcon className="h-3.5 w-3.5" />
افزودن تصویر به گالری (آپلود به MinIO)
</>
)}
</button>
{error && <p className="mt-2 text-xs text-red-300">{error}</p>}
<input
ref={inputRef}
type="file"
multiple
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
className="hidden"
onChange={(event) => {
void addFiles(event.target.files)
}}
/>
</div>
)
}
+1 -1
View File
@@ -191,7 +191,7 @@ export function ImageField({
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif" accept="image/*,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
className="hidden" className="hidden"
onChange={(event) => { onChange={(event) => {
void pick(event.target.files?.[0]) void pick(event.target.files?.[0])
@@ -10,6 +10,7 @@ type ImageAttrs = {
alt: string | null alt: string | null
title: string | null title: string | null
caption: string | null caption: string | null
caption_href?: string | null
} }
const DELETE_BUTTON_CLASS = const DELETE_BUTTON_CLASS =
@@ -18,6 +19,35 @@ const DELETE_BUTTON_CLASS =
const INLINE_FIELD_CLASS = 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' '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'
/**
* فیلد متصل به attrs گره — چون مقدار جدید از پروسس‌میرور با یک رندر تأخیر
* می‌رسد، ورودی کنترل‌شدهٔ مستقیم کرسر را به آخر متن می‌اندازد. اینجا مقدار
* محلی نگه داشته می‌شود و فقط وقتی ورودی فوکوس ندارد از بیرون همگام می‌شود.
*/
function useAttrField(
attrValue: string,
commit: (value: string) => void,
externalRef?: React.MutableRefObject<HTMLInputElement | null>,
) {
const ownRef = useRef<HTMLInputElement | null>(null)
const ref = externalRef ?? ownRef
const [local, setLocal] = useState(attrValue)
useEffect(() => {
if (document.activeElement !== ref.current) setLocal(attrValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attrValue])
return {
ref,
value: local,
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setLocal(event.target.value)
commit(event.target.value)
},
}
}
const ADD_CAPTION_CLASS = const ADD_CAPTION_CLASS =
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper' 'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
@@ -45,12 +75,16 @@ function isExternalImage(src: string): boolean {
* - دکمه X روی تصویر کل تصویر را حذف می‌کند. * - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/ */
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) { export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs const { src, alt, title, caption, caption_href: captionHref } = node.attrs as ImageAttrs & { caption_href?: string | null }
const { runSave } = useAdmin() const { runSave } = useAdmin()
const hasCaption = Boolean((caption ?? '').trim()) const hasCaption = Boolean((caption ?? '').trim())
const [captionOpen, setCaptionOpen] = useState(true) const [captionOpen, setCaptionOpen] = useState(true)
const [rehosting, setRehosting] = useState(false) const [rehosting, setRehosting] = useState(false)
const captionInputRef = useRef<HTMLInputElement>(null) const captionInputRef = useRef<HTMLInputElement>(null)
const altField = useAttrField(alt ?? '', (v) => updateAttributes({ alt: v }))
const titleField = useAttrField(title ?? '', (v) => updateAttributes({ title: v }))
const captionField = useAttrField(caption ?? '', (v) => updateAttributes({ caption: v }), captionInputRef)
const captionHrefField = useAttrField(captionHref ?? '', (v) => updateAttributes({ caption_href: v }))
const focusOnOpen = useRef(false) const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption const showCaption = captionOpen || hasCaption
const external = isExternalImage(src) const external = isExternalImage(src)
@@ -64,7 +98,7 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
/** حذف کامل کپشن: عنصر figcaption و ورودی‌اش از DOM ویرایشگر بیرون می‌رود */ /** حذف کامل کپشن: عنصر figcaption و ورودی‌اش از DOM ویرایشگر بیرون می‌رود */
const removeCaption = () => { const removeCaption = () => {
updateAttributes({ caption: '' }) updateAttributes({ caption: '', caption_href: '' })
setCaptionOpen(false) setCaptionOpen(false)
} }
@@ -123,16 +157,14 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2"> <div contentEditable={false} className="mt-1.5 grid gap-1.5 sm:grid-cols-2">
<input <input
type="text" type="text"
value={alt ?? ''} {...altField}
onChange={(event) => updateAttributes({ alt: event.target.value })}
placeholder="نام تصویر / متن جایگزین (Alt)…" placeholder="نام تصویر / متن جایگزین (Alt)…"
aria-label="متن جایگزین تصویر" aria-label="متن جایگزین تصویر"
className={INLINE_FIELD_CLASS} className={INLINE_FIELD_CLASS}
/> />
<input <input
type="text" type="text"
value={title ?? ''} {...titleField}
onChange={(event) => updateAttributes({ title: event.target.value })}
placeholder="عنوان تصویر (Title)…" placeholder="عنوان تصویر (Title)…"
aria-label="عنوان تصویر" aria-label="عنوان تصویر"
className={INLINE_FIELD_CLASS} className={INLINE_FIELD_CLASS}
@@ -142,14 +174,20 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
{showCaption ? ( {showCaption ? (
<figcaption className="article-figure__caption" contentEditable={false}> <figcaption className="article-figure__caption" contentEditable={false}>
<input <input
ref={captionInputRef}
type="text" type="text"
value={caption ?? ''} {...captionField}
onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…" placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر" aria-label="کپشن تصویر"
className={INLINE_FIELD_CLASS} className={INLINE_FIELD_CLASS}
/> />
<input
type="url"
{...captionHrefField}
placeholder="لینک کپشن (اختیاری) — مثل منبع عکس…"
aria-label="لینک کپشن تصویر"
dir="ltr"
className={INLINE_FIELD_CLASS}
/>
{/* حذف کامل کپشن — تصویر دست‌نخورده می‌ماند */} {/* حذف کامل کپشن — تصویر دست‌نخورده می‌ماند */}
<button <button
type="button" type="button"
+33 -1
View File
@@ -9,6 +9,8 @@ export type SeoInput = {
excerpt: string excerpt: string
body: ArticleBodyDoc | string[] body: ArticleBodyDoc | string[]
keyphrase: string keyphrase: string
/** نام نویسنده — سیگنال E-E-A-T و اسکیمای Article */
author?: string
} }
type Status = 'good' | 'ok' | 'bad' type Status = 'good' | 'ok' | 'bad'
@@ -21,7 +23,9 @@ export function useSeoChecks(input: SeoInput): Check[] {
const text = docToText(input.body) const text = docToText(input.body)
const words = text.split(/\s+/).filter(Boolean) const words = text.split(/\s+/).filter(Boolean)
const kp = input.keyphrase.trim() const kp = input.keyphrase.trim()
const has = (s: string, needle: string) => s.includes(needle) // تطبیق کلمه کلیدی بی‌توجه به بزرگ/کوچکی حروف — و نرمال‌سازی عربی/فارسی
const norm = (s: string) => s.toLowerCase().replace(/ي/g, 'ی').replace(/ك/g, 'ک')
const has = (s: string, needle: string) => norm(s).includes(norm(needle))
const checks: Check[] = [] const checks: Check[] = []
// عنوان // عنوان
@@ -95,6 +99,34 @@ export function useSeoChecks(input: SeoInput): Check[] {
else else
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' }) checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
// ——— سئوی مولد (GEO) — آماده‌سازی برای ارجاع در پاسخ‌های هوش مصنوعی ———
// پاسخ مستقیم در ابتدای مقاله — دستیارهای هوشمند بخش‌های خودبسندهٔ آغازین را نقل می‌کنند
const firstPWords = firstParagraph(input.body).split(/\s+/).filter(Boolean).length
if (firstPWords >= 20)
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'good', hint: 'پاراگراف اول پاسخ نسبتاً کاملی می‌دهد — دقیقاً چیزی که هوش مصنوعی نقل می‌کند.' })
else
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'ok', hint: 'پاراگراف اول را پاسخ‌وار بنویسید (حدود ۴۰ تا ۶۰ واژه، پاسخ مستقیم پرسش اصلی مقاله).' })
// آمار و عدد — قوی‌ترین سیگنال ارجاع هوش مصنوعی (به‌همراه ذکر منبع)
const numberTokens = words.filter((w) => /\d|[۰-۹]/.test(w)).length
if (numberTokens >= 5)
checks.push({ label: `آمار و اعداد (${numberTokens} مورد)`, status: 'good', hint: 'متن داده‌محور است — سیگنال قوی برای استناد.' })
else
checks.push({ label: 'آمار و اعداد', status: 'ok', hint: 'حداقل ۵ عدد یا درصد (با ذکر منبع) به متن اضافه کنید — محتوای داده‌محور بیش از همه ارجاع می‌گیرد.' })
// تیتر پرسشی — هم‌راستا با الگوی پرسش کاربران و «People Also Ask»
if (h2s.some((h) => h.includes('؟') || h.includes('?')))
checks.push({ label: 'تیتر پرسشی', status: 'good', hint: 'حداقل یک تیتر به شکل پرسش است.' })
else
checks.push({ label: 'تیتر پرسشی', status: 'ok', hint: 'یک تیتر H2 پرسشی (با «؟») اضافه کنید — همان‌طور که کاربران می‌پرسند.' })
// نویسنده — سیگنال اعتماد و هویت روشن محتوا
if ((input.author ?? '').trim())
checks.push({ label: 'نویسنده', status: 'good', hint: 'نام نویسنده ثبت شده — در اسکیمای مقاله هم می‌آید.' })
else
checks.push({ label: 'نویسنده', status: 'ok', hint: 'نام نویسنده را بنویسید — سیگنال اعتماد (E-E-A-T) و در اسکیمای مقاله استفاده می‌شود.' })
return checks return checks
}, [input]) }, [input])
} }
+7 -4
View File
@@ -1,6 +1,8 @@
import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowLeftIcon } from 'lucide-react';
import { Reveal } from '../Reveal'; import { Reveal } from '../Reveal';
import { PremiumButton } from '../PremiumButton'; import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard'; import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api'; import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang'; import { useLang } from '../../i18n/lang';
@@ -128,10 +130,11 @@ export function Articles() {
{heading.cta_label && heading.cta_href && ( {heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 hidden md:flex justify-center"> <div className="mt-12 flex justify-center">
<PremiumButton to={href(heading.cta_href)} className="font-light"> <ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} /> <InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
</PremiumButton> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</div> </div>
</Reveal> </Reveal>
)} )}
+5 -1
View File
@@ -48,7 +48,7 @@ export function BeforeAfter() {
} }
return ( return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="ba-title"> <section className="bg-ink py-24 lg:py-32" aria-labelledby="ba-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading <SectionHeading
@@ -78,6 +78,8 @@ export function BeforeAfter() {
src={before_after.before} src={before_after.before}
alt={before_after.before_label} alt={before_after.before_label}
className="absolute inset-0 h-full w-full object-cover object-center" className="absolute inset-0 h-full w-full object-cover object-center"
loading="lazy"
decoding="async"
draggable={false} draggable={false}
/> />
@@ -86,6 +88,8 @@ export function BeforeAfter() {
src={before_after.after} src={before_after.after}
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover object-center" className="absolute inset-0 h-full w-full object-cover object-center"
loading="lazy"
decoding="async"
draggable={false} draggable={false}
/> />
</div> </div>
+9 -11
View File
@@ -11,29 +11,27 @@ type FaqProps = {
hideHeading?: boolean; hideHeading?: boolean;
} }
export function Faq({ hideHeading = false }: FaqProps) { export function Faq({ hideHeading = false, tone = 'gray' }: FaqProps & { tone?: 'black' | 'gray' }) {
const { faqs } = useSite() const { faqs } = useSite()
const { t } = useLang() const { t } = useLang()
const [open, setOpen] = useState<number | null>(0) const [open, setOpen] = useState<number | null>(0)
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title"> <section className={`${tone === 'black' ? 'bg-char' : 'bg-ink'} py-24 lg:py-32`} aria-labelledby="faq-title">
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
{/* Header — centered, full width */}
{!hideHeading && ( {!hideHeading && (
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center"> <div className="lg:col-span-4">
<SectionHeading <SectionHeading
title={t('fallbackFaqTitle')} title={t('fallbackFaqTitle')}
lead={t('fallbackFaqLead')} lead={t('fallbackFaqLead')}
headingKey="faq" headingKey="faq"
align="center"
/> />
</div> </div>
)} )}
{/* Accordion list — constrained width, direction-aware */} <div className={hideHeading ? "" : "lg:col-span-8"}>
<div className="w-full max-w-3xl mx-auto">
<ul> <ul>
{faqs.map((faq, i) => { {faqs.map((faq, i) => {
const isOpen = open === i const isOpen = open === i
@@ -45,9 +43,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
type="button" type="button"
onClick={() => setOpen(isOpen ? null : i)} onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen} aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper" className="flex w-full items-center justify-between gap-6 py-6 text-right 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'}`}> <span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question} {faq.question}
</span> </span>
<PlusIcon <PlusIcon
@@ -65,7 +63,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden" className="overflow-hidden"
> >
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p> <p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -1,93 +1,42 @@
import React, { useState } from 'react' import React from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { PremiumButton } from '../PremiumButton' import { PremiumButton } from '../PremiumButton'
import { ProjectCard } from '../ProjectCard' import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api' import { useSectionHeading, useSite } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import type { ProjectStatus } from '../../data/site'
import { projectsData } from '../../data/projectsData'
export function FeaturedProjects() { export function FeaturedProjects() {
const { status_labels } = useSite() const { projects } = useSite()
const { t, href } = useLang() const { href } = useLang()
const heading = useSectionHeading('featured_projects') const heading = useSectionHeading('featured_projects')
const save = useFieldSaver() const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => { // سه پروژهٔ ثابت صفحهٔ اصلی — بدون تب وضعیت؛ ویرایش پنل همان‌جا اعمال می‌شود
const label = status_labels.find((s) => s.key === key) const FEATURED_TITLES = ['آرامش زنیث', 'آرامش الیسیان', 'آرامش آوالون']
if (!label) return const list = projects.filter((p) => FEATURED_TITLES.includes(p.title))
void save('status-labels', label.id, { label: value }, (d) => { .sort((a, b) => FEATURED_TITLES.indexOf(a.title) - FEATURED_TITLES.indexOf(b.title))
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: useStatusLabel('done') },
{ key: 'progress', label: useStatusLabel('progress') },
]
const [status, setStatus] = useState<ProjectStatus>('done')
// Filter and slice static project data
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
const BASE_IMG_URL = "/seed-media/2026/09/";
return ( return (
<section className="w-full max-w-full overflow-x-clip bg-ink py-16 md:py-24 lg:py-32" aria-labelledby="projects-title"> <section className="w-full max-w-full overflow-x-clip bg-char py-16 md:py-24 lg:py-32" aria-labelledby="projects-title">
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10"> <div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" /> <SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */} <div className="mt-8 min-h-[420px] md:mt-10">
<div className="mt-8 flex flex-wrap items-center justify-center gap-3 md:mt-10 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => {
const isSelected = status === filter.key
return (
<React.Fragment key={filter.key}>
{index > 0 && (
<span className="hidden h-6 w-[2px] rounded-full bg-ivory/10 sm:inline-block" aria-hidden="true" />
)}
<button
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => setStatus(filter.key)}
className={`group relative rounded-lg px-4 py-2 text-xs font-medium transition-all duration-300 ease-lux active:scale-95 cursor-pointer select-none backdrop-blur-md md:px-6 md:py-2.5 md:text-sm ${
isSelected
? '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-8 min-h-[380px] md:mt-10 md:min-h-[420px]">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={status} key="featured"
initial={{ opacity: 0, y: 15 }} initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] md:grid md:grid-cols-3 md:gap-6 md:overflow-visible md:pb-0" className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] md:grid md:grid-cols-3 md:gap-6 md:overflow-visible md:pb-0"
> >
{list.map((project, i) => { {list.map((project, i) => {
const dummyImageName = `${(i % 6) + 1}.png`; const imageUrl = project.image || undefined;
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
return ( return (
<motion.div <motion.div
+1 -1
View File
@@ -51,7 +51,7 @@ export function Gallery() {
<img <img
src={image.src} src={image.src}
alt={image.alt} alt={image.alt}
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" loading="lazy" decoding="async"
/> />
</button> </button>
</EditableItem> </EditableItem>
+105 -17
View File
@@ -1,16 +1,55 @@
import React from 'react' import React from 'react'
import { motion, useScroll, useTransform } from 'framer-motion' import { motion, useScroll, useTransform } from 'framer-motion'
import { ChevronDownIcon } from 'lucide-react' import { Link } from 'react-router-dom'
import { PremiumButton } from '../PremiumButton' import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { HeroProjectBadge } from './HeroProjectBadge'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
/** واژه‌های عمومی برچسب نشان که در تطبیق نام پروژه نادیده گرفته می‌شوند */
const NOISE_WORDS = ['پروژه', 'فعال', 'آرامش', 'گروه', 'ساختمانی', 'Active', 'Project', 'Aramesh']
/** نام لاتین برچسب انگلیسی → معادل فارسی در عنوان پروژه (پروژه‌های بدون title_en) */
const LATIN_ALIAS: Record<string, string> = { elysian: 'الیسیان' }
export function Hero() { export function Hero() {
const { brand, hero } = useSite() const { brand, hero, projects } = useSite()
const { t, href } = useLang() const { t, href, lang } = useLang()
/**
* پروژهٔ فعال هیرو → صفحهٔ همان پروژه.
* برچسب نشان مثل «پروژه فعال آرامش الیسیان» یا «Elysian Aramesh Active Project»
* است؛ واژه‌های عمومی حذف و نام باقی‌مانده با عنوان پروژه‌ها تطبیق داده می‌شود.
*/
const activeProjectHref = (() => {
const label = hero.badge_label.trim()
if (!label) return undefined
const strip = (text: string) => {
let out = text
NOISE_WORDS.forEach((w) => {
out = out.split(w).join(' ')
})
return out.replace(/[\s\-—_]+/g, '').trim().toLowerCase()
}
let key = strip(label)
// برچسب انگلیسی ممکن است نام لاتین داشته باشد و پروژه فقط عنوان فارسی (Elysian → الیسیان)
if (key.length < 3 || LATIN_ALIAS[key]) key = LATIN_ALIAS[key] ?? key
if (key.length < 3) return undefined
const hit = projects.find((p) => {
const candidates = [p.title, p.title_en ?? ''].filter(Boolean)
return candidates.some((title) => {
const k = strip(title)
return k.length >= 3 && (k.includes(key) || key.includes(k))
})
})
return hit ? `/projects/${hit.slug}` : undefined
})()
const save = useFieldSaver() const save = useFieldSaver()
const { scrollY } = useScroll() const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110]) const imageY = useTransform(scrollY, [0, 700], [0, 110])
@@ -51,7 +90,7 @@ export function Hero() {
<motion.p <motion.p
initial={{ opacity: 0, y: 18 }} initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 2.1, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.05, ease: [0.23, 1, 0.32, 1] }}
className="text-sm font-light text-copper tracking-wide" className="text-sm font-light text-copper tracking-wide"
> >
<InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} /> <InlineEdit value={hero.kicker} onSave={(v) => saveHero('kicker', v)} />
@@ -61,7 +100,7 @@ export function Hero() {
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 28 }} initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.12, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory leading-[1.3]" className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
> >
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} /> <InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
@@ -71,33 +110,82 @@ export function Hero() {
<motion.p <motion.p
initial={{ opacity: 0, y: 22 }} initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.32, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75" className="mt-7 max-w-xl text-base font-light leading-9 text-ivory/75"
> >
<InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} /> <InlineEdit multiline value={hero.subtitle} onSave={(v) => saveHero('subtitle', v)} />
</motion.p> </motion.p>
{/* پیوند درباره ما — زیر تیتر، در هر دو زبان */}
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.24, ease: [0.23, 1, 0.32, 1] }}
className="mt-4"
>
<Link
to={href('/about')}
className="inline-flex items-center gap-1.5 border-b border-ivory/25 pb-0.5 text-sm font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
{t('aboutUs')}
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" aria-hidden="true" />
</Link>
</motion.div>
{/* داک دکمه‌ها: استایل کپسولی، شیشه‌ای و دایره‌ای */} {/* داک دکمه‌ها: استایل کپسولی، شیشه‌ای و دایره‌ای */}
<motion.div <motion.div
initial={{ opacity: 0, y: 22 }} initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: 0.28, ease: [0.23, 1, 0.32, 1] }}
className="mt-10 flex flex-wrap items-center gap-4" className="mt-10 flex flex-wrap items-center gap-4"
> >
{/* دکمه اصلی */} {/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<PremiumButton to={href(hero.cta_primary_href)}> <ButtonLink
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} /> to={href(hero.cta_primary_href)}
</PremiumButton> variant="copper"
className="!p-1.5 !ps-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
>
<span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" />
</span>
</ButtonLink>
{/* دکمه ثانویه */} {/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md"> <ButtonLink
to={href(hero.cta_secondary_href)}
variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
>
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} /> <InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
</PremiumButton> </ButtonLink>
</motion.div> </motion.div>
</div> </div>
{/* کادر آمار شناور (گوشه پایین چپ): شیشه‌ای با لبه‌های گرد مدرن */} {/* کادر آمار شناور (گوشه پایین چپ) — شیشهٔ چندلایه، خط مویی مسی، هالهٔ نرم و نشان زنده */}
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.36, ease: [0.23, 1, 0.32, 1] }}
className="group absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-fit max-w-[22rem] lg:block lg:ltr:right-10 lg:rtl:left-10"
>
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-ink/55 p-6 shadow-2xl shadow-black/50 backdrop-blur-2xl transition-colors duration-500 group-hover:border-copper/40">
{/* خط مویی مسی روی لبهٔ شروع */}
<span aria-hidden="true" className="absolute inset-y-0 start-0 w-[3px] bg-gradient-to-b from-copper/90 via-copper/30 to-transparent" />
{/* هالهٔ مسی نرم گوشهٔ مقابل — با هاور پررنگ‌تر */}
<span aria-hidden="true" className="absolute -top-20 -end-20 h-44 w-44 rounded-full bg-copper/10 blur-3xl transition-colors duration-700 group-hover:bg-copper/20" />
<div className="relative">
<HeroProjectBadge
value={String(hero.badge_value ?? '').trim()}
label={hero.badge_label}
href={activeProjectHref ? href(activeProjectHref) : undefined}
/>
</div>
</div>
</motion.div>
</motion.div> </motion.div>
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */} {/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
@@ -0,0 +1,48 @@
import { Building2Icon } from 'lucide-react'
import { Link } from 'react-router-dom'
/**
* نشان «پروژه فعال» هیرو — آیکون و متن کنار هم، با فاصلهٔ یکنواخت و
* چیدمان وسط‌چین. فلکس‌باکس ستون را از بین می‌برد تا فاصلهٔ آیکون و متن
* یکدست بماند و آیکون درست با متن هم‌تراز شود.
*/
export function HeroProjectBadge({
value,
label,
href,
}: {
value: string
label: string
/** نشانی صفحهٔ پروژهٔ فعال — نشان کلیک‌پذیر می‌شود و به همان پروژه می‌رود */
href?: string
}) {
const body = (
<div className="flex items-center gap-3.5 text-center">
{/* آیکون در کادر مربعی با نشان زندهٔ مسی */}
<span className="relative flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-copper/40 bg-copper/10">
<Building2Icon className="h-6 w-6 text-copper" aria-hidden="true" />
<span className="absolute -end-1 -top-1 flex h-3 w-3" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-copper opacity-60" />
<span className="relative inline-flex h-3 w-3 rounded-full border border-ink bg-copper" />
</span>
</span>
<div className="flex flex-col items-center">
{value && <p className="font-mono text-[2.5rem] font-bold leading-none tracking-tight text-copper">{value}</p>}
<p className={`text-center text-[15px] font-light leading-7 text-ivory/85 ${value ? 'mt-2' : ''}`}>{label}</p>
</div>
</div>
)
// بدون نشانی همان ظرف ساده می‌ماند؛ با نشانی، کل نشان یک پیوند به پروژه است
if (!href) return body
return (
<Link
to={href}
className="block rounded-2xl transition-transform duration-300 ease-lux hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={label}
>
{body}
</Link>
)
}
+10 -23
View File
@@ -1,22 +1,20 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { CheckIcon } from 'lucide-react' import { CheckIcon, SendIcon } from 'lucide-react'
import Swal from 'sweetalert2' import Swal from 'sweetalert2'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { PremiumButton } from '../PremiumButton' import { Button } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
import { API_URL } from '../../config' import { API_URL } from '../../config'
import { track } from '../../analytics'
export function Newsletter() { export function Newsletter() {
const { newsletter } = useSite() const { newsletter } = useSite()
const { t } = useLang() const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [website, setWebsite] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle') const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
@@ -77,7 +75,7 @@ export function Newsletter() {
const res = await fetch(`${API_URL}/api/newsletter`, { const res = await fetch(`${API_URL}/api/newsletter`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailValue, website }), body: JSON.stringify({ email: emailValue }),
}) })
if (!res.ok) { if (!res.ok) {
@@ -89,7 +87,6 @@ export function Newsletter() {
return return
} }
track('newsletter-signup')
setStatus('done') setStatus('done')
setEmail('') setEmail('')
} catch { } catch {
@@ -101,7 +98,7 @@ export function Newsletter() {
} }
return ( return (
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title"> <section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
<SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" /> <SingletonPill resource="newsletter" label="ویرایش متن‌های خبرنامه" className="absolute left-4 top-4" />
{/* هاله نور پس‌زمینه برای عمق بصری */} {/* هاله نور پس‌زمینه برای عمق بصری */}
@@ -120,17 +117,6 @@ export function Newsletter() {
<Reveal delay={0.08}> <Reveal delay={0.08}>
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate> <form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
{/* honeypot — خارج از دید؛ فقط ربات‌ها آن را پر می‌کنند */}
<input
type="text"
name="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div className="flex-1"> <div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only"> <label htmlFor="newsletter-email" className="sr-only">
{newsletter.placeholder} {newsletter.placeholder}
@@ -156,18 +142,19 @@ export function Newsletter() {
)} )}
</div> </div>
<button <Button
type="submit" type="submit"
variant="dark"
disabled={sending} 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" className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
> >
<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" /> <SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" />
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink"> <span>
{sending ? newsletter.sending_label : ( {sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} /> <InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
)} )}
</span> </span>
</button> </Button>
</form> </form>
</Reveal> </Reveal>
</div> </div>
+3 -3
View File
@@ -5,7 +5,7 @@ import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Services() { export function Services({ tone = 'black' }: { tone?: 'black' | 'gray' } = {}) {
const { services } = useSite() const { services } = useSite()
const heading = useSectionHeading('services') const heading = useSectionHeading('services')
const save = useFieldSaver() const save = useFieldSaver()
@@ -17,7 +17,7 @@ export function Services() {
}) })
return ( return (
<section className="w-full max-w-full overflow-hidden bg-ink py-12 md:py-24 lg:py-32" aria-labelledby="services-title"> <section className={`w-full max-w-full overflow-hidden ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-12 md:py-24 lg:py-32`} aria-labelledby="services-title">
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10"> <div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
<Reveal className="max-w-2xl text-start"> <Reveal className="max-w-2xl text-start">
<h2 id="services-title" className="text-section font-bold text-ivory"> <h2 id="services-title" className="text-section font-bold text-ivory">
@@ -43,7 +43,7 @@ export function Services() {
<h3 className="mt-3 text-sm font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper md:mt-6 md:text-lg"> <h3 className="mt-3 text-sm font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper md:mt-6 md:text-lg">
{service.title} {service.title}
</h3> </h3>
<p className="mt-2 text-xs font-light leading-relaxed text-ivory/55 line-clamp-2 md:mt-3 md:text-sm md:leading-8 md:line-clamp-none"> <p className="mt-2 text-xs font-light leading-relaxed text-ivory/55 md:mt-3 md:text-sm md:leading-8">
{service.desc} {service.desc}
</p> </p>
</article> </article>
+2 -2
View File
@@ -65,13 +65,13 @@ function Counter({ value }: { value: string | number }) {
return <span ref={ref}>{renderValue}</span>; return <span ref={ref}>{renderValue}</span>;
} }
export function Stats() { export function Stats({ tone = 'black' }: { tone?: 'black' | 'gray' } = {}) {
const { stats } = useSite(); const { stats } = useSite();
const { t } = useLang(); const { t } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
return ( return (
<section className="w-full max-w-full overflow-x-clip bg-char py-8 md:py-16" aria-label={t('statsAria')}> <section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-8 md:py-16`} aria-label={t('statsAria')}>
<div className="mx-auto grid max-w-site grid-cols-2 gap-3.5 p-4 md:grid-cols-4 md:gap-y-12 md:px-10 md:py-0"> <div className="mx-auto grid max-w-site grid-cols-2 gap-3.5 p-4 md:grid-cols-4 md:gap-y-12 md:px-10 md:py-0">
{stats.map((stat) => {stats.map((stat) =>
<EditableItem key={stat.id} resource="stats" item={stat}> <EditableItem key={stat.id} resource="stats" item={stat}>
+11 -8
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { PremiumButton } from '../PremiumButton' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
@@ -14,9 +14,11 @@ type StoryProps = {
showQuote?: boolean; showQuote?: boolean;
alignTop?: boolean; alignTop?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
/** پس‌زمینه: black = char (تیره) و gray = ink (روشن‌تر) — ریتم تناوب بخش‌ها */
tone?: 'black' | 'gray';
} }
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) { export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, tone = 'gray', children }: StoryProps) {
const { story } = useSite() const { story } = useSite()
const { href } = useLang() const { href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
@@ -33,7 +35,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
} }
return ( return (
<section id="story" className="bg-ink py-24 lg:py-32" aria-labelledby="story-title"> <section id="story" className={`${tone === 'black' ? 'bg-char' : 'bg-ink'} py-24 lg:py-32`} aria-labelledby="story-title">
<div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}> <div className={`mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-2 lg:px-10 ${alignTop ? 'items-start' : 'items-center'}`}>
<div> <div>
{showLabel && ( {showLabel && (
@@ -47,7 +49,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
<Reveal delay={0.08}> <Reveal delay={0.08}>
{story.paragraphs.map((paragraph, i) => ( {story.paragraphs.map((paragraph, i) => (
<p key={i} className={`text-base font-light leading-9 text-ivory/65 ${i === 0 ? 'mt-8' : 'mt-5'}`}> <p key={i} className={`text-base font-light leading-9 text-ivory/65 text-justify ${i === 0 ? 'mt-8' : 'mt-5'}`}>
<InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} /> <InlineEdit multiline value={paragraph} onSave={(v) => saveParagraph(i, v)} />
</p> </p>
))} ))}
@@ -89,9 +91,10 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && ( {showLink && (
<Reveal delay={0.22}> <Reveal delay={0.22}>
<PremiumButton to={href(story.link_href)} className="mt-10 font-light"> <ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} /> <InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
</PremiumButton> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</Reveal> </Reveal>
)} )}
@@ -110,7 +113,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
<img <img
src={item.src} src={item.src}
alt={item.alt} alt={item.alt}
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105" className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105" loading="lazy" decoding="async"
/> />
</div> </div>
</Reveal> </Reveal>
+17 -11
View File
@@ -4,11 +4,11 @@ import { useSite } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Team() { export function Team({ tone = 'gray' }: { tone?: 'black' | 'gray' } = {}) {
const { team } = useSite() const { team } = useSite()
const { t } = useLang() const { t } = useLang()
return ( return (
<section className="w-full max-w-full overflow-x-clip bg-ink py-16 md:py-24 lg:py-32" aria-labelledby="team-title"> <section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-16 md:py-24 lg:py-32`} aria-labelledby="team-title" >
<div className="mx-auto max-w-site px-4 md:px-6 lg:px-10"> <div className="mx-auto max-w-site px-4 md:px-6 lg:px-10">
<SectionHeading <SectionHeading
title={t('fallbackTeamTitle')} title={t('fallbackTeamTitle')}
@@ -17,17 +17,23 @@ export function Team() {
align="center" align="center"
/> />
<div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 md:mt-16 md:gap-8"> <div className="mx-auto mt-8 flex max-w-5xl flex-wrap justify-center gap-4 md:mt-16 md:gap-8">
{team.map((member, i) => ( {team.map((member, i) => (
<Reveal key={member.id} delay={0.06 * i} className="h-full"> <Reveal
key={member.id}
delay={0.06 * i}
className={`h-full ${
i === 0
? 'w-full max-w-[19rem] md:max-w-[19rem]' /* مدیرعامل — تنها در ردیف اول، وسط */
: 'w-[calc(50%-0.5rem)] max-w-[19rem] md:w-[calc(50%-1rem)]'
}`}
>
<EditableItem resource="team" item={member} className="h-full"> <EditableItem resource="team" item={member} className="h-full">
<article className="group flex h-full flex-col items-center rounded-xl border border-ivory/10 bg-char p-3 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift md:rounded-2xl md:p-10"> {/* اضافه شدن rounded-2xl برای گرد شدن گوشه‌ها */}
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-copper/30 text-sm font-bold text-copper transition-colors duration-500 group-hover:border-copper md:h-24 md:w-24 md:text-3xl"> <article className="group flex h-full flex-col items-center rounded-xl border border-ivory/10 bg-char p-4 text-center md:rounded-2xl md:p-10 transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
{member.name.trim().charAt(0)} <h3 className="text-lg font-bold text-ivory">{member.name}</h3>
</div> <p className="mt-2 text-sm font-light text-copper">{member.role}</p>
<h3 className="mt-2 text-xs font-bold text-ivory md:mt-8 md:text-lg">{member.name}</h3> <p className="mt-6 border-t border-ivory/10 pt-6 text-sm font-light text-ivory/60">
<p className="mt-0.5 text-[11px] font-medium text-copper line-clamp-1 md:mt-2 md:text-sm md:font-light md:line-clamp-none">{member.role}</p>
<p className="mt-2 w-full border-t border-ivory/10 pt-2 text-[10px] font-light leading-relaxed text-ivory/60 line-clamp-2 md:mt-6 md:pt-6 md:text-sm md:line-clamp-none">
{member.bio} {member.bio}
</p> </p>
</article> </article>
+56 -64
View File
@@ -1,4 +1,4 @@
import { useRef } from 'react' import React, { useLayoutEffect, useRef, useState } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion' import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api' import { useSite, type TimelineItem } from '../../api'
import { useLang } from '../../i18n/lang' import { useLang } from '../../i18n/lang'
@@ -9,17 +9,17 @@ const ease = [0.23, 1, 0.32, 1] as const
type Milestone = TimelineItem type Milestone = TimelineItem
function DesktopTimelineItem({ item, index }: { item: Milestone; index: number }) { function Item({ item, index, featured = false, onLi }: { item: Milestone; index: number; featured?: boolean; onLi?: (el: HTMLLIElement | null) => void }) {
const ref = useRef<HTMLLIElement>(null) const ref = useRef<HTMLLIElement | null>(null)
const active = useInView(ref, { once: true, amount: 0.6 }) const active = useInView(ref, { once: true, amount: 0.6 })
const onRight = index % 2 === 0 const onRight = index % 2 === 0
return ( return (
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16"> <li ref={(el) => { ref.current = el; onLi?.(el) }} className={`relative md:grid md:grid-cols-2 md:gap-16 ${featured ? 'mt-10 md:mt-16' : ''}`}>
{/* دایره‌ها: مرکز دایره دقیقاً روی مرکز خط 2px است */} {/* دایره‌ها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
<span <span
aria-hidden="true" aria-hidden="true"
className={`absolute right-1/2 top-8 z-10 h-3 w-3 translate-x-1/2 rounded-full transition-all duration-500 ease-lux ${ className={`absolute right-[-5px] z-10 -translate-y-1/2 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${featured ? 'top-0' : 'top-1/2'} ${
active active
? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]' ? 'bg-copper shadow-[0_0_15px_rgba(197,143,92,0.6),0_0_0_6px_rgba(213,135,76,0.15)]'
: 'bg-ivory/20' : 'bg-ivory/20'
@@ -27,7 +27,13 @@ function DesktopTimelineItem({ item, index }: { item: Milestone; index: number }
/> />
<motion.div <motion.div
className={onRight ? 'md:col-start-1 md:pl-16 md:text-left' : 'md:col-start-2 md:pr-16'} className={`pr-8 md:pr-0 ${
featured
? 'md:col-span-2 md:col-start-1 md:max-w-xl md:mx-auto'
: onRight
? 'md:col-start-1 md:pl-16 md:text-left'
: 'md:col-start-2 md:pr-16'
}`}
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }} animate={active ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
transition={{ duration: 0.55, ease }} transition={{ duration: 0.55, ease }}
@@ -46,19 +52,40 @@ function DesktopTimelineItem({ item, index }: { item: Milestone; index: number }
) )
} }
export function Timeline() { export function Timeline({ tone = 'gray' }: { tone?: 'black' | 'gray' } = {}) {
const { timeline } = useSite() const { timeline } = useSite()
const { t } = useLang() const { t } = useLang()
const ref = useRef<HTMLOListElement>(null)
const desktopRef = useRef<HTMLDivElement>(null) const lastLiRef = useRef<HTMLLIElement | null>(null)
// فاصلهٔ انتهای فهرست تا بالای کارت پایانی — خط دقیقاً روی بالای کارت تمام می‌شود
const [lineBottom, setLineBottom] = useState(0)
const { scrollYProgress } = useScroll({ const { scrollYProgress } = useScroll({
target: desktopRef, target: ref,
offset: ['start 70%', 'end 60%'], offset: ['start 70%', 'end 110%'],
}) })
const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1]) const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1])
useLayoutEffect(() => {
const measure = () => {
const ol = ref.current
const li = lastLiRef.current
if (!ol || !li) return
const gap = ol.getBoundingClientRect().bottom - li.getBoundingClientRect().top
setLineBottom(Math.max(0, Math.round(gap)))
}
measure()
const observer = new ResizeObserver(measure)
if (ref.current) observer.observe(ref.current)
if (lastLiRef.current) observer.observe(lastLiRef.current)
window.addEventListener('resize', measure)
return () => {
observer.disconnect()
window.removeEventListener('resize', measure)
}
}, [])
return ( return (
<section className="w-full max-w-full overflow-x-clip bg-char py-16 md:py-24 lg:py-32" aria-labelledby="timeline-title"> <section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-16 md:py-24 lg:py-32`} aria-labelledby="timeline-title">
<div className="mx-auto w-full max-w-site px-4 md:px-6 lg:px-10"> <div className="mx-auto w-full max-w-site px-4 md:px-6 lg:px-10">
<div className="mb-8 md:mb-16"> <div className="mb-8 md:mb-16">
@@ -70,58 +97,23 @@ export function Timeline() {
/> />
</div> </div>
{/* Mobile View: Minimalist Editorial Spine */} <div className="relative mx-auto mt-20 max-w-4xl">
<div className="block md:hidden px-5 py-4"> {/* خط روی خود فهرست بسته می‌شود؛ انتهایش به انتهای کارت آخر (مرکز دایره) می‌رسد */}
<div className="relative rtl:pr-6 rtl:border-r ltr:pl-6 ltr:border-l border-copper/25 space-y-6 my-2"> <ol ref={ref} className="relative flex flex-col gap-12 md:gap-16">
{timeline.map((item) => ( {/* خط پس‌زمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
<EditableItem key={item.id} resource="timeline" item={item}> <span
<div className="relative group"> aria-hidden="true"
{/* Timeline Node on the vertical spine */} className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
<span style={{ bottom: lineBottom }}
aria-hidden="true" />
className="absolute rtl:-right-[29px] ltr:-left-[29px] top-1.5 h-2.5 w-2.5 rounded-full bg-copper ring-4 ring-copper/15" {/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
/> <motion.span
{/* Milestone Content */} aria-hidden="true"
<div className="p-0"> className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/55 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
<div className="flex items-center gap-2 mb-1"> style={{ scaleY, bottom: lineBottom }}
<span className="text-[11px] font-bold tracking-wider text-copper bg-copper/10 px-2 py-0.5 rounded-md"> />
{item.year}
</span>
<h3 className="text-sm font-semibold text-ivory">
{item.title}
</h3>
</div>
<p className="text-xs text-ivory/60 leading-relaxed line-clamp-2 rtl:pr-0.5 ltr:pl-0.5">
{item.desc}
</p>
</div>
</div>
</EditableItem>
))}
</div>
<div className="mt-6">
<AddItemButton resource="timeline" />
</div>
</div>
{/* Desktop View: Original Vertical Timeline */}
<div ref={desktopRef} className="relative mx-auto mt-20 hidden max-w-4xl md:block">
{/* خط پس‌زمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
<span
aria-hidden="true"
className="absolute inset-y-0 right-1/2 w-[2px] translate-x-1/2 bg-ivory/10"
/>
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
<motion.span
aria-hidden="true"
className="absolute inset-y-0 right-1/2 w-[2px] origin-top translate-x-1/2 bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)]"
style={{ scaleY }}
/>
<ol className="flex flex-col gap-16">
{timeline.map((item, i) => ( {timeline.map((item, i) => (
<DesktopTimelineItem key={item.id} item={item} index={i} /> <Item key={item.id} item={item} index={i} featured={i === timeline.length - 1} onLi={i === timeline.length - 1 ? (el) => { lastLiRef.current = el } : undefined} />
))} ))}
</ol> </ol>
<div className="mt-12"> <div className="mt-12">
+6 -3
View File
@@ -6,7 +6,7 @@ import { InlineEdit } from '../admin/InlineEdit';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { useLang, formatNumber } from '../../i18n/lang'; import { useLang, formatNumber } from '../../i18n/lang';
export function WhyUs() { export function WhyUs({ tone = 'black' }: { tone?: 'black' | 'gray' } = {}) {
const { why_us } = useSite(); const { why_us } = useSite();
const { lang } = useLang(); const { lang } = useLang();
const heading = useSectionHeading('why_us'); const heading = useSectionHeading('why_us');
@@ -25,7 +25,7 @@ export function WhyUs() {
}; };
return ( return (
<section className="w-full max-w-full overflow-x-clip bg-ink py-16 md:py-24 lg:py-32" aria-labelledby="why-title"> <section className={`w-full max-w-full overflow-x-clip ${tone === 'black' ? 'bg-char' : 'bg-ink'} py-16 md:py-24 lg:py-32`} aria-labelledby="why-title">
<div className="mx-auto grid max-w-site gap-8 px-4 md:gap-14 md:px-6 lg:grid-cols-12 lg:px-10"> <div className="mx-auto grid max-w-site gap-8 px-4 md:gap-14 md:px-6 lg:grid-cols-12 lg:px-10">
<div className="lg:col-span-5"> <div className="lg:col-span-5">
<SectionHeading <SectionHeading
@@ -38,7 +38,10 @@ export function WhyUs() {
<img <img
src={heading.image} src={heading.image}
alt={heading.title} alt={heading.title}
className="mt-6 hidden h-80 w-full object-cover md:mt-10 md:block" /> className="mt-10 h-80 w-full object-cover"
loading="lazy"
decoding="async" />
</Reveal> </Reveal>
)} )}
</div> </div>
+73 -69
View File
@@ -32,66 +32,71 @@ type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: nu
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description * description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
*/ */
const RAW_CSV = `name,year,new,lat,lng,description const RAW_CSV = `name,year,new,lat,lng,description
پروژه سروش,1381,no,32.6662842,51.701735, پروژه سروش,1381,no,32.6662842,51.701735
چهرازی-زیتون,1383,no,32.6700428,51.69985, چهرازی-زیتون,1383,no,32.6700428,51.69985
جعفریان,1384,no,32.6669259,51.701085, جعفریان,1384,no,32.6669259,51.701085
بهار,1385,yes,32.6468963,51.6899119, بهار,1385,no,32.6468963,51.6899119
حسن مرادی,1385,no,32.6704278,51.6985567, حسن مرادی,1385,no,32.6704278,51.6985567
کوثر,1386,no,32.6522044,51.6856474, کوثر- هشت بهشت غربی,1386,no,32.6522044,51.6856474
صبا,1387,no,32.6541363,51.6847828, صبا,1387,no,32.6541363,51.6847828
پورمعالی - بهشت,1387,no,32.6500402,51.6919823, شریف واقفی - بهشت,1387,no,32.6500402,51.6919823
2 پروژه- فردوس,1388,no,32.6614576,51.706429, پروژه- فردوس,1388,no,32.6614576,51.706429
ارم,1388,no,32.6563712,51.710694, ارم,1388,no,32.6563712,51.710694
سینا,1388,no,32.6469299,51.6855403, سینا,1388,no,32.6469299,51.6855403
مسجد سید,1389,yes,32.6669953,51.6557085, مسجد سید,1389,no,32.6669953,51.6557085
هفده شهریور,1389,yes,32.7249998,51.6543819, هفده شهریور,1389,no,32.7249998,51.6543819
آرامش رفیع زاده,1390,no,32.6467107,51.6873398, آرامش رفیع زاده,1390,no,32.6467107,51.6873398
بن بست شریعت,1390,yes,32.6459939,51.6897556, باهنر- ارشاد,1391,no,32.6822102,51.6690006
قصر - یاسین,1390,no,32.6663549,51.7065512, بن بست شریعت,1390,no,32.6459939,51.6897556
پردیس,1390,no,32.6518961,51.6973621, قصر - یاسین,1389,no,32.6663549,51.7065512
آرامش حکیم,1392,no,32.6426783,51.6813477, پردیس,1390,no,32.6518961,51.6973621
آرامش دخانیات,1393,no,32.6411924,51.6797555, آرامش حکیم,1392,no,32.6426783,51.6813477
بهشتی فرشادی,1393,no,32.6531989,51.6790984, آرامش- ادیب,1387,no,32.6789359,51.6771241
کاکتوس,1394,no,32.6486747,51.6859003, آرامش دخانیات,1393,no,32.6411924,51.6797555
آرامش - صیاد,1395,no,32.6486798,51.712659, بهشتی فرشادی,1393,no,32.6531989,51.6790984
کوثر,1395,yes,32.6506204,51.6344053, کاکتوس- شریف واقفی,1394,no,32.6486747,51.6859003
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424, آرامش - صیاد,1395,no,32.6486798,51.712659
میرزا نصیر,1396,no,32.668195,51.7173079, کوثر- میرزاطاهر,1395,no,32.6506204,51.6344053
نیکان,1396,yes,32.6504663,51.6355982, آرامش - بوعلی 1,1396,no,32.6455156,51.6918424
آرامش-رسا,1397,no,32.6522126,51.6857297, آرامش - میرزا نصیر,1396,no,32.668195,51.7173079
نگین,1397,yes,32.6507084,51.6313395, نیکان میرزاطاهر,1396,no,32.6504663,51.6355982
آرامش - بوعلی 2,1398,no,32.645391,51.6917828, آرامش-رسا,1397,no,32.6522126,51.6857297
آرامش استاد همایی,1398,no,32.6563505,51.691988, نگین- میرزاطاهر,1393,no,32.6507084,51.6313395
آرامش صدر,1398,no,32.6469234,51.6826176, آرامش - بوعلی 2,1398,no,32.645391,51.6917828
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268, آرامش استاد همایی,1398,no,32.6563505,51.691988
آرامش- صدر,1399,no,32.6469205,51.6829887, آرامش- صدر,1399,no,32.6469234,51.6826176
آرامش - سنایی,1400,yes,32.6973339,51.6194128, آرامش - سپهسالار,1399,no,32.6259658,51.7080268
مغازه لباس فروشی,1400,no,32.6516965,51.7020497, آرامش - سنایی,1400,no,32.6973339,51.6194128
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244, مغازه لباس فروشی,1400,no,32.6516965,51.7020497
آرامش - بزرگمهر,1401,no,32.651381,51.6993575, آرامش - آتشگاه,1401,no,32.6547584,51.6319244
آرامش - سعدی,1401,yes,32.6970115,51.6196909, آرامش - بزرگمهر,1401,no,32.651381,51.6993575
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248, آرامش - سعدی,1401,no,32.6970115,51.6196909
آرامش 2,1401,yes,32.6505704,51.6337863, آرامش - صمدیه لباف,1401,no,32.6716585,51.6359248
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827, آرامش میرزا طاهر 2,1401,no,32.6505704,51.6337863
آرامش احمدآباد,1401,no,32.6626101,51.6969828, آرامش 5 رمضان,1401,no,32.6674407,51.6568827
تجاری آتشگاه,1401,yes,32.6515087,51.6033278, آرامش احمدآباد,1401,no,32.6626101,51.6969828
آرامش - شهیدان,1402,yes,32.6854968,51.6171964, باهنر- شادی,1390,no,32.6824682,51.6670556
آرامش خواجو,1402,no,32.6419187,51.6831873, تجاری آتشگاه,1401,no,32.6515087,51.6033278
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519, آرامش - شهیدان,1402,no,32.6854968,51.6171964
آرامش - انقلاب,1403,yes,32.719769,51.645921, آرامش خواجو,1402,no,32.6419187,51.6831873
آرامش - شریف,1404,no,32.6473319,51.6955996, آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519
آرامش - کهندژ,1404,yes,32.6649497,51.630077, آرامش - انقلاب اسلامی,1403,no,32.719769,51.645921
آرامش جی,1404,no,32.6610717,51.7110184, آرامش - شریف,1404,no,32.6473319,51.6955996
آرامش - بابک,1405,yes,32.674076,51.6368204, آرامش - کهندژ,1404,no,32.6649497,51.630077
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771, آرامش جی,1404,no,32.6610717,51.7110184
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643, آرامش زنیث,1405,no,32.674076,51.6368204
آرامش - وحدت,1405,yes,32.6537564,51.6197007, آرامش - شمس آبادی,1405,no,32.6560844,51.664771
آرامش پروین,1405,no,32.6618223,51.7098152, آرامش الیسیان,1405,no,32.6495532,51.6178643
خلیفه سلطانی,1405,yes,32.7035398,51.6223296, آرامش - وحدت,1405,no,32.6537564,51.6197007
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975, آرامش پروین,1405,no,32.6618223,51.7098152
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342, خلیفه سلطانی,1405,no,32.7035398,51.6223296
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436, آرامش آوالون,1406,no,32.6517244,51.6202975
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,` آرامش - میرزا طاهر 4,,no,32.6510328,51.631436
آرامش- میرزا طاهر 1,1396,no,32.6506236,51.6298238
آرامش لکسون,1406,no,32.60600917636113,51.67503993694544
ارغوانیه,1395,no,32.64448932441766,51.77707692162298
آرامش- ابوریحان,1405,no,32.700027968614734,51.71497771552572
`
/** /**
* تجزیه‌ی یک خط CSV — پشتیبانی از فیلدهای گیومه‌دار و گیومه‌ی دوگانه‌ی درونی. * تجزیه‌ی یک خط CSV — پشتیبانی از فیلدهای گیومه‌دار و گیومه‌ی دوگانه‌ی درونی.
@@ -140,14 +145,13 @@ export function parseProjectsCsv(raw: string): MapProject[] {
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV) export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
/** پیوند نشان روی نقشه برای سرویس نشان */ /**
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string { * پیوند نقشهٔ گوگل روی مختصات پروژه — قالب رسمی Maps URLs با پارامتر query،
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z` * بی‌نیاز از کلید API. یک سنجاق دقیق روی همان نقطه می‌نشاند و روی موبایل
} * اپلیکیشن گوگل‌مپ را باز می‌کند.
*/
/** پیوند نقشه گوگل */
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string { export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
return `https://www.google.com/maps?q=${project.lat},${project.lng}` return `https://www.google.com/maps/search/?api=1&query=${project.lat},${project.lng}`
} }
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه'] const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
@@ -227,7 +231,7 @@ export function csvProjectToCard(project: MapProject, detailHref: string): Proje
title: project.name, title: project.name,
status: project.isNew ? 'progress' : 'done', status: project.isNew ? 'progress' : 'done',
detailHref, detailHref,
addressUrl: buildNeshanUrl(project), addressUrl: buildGoogleMapsUrl(project),
} }
} }
@@ -242,6 +246,6 @@ export function apiProjectToCard(project: Project, detailHref: string): ProjectC
location: project.location || undefined, location: project.location || undefined,
image: project.image || undefined, image: project.image || undefined,
area: project.area || undefined, area: project.area || undefined,
addressUrl: matched ? buildNeshanUrl(matched) : undefined, addressUrl: matched ? buildGoogleMapsUrl(matched) : undefined,
} }
} }
-74
View File
@@ -1,74 +0,0 @@
export type BilingualText = { en: string; fa: string };
export type StaticProject = {
id: string;
slug: string;
name: BilingualText;
location: BilingualText;
area: BilingualText;
status: 'done' | 'progress';
};
export const projectsData: StaticProject[] = [
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
];
+5 -1
View File
@@ -49,7 +49,7 @@ export type ArticleStatus = 'draft' | 'published';
export type Article = { export type Article = {
id: number; id: number;
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string; slug: string; slug_en?: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
image: string; image: string;
read: string; read_en?: string; excerpt: string; excerpt_en?: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string;
/** پرسش‌وپاسخ‌های پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */ /** پرسش‌وپاسخ‌های پایان مقاله — سئوی FAQPage (faqs_en خالی → برگشت به فارسی) */
@@ -63,16 +63,20 @@ export type Article = {
status: ArticleStatus; status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */ /** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
seo_keyphrase: string; seo_keyphrase: string;
seo_keyphrase_en?: string;
/** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */ /** توضیحات متای سئو — در اولویت بر چکیده برای تگ description */
meta_description?: string; meta_description?: string;
meta_description_en?: string;
/** متن جایگزین تصویر شاخص (Alt) */ /** متن جایگزین تصویر شاخص (Alt) */
image_alt?: string; image_alt?: string;
/** عنوان تصویر شاخص (Title) */ /** عنوان تصویر شاخص (Title) */
image_title?: string; image_title?: string;
/** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */ /** نویسنده مقاله — در سربرگ مقاله زیر عنوان نمایش داده می‌شود */
author?: string; author?: string;
author_en?: string;
/** هشتگ‌ها/برچسب‌های مقاله */ /** هشتگ‌ها/برچسب‌های مقاله */
tags?: string[]; tags?: string[];
tags_en?: string[];
}; };
/** /**
+36 -2
View File
@@ -12,6 +12,12 @@ export const fa = {
switchLang: 'English', switchLang: 'English',
switchLangAria: 'تغییر زبان به انگلیسی', switchLangAria: 'تغییر زبان به انگلیسی',
understood: 'متوجه شدم', understood: 'متوجه شدم',
paginationAria: 'صفحه‌بندی پروژه‌ها',
prevProject: 'پروژهٔ قبلی',
nextProject: 'پروژهٔ بعدی',
projectNavAria: 'پیمایش بین پروژه‌ها',
previousPage: 'صفحهٔ قبل',
nextPage: 'صفحهٔ بعد',
// هدر و ناوبری // هدر و ناوبری
mainNav: 'ناوبری اصلی', mainNav: 'ناوبری اصلی',
@@ -53,8 +59,16 @@ export const fa = {
projectNotFound: 'این پروژه پیدا نشد', projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.', projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
backToProjects: 'بازگشت به پروژه‌ها', backToProjects: 'بازگشت به پروژه‌ها',
specStatus: 'وضعیت',
specLandArea: 'مساحت عرصه (زمین)',
specPilotFloors: 'طبقات روی همکف (پیلوت)',
specUnitsResidential: 'تعداد واحد مسکونی',
specUnitsCommercial: 'تعداد واحد تجاری',
specUnitsOffice: 'تعداد واحد اداری',
specLocation: 'موقعیت', specLocation: 'موقعیت',
specYear: 'سال', specProjectType: 'نوع پروژه',
specUsage: 'کاربری',
specYear: 'سال ساخت',
specArea: 'زیربنا', specArea: 'زیربنا',
specFloors: 'تعداد طبقات', specFloors: 'تعداد طبقات',
specUnits: 'واحدها', specUnits: 'واحدها',
@@ -76,6 +90,9 @@ export const fa = {
relatedArticlesAria: 'مقالات مرتبط', relatedArticlesAria: 'مقالات مرتبط',
relatedArticles: 'مقالات مرتبط', relatedArticles: 'مقالات مرتبط',
readArticlePrefix: 'خواندن مقاله', readArticlePrefix: 'خواندن مقاله',
author: 'نویسنده',
tableOfContents: 'فهرست مطالب',
aboutUs: 'درباره ما',
articleImagePrefix: 'تصویر مقاله', articleImagePrefix: 'تصویر مقاله',
featuredArticle: 'مقاله ویژه', featuredArticle: 'مقاله ویژه',
readMore: 'ادامه مطلب', readMore: 'ادامه مطلب',
@@ -130,6 +147,12 @@ export const en: Dict = {
switchLang: 'فا', switchLang: 'فا',
switchLangAria: 'Switch language to Persian (Farsi)', switchLangAria: 'Switch language to Persian (Farsi)',
understood: 'Got it', understood: 'Got it',
paginationAria: 'Projects pagination',
prevProject: 'Previous project',
nextProject: 'Next project',
projectNavAria: 'Browse projects',
previousPage: 'Previous page',
nextPage: 'Next page',
mainNav: 'Main navigation', mainNav: 'Main navigation',
mobileNav: 'Mobile navigation', mobileNav: 'Mobile navigation',
@@ -166,8 +189,16 @@ export const en: Dict = {
projectNotFound: 'Project not found', projectNotFound: 'Project not found',
projectNotFoundText: 'The address may have changed. Browse the full list of projects.', projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
backToProjects: 'Back to projects', backToProjects: 'Back to projects',
specStatus: 'Status',
specLandArea: 'Land area',
specPilotFloors: 'Floors above ground',
specUnitsResidential: 'Residential units',
specUnitsCommercial: 'Commercial units',
specUnitsOffice: 'Office units',
specLocation: 'Location', specLocation: 'Location',
specYear: 'Year', specProjectType: 'Project type',
specUsage: 'Land use',
specYear: 'Year built',
specArea: 'Built area', specArea: 'Built area',
specFloors: 'Floors', specFloors: 'Floors',
specUnits: 'Units', specUnits: 'Units',
@@ -188,6 +219,9 @@ export const en: Dict = {
relatedArticlesAria: 'Related articles', relatedArticlesAria: 'Related articles',
relatedArticles: 'Related articles', relatedArticles: 'Related articles',
readArticlePrefix: 'Read article', readArticlePrefix: 'Read article',
author: 'Author',
tableOfContents: 'Table of Contents',
aboutUs: 'About Us',
articleImagePrefix: 'Image of article', articleImagePrefix: 'Image of article',
featuredArticle: 'Featured article', featuredArticle: 'Featured article',
readMore: 'Read more', readMore: 'Read more',
+7 -17
View File
@@ -2,9 +2,10 @@
// فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان // فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان
// بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند. // بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند.
import type { Article, ArticleBodyDoc } from '../data/site' import type { Article } from '../data/site'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import type { Lang } from './lang' import type { Lang } from './lang'
import { docHasText } from '../utils/tiptapDoc'
/** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */ /** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa) const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
@@ -12,28 +13,13 @@ const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa
/** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */ /** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa) const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
/** سند TipTap انگلیسی فقط وقتی متن واقعی دارد جایگزین بدنه فارسی می‌شود */
function docHasText(doc: unknown): boolean {
if (!doc || typeof doc !== 'object') return false
const content = (doc as ArticleBodyDoc).content
if (!Array.isArray(content)) return false
let found = false
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object' || found) return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && typeof n.text === 'string' && n.text.trim()) found = true
n.content?.forEach(walk)
}
content.forEach(walk)
return found
}
export function localizeSite(data: SiteData, lang: Lang): SiteData { export function localizeSite(data: SiteData, lang: Lang): SiteData {
if (lang === 'fa') return data if (lang === 'fa') return data
return { return {
brand: { brand: {
...data.brand, ...data.brand,
name: s(data.brand.name, data.brand.name_en), name: s(data.brand.name, data.brand.name_en),
logo: s(data.brand.logo, data.brand.logo_en),
address: s(data.brand.address, data.brand.address_en), address: s(data.brand.address, data.brand.address_en),
}, },
nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })), nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })),
@@ -93,13 +79,17 @@ export function localizeSite(data: SiteData, lang: Lang): SiteData {
})), })),
articles: data.articles.map((a: Article) => ({ articles: data.articles.map((a: Article) => ({
...a, ...a,
slug: s(a.slug, a.slug_en),
title: s(a.title, a.title_en), title: s(a.title, a.title_en),
date: s(a.date, a.date_en), date: s(a.date, a.date_en),
category: s(a.category, a.category_en), category: s(a.category, a.category_en),
read: s(a.read, a.read_en), read: s(a.read, a.read_en),
author: s(a.author ?? '', a.author_en),
excerpt: s(a.excerpt, a.excerpt_en), excerpt: s(a.excerpt, a.excerpt_en),
meta_description: s(a.meta_description ?? '', a.meta_description_en),
body: docHasText(a.body_en) ? a.body_en! : a.body, body: docHasText(a.body_en) ? a.body_en! : a.body,
faqs: list(a.faqs ?? [], a.faqs_en), faqs: list(a.faqs ?? [], a.faqs_en),
tags: list(a.tags ?? [], a.tags_en),
})), })),
testimonials: data.testimonials.map((tm) => ({ testimonials: data.testimonials.map((tm) => ({
...tm, ...tm,
+115 -11
View File
@@ -7,6 +7,12 @@
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */ /* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
/* CSS پایهٔ مپ‌لیبر در شیت اصلی می‌ماند (نه در چانک تنبل نقشه).
اگر با خودِ کامپوننت نقشه بار شود، روی موبایل/اتصال کند ممکن است کد نقشه
پیش از رسیدن این فایل اجرا شود و .maplibregl-map (position/overflow) اعمال
نشود — نتیجه: ظرف با ارتفاع صفر و نقشهٔ نامرئی. */
@import 'maplibre-gl/dist/maplibre-gl.css';
/* فونت سایت سلف‌هاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد. /* فونت سایت سلف‌هاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
Pinar فونت اصلی است (۵ وزن) و Vazirmatn به‌عنوان جایگزین پشتیبان می‌ماند. Pinar فونت اصلی است (۵ وزن) و Vazirmatn به‌عنوان جایگزین پشتیبان می‌ماند.
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده می‌کند (Inter در صورت نصب بودن). */ نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده می‌کند (Inter در صورت نصب بودن). */
@@ -77,16 +83,33 @@ html[lang='en'] body {
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
/* حرف Q در فونت پینار به‌صورت O ترسیم شده (نقص خود فونت) — روی نسخه انگلیسی
تمام عناصرِ font-pinar هم به فونت لاتین می‌روند تا Q درست رندر شود. */
html[lang='en'] .font-pinar {
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
}
html[lang='en'] .article-body { html[lang='en'] .article-body {
line-height: 1.8; line-height: 1.8;
font-size: 17px; /* وبلاگ انگلیسی دو سه درجه بزرگ‌تر از فارسی */
} }
html[lang='en'] .article-body p { html[lang='en'] .article-body p {
margin-top: 1.25rem; margin-top: 1.25rem;
} }
html[lang='en'] .article-body h1,
html[lang='en'] .article-body h2, html[lang='en'] .article-body h2,
html[lang='en'] .article-body h3 { html[lang='en'] .article-body h3 {
line-height: 1.4; line-height: 1.4;
} }
/* هدینگ‌ها نسبت به متن ۱۷px انگلیسی بزرگ‌تر — همان نسبت نسخه فارسی */
html[lang='en'] .article-body h1 {
font-size: 22px;
}
html[lang='en'] .article-body h2 {
font-size: 19px;
}
html[lang='en'] .article-body h3 {
font-size: 16px;
}
::selection { ::selection {
background-color: #d5874c; background-color: #d5874c;
color: #1e1a16; color: #1e1a16;
@@ -101,7 +124,7 @@ html[lang='en'] .article-body h3 {
.article-body { .article-body {
line-height: 2.4; line-height: 2.4;
word-wrap: break-word; word-wrap: break-word;
font-size: 14px; /* متن اصلی ۱۴ تنظیم شد */ font-size: 15px; /* متن اصلی — یک درجه بزرگ‌تر از قبل */
} }
.article-body p { .article-body p {
margin-top: 1.75rem; margin-top: 1.75rem;
@@ -150,20 +173,16 @@ html[lang='en'] .article-body h3 {
font-size: 12px; font-size: 12px;
} }
/* تیترها و فهرست‌ها هم‌تراز نمی‌شوند — فقط متن پاراگراف تراز می‌شود /* فقط تیترها هم‌تراز نمی‌شوند (تیتر کوتاه با justify کشیده و بدقلق می‌شود) —
(تیتر کوتاه با justify کشیده و بدقلق می‌شود و فهرست نقطه‌گذاری‌اش به‌هم می‌ریزد). */ پاراگراف‌ها و فهرست‌ها کاملاً justify هستند و کپشن همیشه وسط‌چین است. */
[data-dir='rtl'] .article-body h1, [data-dir='rtl'] .article-body h1,
[data-dir='rtl'] .article-body h2, [data-dir='rtl'] .article-body h2,
[data-dir='rtl'] .article-body h3, [data-dir='rtl'] .article-body h3 {
[data-dir='rtl'] .article-body li,
[data-dir='rtl'] .article-body figcaption {
text-align: right; text-align: right;
} }
[data-dir='ltr'] .article-body h1, [data-dir='ltr'] .article-body h1,
[data-dir='ltr'] .article-body h2, [data-dir='ltr'] .article-body h2,
[data-dir='ltr'] .article-body h3, [data-dir='ltr'] .article-body h3 {
[data-dir='ltr'] .article-body li,
[data-dir='ltr'] .article-body figcaption {
text-align: left; text-align: left;
} }
[data-dir='ltr'] .article-body h1 { [data-dir='ltr'] .article-body h1 {
@@ -230,10 +249,18 @@ html[lang='en'] .article-body h3 {
margin-top: 0.6rem; margin-top: 0.6rem;
color: rgba(245, 243, 239, 0.7); color: rgba(245, 243, 239, 0.7);
} }
.article-body li p { .article-body li > p {
margin-top: 0; margin-top: 0;
display: inline; display: inline;
} }
/* نقل‌قول و هر بلاک تو در تو داخل آیتم فهرست — بلاکی بماند تا پاراگراف‌ها یکی نشوند */
.article-body li blockquote p {
display: block;
margin-top: 0;
}
.article-body li blockquote p + p {
margin-top: 0.75rem;
}
.article-body blockquote { .article-body blockquote {
margin-top: 2rem; margin-top: 2rem;
border-inline-start: 3px solid #d5874c; border-inline-start: 3px solid #d5874c;
@@ -246,6 +273,15 @@ html[lang='en'] .article-body h3 {
margin-top: 0; margin-top: 0;
color: inherit; color: inherit;
} }
/* پاراگراف‌های داخل نقل‌قول (متن/شخص/نقش) از هم جدا دیده شوند — بدون این،
چند پاراگراف پشت‌سرهم یک بلاک یکسره به نظر می‌رسند */
.article-body blockquote p + p {
margin-top: 0.75rem;
}
/* پاراگراف‌های خالی‌ای که موقع تایپ نقل‌قول جا می‌مانند — خط خالی اضافه رندر نکنند */
.article-body blockquote p:empty {
display: none;
}
.article-body a { .article-body a {
color: #d5874c; color: #d5874c;
text-decoration: underline; text-decoration: underline;
@@ -257,6 +293,15 @@ html[lang='en'] .article-body h3 {
border: 1px solid rgba(245, 243, 239, 0.1); border: 1px solid rgba(245, 243, 239, 0.1);
object-fit: cover; object-fit: cover;
} }
/* فهرست مطالب: اسکرول بدون نوار اسکرول — چرخ ماوس و لمس کار می‌کنند */
.no-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge legacy */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome / Safari / Edge */
}
/* ---------- تصویر کپشن‌دار: <figure><img><figcaption> ---------- */ /* ---------- تصویر کپشن‌دار: <figure><img><figcaption> ---------- */
.article-body figure { .article-body figure {
margin-top: 2rem; margin-top: 2rem;
@@ -283,6 +328,10 @@ html[lang='en'] .article-body h3 {
/* تراز از جهت بلوک پیروی می‌کند — در مقاله انگلیسی چپ و در فارسی راست */ /* تراز از جهت بلوک پیروی می‌کند — در مقاله انگلیسی چپ و در فارسی راست */
text-align: start; text-align: start;
} }
/* متن کپشن در ویرایشگر هم وسط‌چین است تا با خروجی سایت یکی باشد */
[data-wysiwyg] .article-figure__caption input[type='text'] {
text-align: center;
}
[data-wysiwyg] .article-figure-node.is-selected .article-figure { [data-wysiwyg] .article-figure-node.is-selected .article-figure {
outline: 1px dashed rgba(213, 135, 76, 0.8); outline: 1px dashed rgba(213, 135, 76, 0.8);
outline-offset: 3px; outline-offset: 3px;
@@ -462,4 +511,59 @@ html[lang='en'] .article-body h3 {
to { to {
opacity: 1; opacity: 1;
} }
} }
/* ---------- جدول در بدنه مقاله و ویرایشگر ---------- */
.article-body table,
.ProseMirror table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
margin-top: 1.5rem;
font-size: 0.92em;
overflow: hidden;
}
.ProseMirror td,
.ProseMirror th {
position: relative;
box-sizing: border-box;
}
.ProseMirror .column-resize-handle {
position: absolute;
top: 0;
bottom: -1px;
left: -2px;
width: 4px;
z-index: 20;
background-color: rgba(213, 135, 76, 0.6);
pointer-events: none;
}
.ProseMirror.resize-cursor {
cursor: col-resize;
}
.ProseMirror .selectedCell::after {
content: '';
position: absolute;
z-index: 2;
inset: 0;
background: rgba(213, 135, 76, 0.12);
pointer-events: none;
}
.article-body th,
.article-body td,
.ProseMirror th,
.ProseMirror td {
border: 1px solid rgba(245, 243, 239, 0.18);
padding: 0.5rem 0.75rem;
text-align: start;
vertical-align: top;
}
.article-body th,
.ProseMirror th {
background: rgba(213, 135, 76, 0.12);
font-weight: 700;
color: #f5f3ef;
}
.ProseMirror td > p,
.ProseMirror th > p {
margin: 0;
}
+3
View File
@@ -2,6 +2,9 @@ import "./index.css";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { initAnalytics } from "./analytics";
initAnalytics();
const rootEl = document.getElementById("root"); const rootEl = document.getElementById("root");
if (rootEl) { if (rootEl) {
+45 -7
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense } from 'react' import { Component, lazy, Suspense } from 'react'
import type { ReactNode } from 'react'
import type { ProjectsMapAllProps } from './ProjectsMapAll' import type { ProjectsMapAllProps } from './ProjectsMapAll'
import type { ProjectsMapSingleProps } from './ProjectsMapSingle' import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
@@ -17,18 +18,55 @@ function MapSkeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} /> return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
} }
/**
* مرز خطای نقشه — تبِ باز از پیش از استقرار، تکه‌های قدیمی را درخواست می‌کند
* که دیگر وجود ندارند (۴۰۴) و نقشه بی‌صدا می‌میرد. این مرز خطا را می‌گیرد و
* بارگیری دوبارهٔ کامل صفحه را پیشنهاد می‌دهد که تازه‌ترین نسخه را می‌آورد.
*/
class MapErrorBoundary extends Component<{ children: ReactNode; className?: string }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
render() {
if (this.state.failed) {
return (
<div
className={`flex flex-col items-center justify-center gap-3 rounded-3xl border border-ivory/10 bg-char/50 ${this.props.className ?? ''}`}
>
<p className="px-6 text-center text-sm font-light text-ivory/60">
بارگیری نقشه ناموفق بود — نسخهٔ صفحه در این تب قدیمی است
</p>
<button
type="button"
onClick={() => window.location.reload()}
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>
</div>
)
}
return this.props.children
}
}
export function LazyProjectsMapAll(props: ProjectsMapAllProps) { export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
return ( return (
<Suspense fallback={<MapSkeleton className={props.className} />}> <MapErrorBoundary className={props.className}>
<ProjectsMapAll {...props} /> <Suspense fallback={<MapSkeleton className={props.className} />}>
</Suspense> <ProjectsMapAll {...props} />
</Suspense>
</MapErrorBoundary>
) )
} }
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) { export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
return ( return (
<Suspense fallback={<MapSkeleton className={props.className} />}> <MapErrorBoundary className={props.className}>
<ProjectsMapSingle {...props} /> <Suspense fallback={<MapSkeleton className={props.className} />}>
</Suspense> <ProjectsMapSingle {...props} />
</Suspense>
</MapErrorBoundary>
) )
} }
+2 -3
View File
@@ -2,7 +2,6 @@ import * as maplibregl from 'maplibre-gl'
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react' import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
import { Loader2Icon } from 'lucide-react' import { Loader2Icon } from 'lucide-react'
import { MdClose, MdFullscreen } from 'react-icons/md' import { MdClose, MdFullscreen } from 'react-icons/md'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import type { MapStatus } from './types' import type { MapStatus } from './types'
@@ -67,7 +66,7 @@ export function MapViewport({
const shellClass = isFullscreen const shellClass = isFullscreen
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0' ? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}` : `relative h-full min-h-[280px] w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
return ( return (
<div <div
@@ -79,7 +78,7 @@ export function MapViewport({
است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار است تا هم بستر موقعیت‌یابی فرزندان مپ‌لیبر باشد و هم هشدار
«container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول «container has a non-static position» و محاسبه‌ی اشتباه آفست اسکرول
رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */} رخ ندهد — ترتیب بار شدن CSS هیچ‌وقت تعیین‌کننده نباشد */}
<div ref={containerRef} className="relative h-full w-full" /> <div ref={containerRef} className="relative h-full min-h-[280px] w-full" />
{isFullscreen ? ( {isFullscreen ? (
<button <button
+5 -2
View File
@@ -9,6 +9,8 @@ type ProjectImageSliderProps = {
images: string[] images: string[]
alt: string alt: string
className?: string className?: string
/** در نبود تصویر، به‌جای جای‌نگهدار کل قاب پنهان شود (صفحه‌ای که تصویر را جای دیگر نشان می‌دهد) */
hideWhenEmpty?: boolean
} }
/** /**
@@ -16,7 +18,7 @@ type ProjectImageSliderProps = {
* شمارندهٔ اسلاید و گوشه‌های گرد هم‌اندازهٔ کارت. همهٔ رویدادهای اشاره روی * شمارندهٔ اسلاید و گوشه‌های گرد هم‌اندازهٔ کارت. همهٔ رویدادهای اشاره روی
* اسلایدر متوقف می‌شوند تا کلیک/کشیدن آن، نقشه را جابه‌جا یا پاپ‌آپ را نبندد. * اسلایدر متوقف می‌شوند تا کلیک/کشیدن آن، نقشه را جابه‌جا یا پاپ‌آپ را نبندد.
*/ */
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) { export function ProjectImageSlider({ images, alt, className = '', hideWhenEmpty = false }: ProjectImageSliderProps) {
const { t } = useLang() const { t } = useLang()
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
@@ -26,6 +28,7 @@ export function ProjectImageSlider({ images, alt, className = '' }: ProjectImage
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}` const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
if (!images.length) { if (!images.length) {
if (hideWhenEmpty) return null
return ( return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}> <div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<ProjectImagePlaceholder /> <ProjectImagePlaceholder />
@@ -41,7 +44,7 @@ export function ProjectImageSlider({ images, alt, className = '' }: ProjectImage
return ( return (
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}> <div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" /> <img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" loading="lazy" decoding="async" />
{images.length > 1 && ( {images.length > 1 && (
<> <>
+4 -2
View File
@@ -15,6 +15,8 @@ type ProjectPopupCardProps = {
project: MapProject project: MapProject
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جای‌نگهدار می‌آید */ /** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جای‌نگهدار می‌آید */
images?: string[] images?: string[]
/** در نبود تصویر، قاب تصویر پنهان شود (نه جای‌نگهدار) — برای صفحهٔ خود پروژه */
hideImageWhenEmpty?: boolean
onClose: () => void onClose: () => void
/** دکمه‌های پایهٔ کارت — هر نما اکشن‌های خودش را می‌دهد (عمودی روی هم) */ /** دکمه‌های پایهٔ کارت — هر نما اکشن‌های خودش را می‌دهد (عمودی روی هم) */
children: ReactNode children: ReactNode
@@ -25,7 +27,7 @@ type ProjectPopupCardProps = {
* عنوان و توضیح، و در پایه دکمه‌های عمودی. راست‌به‌چپ، شیشه‌ای تیره و * عنوان و توضیح، و در پایه دکمه‌های عمودی. راست‌به‌چپ، شیشه‌ای تیره و
* هم‌اندازهٔ کارت‌های سایت. سال پروژه عمداً نمایش داده نمی‌شود. * هم‌اندازهٔ کارت‌های سایت. سال پروژه عمداً نمایش داده نمی‌شود.
*/ */
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) { export function ProjectPopupCard({ project, images = [], hideImageWhenEmpty = false, onClose, children }: ProjectPopupCardProps) {
const { t } = useLang() const { t } = useLang()
const description = project.description.trim() || t('mapProjectDescription') const description = project.description.trim() || t('mapProjectDescription')
@@ -36,7 +38,7 @@ export function ProjectPopupCard({ project, images = [], onClose, children }: Pr
> >
{/* اسلایدر تصویر: لبه‌تا‌لبه، بدون حاشیه، با فلش‌های چپ/راست */} {/* اسلایدر تصویر: لبه‌تا‌لبه، بدون حاشیه، با فلش‌های چپ/راست */}
<div className="relative"> <div className="relative">
<ProjectImageSlider images={images} alt={project.name} /> <ProjectImageSlider images={images} alt={project.name} hideWhenEmpty={hideImageWhenEmpty} />
<button <button
type="button" type="button"
+19 -7
View File
@@ -1,12 +1,12 @@
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { MdArrowBack, MdOpenInNew } from 'react-icons/md' import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
import { import {
ISFAHAN_CENTER, ISFAHAN_CENTER,
MAP_PROJECTS, MAP_PROJECTS,
boundsForProjects, boundsForProjects,
buildNeshanUrl, buildGoogleMapsUrl,
type MapProject, type MapProject,
} from '../data/mapProjects' } from '../data/mapProjects'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
@@ -32,8 +32,8 @@ export type ProjectsMapAllProps = {
/** /**
* نمای «همه‌ی پروژه‌ها» — همه‌ی مختصات شیت روی یک نقشه‌ی تمام‌عرض با * نمای «همه‌ی پروژه‌ها» — همه‌ی مختصات شیت روی یک نقشه‌ی تمام‌عرض با
* سنجاق‌های مسی، پاپ‌آپ کارتی و دو اکشن عمودی: صفحه‌ی پروژه و آدرس نشان. * سنجاق‌های مسی، پاپ‌آپ کارتی و دو اکشن عمودی: صفحه‌ی پروژه و آدرس نشان.
* پاپ‌آپ فقط با کلیک کاربر باز می‌شود و جابه‌جایی نقشه به محدودهٔ پروژه‌ها * پاپ‌آپ فقط با کلیک کاربر باز می‌شود؛ نمای نخست همه‌ی سنجاق‌ها را در قاب
* بسته است. * دارد و زوم‌اوت تا دید کشوری آزاد است.
*/ */
export function ProjectsMapAll({ export function ProjectsMapAll({
className = '', className = '',
@@ -43,9 +43,21 @@ export function ProjectsMapAll({
}: ProjectsMapAllProps) { }: ProjectsMapAllProps) {
const { t, href } = useLang() const { t, href } = useLang()
const bounds = useMemo(() => boundsForProjects(projects), [projects]) const bounds = useMemo(() => boundsForProjects(projects), [projects])
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds }) // بدون maxBounds — زوم‌اوت آزاد تا کف زوم ۵ (دید کشوری)؛ نمای نخست با fitBounds
// همهٔ سنجاق‌ها را در یک قاب می‌نشیند.
const { containerRef, map, status, retry } = useMapLibre({
center: ISFAHAN_CENTER,
zoom: 12,
minZoom: 5,
})
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects) const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
// نمای نخست: قاب همهٔ پروژه‌ها — بعد از آماده‌شدن نقشه یک‌بار اجرا می‌شود
useEffect(() => {
if (!map || !bounds) return
map.fitBounds(bounds, { padding: 48, maxZoom: 14, duration: 0 })
}, [map, bounds])
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects') const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : [] const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
@@ -61,14 +73,14 @@ export function ProjectsMapAll({
{popupHost && {popupHost &&
activeProject && activeProject &&
createPortal( createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}> <ProjectPopupCard project={activeProject} images={images} hideImageWhenEmpty onClose={closePopup}>
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}> <Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
{t('viewProject')} {t('viewProject')}
<MdArrowBack className="h-4 w-4" aria-hidden="true" /> <MdArrowBack className="h-4 w-4" aria-hidden="true" />
</Link> </Link>
{/* منطق دکمهٔ آدرس دست‌نخورده — فقط در ستون عمودی نشسته است */} {/* منطق دکمهٔ آدرس دست‌نخورده — فقط در ستون عمودی نشسته است */}
<a <a
href={buildNeshanUrl(activeProject)} href={buildGoogleMapsUrl(activeProject)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={POPUP_SECONDARY_CLASS} className={POPUP_SECONDARY_CLASS}
+5 -5
View File
@@ -1,7 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { MdOpenInNew } from 'react-icons/md' import { MdOpenInNew } from 'react-icons/md'
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects' import { buildGoogleMapsUrl, type MapProject } from '../data/mapProjects'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { MapViewport } from './MapViewport' import { MapViewport } from './MapViewport'
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard' import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
@@ -25,11 +25,11 @@ export type ProjectsMapSingleProps = {
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) { export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
const { t } = useLang() const { t } = useLang()
const projects = useMemo(() => [location], [location]) const projects = useMemo(() => [location], [location])
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects]) // بدون maxBounds — زوم‌اوت آزاد تا دید کشوری (هم‌رفتار با نقشهٔ صفحهٔ پروژه‌ها)
const { containerRef, map, status, retry } = useMapLibre({ const { containerRef, map, status, retry } = useMapLibre({
center: [location.lng, location.lat], center: [location.lng, location.lat],
zoom: 15, zoom: 15,
maxBounds: bounds, minZoom: 5,
}) })
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects) const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
@@ -45,10 +45,10 @@ export function ProjectsMapSingle({ location, images = [], className = '' }: Pro
{popupHost && {popupHost &&
activeProject && activeProject &&
createPortal( createPortal(
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}> <ProjectPopupCard project={activeProject} images={images} hideImageWhenEmpty onClose={closePopup}>
{/* منطق دکمهٔ آدرس دست‌نخورده است — تنها اکشن این کارت */} {/* منطق دکمهٔ آدرس دست‌نخورده است — تنها اکشن این کارت */}
<a <a
href={buildNeshanUrl(activeProject)} href={buildGoogleMapsUrl(activeProject)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={POPUP_PRIMARY_CLASS} className={POPUP_PRIMARY_CLASS}
+46 -3
View File
@@ -12,6 +12,8 @@ type UseMapLibreOptions = {
scrollZoom?: boolean scrollZoom?: boolean
/** محدودهٔ مجاز جابه‌جایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژه‌ها */ /** محدودهٔ مجاز جابه‌جایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژه‌ها */
maxBounds?: maplibregl.LngLatBoundsLike maxBounds?: maplibregl.LngLatBoundsLike
/** کف بزرگ‌نمایی — زوم‌اوت بی‌معنا روی نقشهٔ خالی را می‌بندد */
minZoom?: number
} }
/** سقف انتظار سبک نقشه — سرور کاشی بی‌پاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بی‌پایان */ /** سقف انتظار سبک نقشه — سرور کاشی بی‌پاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بی‌پایان */
@@ -36,7 +38,7 @@ const ABSOLUTE_MAX_ZOOM = 22
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */ /** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
const FALLBACK_MAX_ZOOM = 17 const FALLBACK_MAX_ZOOM = 17
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) { export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds, minZoom }: UseMapLibreOptions) {
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<maplibregl.Map | null>(null) const [map, setMap] = useState<maplibregl.Map | null>(null)
const [status, setStatus] = useState<MapStatus>('loading') const [status, setStatus] = useState<MapStatus>('loading')
@@ -48,9 +50,17 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
let disposed = false let disposed = false
let instance: maplibregl.Map | null = null let instance: maplibregl.Map | null = null
const cleanups: Array<() => void> = []
let readyFallback: number | undefined let readyFallback: number | undefined
let dprQuery: MediaQueryList | null = null let dprQuery: MediaQueryList | null = null
let handleDprChange: (() => void) | null = null let handleDprChange: (() => void) | null = null
// آیا می‌توان کانتکست آنتی‌آلیاسه گرفت؟ (تست سبک، یک‌بار در هر ساخت نقشه)
const probeCanvas = document.createElement('canvas')
const canAntialias = Boolean(
probeCanvas.getContext('webgl2', { antialias: true }) ||
probeCanvas.getContext('webgl', { antialias: true }),
)
const controller = new AbortController() const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS) const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
@@ -59,6 +69,16 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
// وب‌ورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛ // وب‌ورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
// وگرنه نخستین درخواست‌های کاشی در وب‌ورکر بی‌پاسخ می‌مانند // وگرنه نخستین درخواست‌های کاشی در وب‌ورکر بی‌پاسخ می‌مانند
// پیش‌سنجی WebGL: برخی مرورگرهای موبایل (یا حالت ذخیرهٔ باتری/داده) کانتکست
// WebGL نمی‌دهند. بدون این بررسی، سازندهٔ مپ‌لیبر خطا می‌دهد و صفحه در حالت
// «در حال بارگذاری» گیر می‌کند — کاربر فقط یک قاب خالی می‌بیند.
const probe = document.createElement('canvas')
const gl = probe.getContext('webgl2') || probe.getContext('webgl') || probe.getContext('experimental-webgl')
if (!gl) {
setStatus('error')
return
}
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()]) Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
.then(([style]) => { .then(([style]) => {
if (disposed) return if (disposed) return
@@ -68,11 +88,14 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
center, center,
zoom, zoom,
maxBounds, maxBounds,
minZoom,
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق می‌کند // سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق می‌کند
maxZoom: FALLBACK_MAX_ZOOM, maxZoom: FALLBACK_MAX_ZOOM,
renderWorldCopies: false, renderWorldCopies: false,
// لبه‌های نرم‌تر خطوط و چندضلعی‌ها — حس لوکس رندر برداری // لبه‌های نرم‌تر خطوط و چندضلعی‌ها — حس لوکس رندر برداری
canvasContextAttributes: { antialias: true }, // antialias فقط جایی که مطمئنیم پشتیبانی می‌شود — درخواست بی‌قید آن
// روی بعضی GPUهای موبایل کانتکست را رد می‌کند و نقشه ساخته نمی‌شود.
canvasContextAttributes: { antialias: canAntialias },
// کیفیت حداکثری در بزرگ‌نمایی‌های عمیق: هیچ سطحی با مقیاس‌کردن سطح // کیفیت حداکثری در بزرگ‌نمایی‌های عمیق: هیچ سطحی با مقیاس‌کردن سطح
// بالاتر رندر نمی‌شود و همه‌ی سطح‌ها تا maxZoom از تقسیم هندسه // بالاتر رندر نمی‌شود و همه‌ی سطح‌ها تا maxZoom از تقسیم هندسه
// می‌آیند (پیش‌فرض ۴ سطح آخر را overscale می‌کند). مبادله: کار // می‌آیند (پیش‌فرض ۴ سطح آخر را overscale می‌کند). مبادله: کار
@@ -106,9 +129,26 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
instance.on('style.load', () => { instance.on('style.load', () => {
if (disposed) return if (disposed) return
setStatus('ready') setStatus('ready')
// یک هم‌گام‌سازی پس از چیدمان نهایی (فونت/تصویر ممکن است ارتفاع را عوض کند)
window.setTimeout(() => instance?.resize(), 150) window.setTimeout(() => instance?.resize(), 150)
}) })
/**
* هم‌گام‌سازی مداوم بوم با اندازهٔ واقعی ظرف.
*
* تایمرهای ثابت کافی نیستند: در حالت موبایل DevTools و روی گوشی واقعی،
* ظرف پس از ساخته‌شدن نقشه تغییر اندازه می‌دهد (اعمال viewport شبیه‌سازی‌شده،
* جمع/باز شدن نوار آدرس، چرخش صفحه). در آن حالت بوم با اندازهٔ قدیمی
* می‌ماند و نقشه ناقص/تاریخ‌شده دیده می‌شود. ResizeObserver دقیقاً همان
* لحظه خبر می‌دهد و resize روی همان فریم اعمال می‌شود.
*/
const sizeObserver = new ResizeObserver(() => {
if (disposed || !instance) return
instance.resize()
})
sizeObserver.observe(container)
cleanups.push(() => sizeObserver.disconnect())
/** /**
* کنترل بزرگ‌نمایی همراه خود نقشه ساخته می‌شود و با map.remove() پایین * کنترل بزرگ‌نمایی همراه خود نقشه ساخته می‌شود و با map.remove() پایین
* می‌آید — map.remove به‌تنهایی onRemove همه‌ی کنترل‌ها را صدا می‌زند، * می‌آید — map.remove به‌تنهایی onRemove همه‌ی کنترل‌ها را صدا می‌زند،
@@ -170,7 +210,9 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
setMap(instance) setMap(instance)
}) })
.catch(() => { .catch((error) => {
// خطای ساخت/بارگذاری نقشه — وضعیت خطا با پیام قابل‌تلاش‌دوباره، نه قاب خالی
console.warn('[map] راه‌اندازی نقشه ناموفق بود:', error)
if (!disposed) setStatus('error') if (!disposed) setStatus('error')
}) })
@@ -179,6 +221,7 @@ export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseM
window.clearTimeout(timeoutId) window.clearTimeout(timeoutId)
if (readyFallback !== undefined) window.clearTimeout(readyFallback) if (readyFallback !== undefined) window.clearTimeout(readyFallback)
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange) if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
cleanups.forEach((fn) => fn())
controller.abort() controller.abort()
instance?.remove() instance?.remove()
} }
+5 -5
View File
@@ -12,12 +12,12 @@ export function About() {
<> <>
<PageHero pageKey="about" /> <PageHero pageKey="about" />
<Story showLink={false} showLabel={false} showQuote={true} alignTop={true} /> <Story showLink={false} showLabel={false} showQuote={true} alignTop={true} tone="black" />
<Stats /> <Stats tone="gray" />
<Timeline /> <Timeline tone="black" />
<WhyUs /> <WhyUs tone="gray" />
<Team /> <Team tone="black" />
<ContactSection /> <ContactSection />
</> </>
) )
+59 -43
View File
@@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer'; import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { PremiumButton } from '../components/PremiumButton'; import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { ArticleMeta } from '../components/ArticleMeta'; import { ArticleMeta } from '../components/ArticleMeta';
import { ArticleFaq } from '../components/ArticleFaq'; import { ArticleFaq } from '../components/ArticleFaq';
@@ -14,9 +14,10 @@ import { useLang } from '../i18n/lang';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import { docToText } from '../utils/tiptapDoc'; import { docToText, docHasText } from '../utils/tiptapDoc';
import { detectDirection } from '../utils/direction'; import { detectDirection } from '../utils/direction';
import { wrapScriptRuns } from '../utils/bidiText'; import { wrapScriptRuns } from '../utils/bidiText';
import { toPublishedISO } from '../utils/date';
import type { Article } from '../data/site'; import type { Article } from '../data/site';
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ /** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
@@ -45,47 +46,60 @@ export function ArticleDetail() {
const { t, href, lang } = useLang(); const { t, href, lang } = useLang();
const article = articles.find((a) => a.slug === slug); const article = articles.find((a) => a.slug === slug);
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — هم‌ارز نسخهٔ سمت سرور (seo_pages.py)
useEffect(() => { useEffect(() => {
if (!article) return; if (!article) return;
let jsonLd: Record<string, unknown> | Record<string, unknown>[] = { const pageUrl = new URL(href(`/blog/${article.slug}`), window.location.origin).href;
const absolute = (path: string) => new URL(path, window.location.origin).href;
const authorName = (article.author ?? '').trim();
const description = article.meta_description || article.excerpt;
const articleSchema: Record<string, unknown> = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Article', '@type': 'Article',
headline: article.title, headline: article.title,
description: article.meta_description || article.excerpt, description,
image: article.image ? new URL(article.image, window.location.origin).href : undefined, image: article.image ? absolute(article.image) : undefined,
inLanguage: lang, inLanguage: lang,
author: { '@type': 'Organization', name: article.author?.trim() || brand.name }, // نویسندهٔ انسانی ثبت‌شده → Person (سیگنال E-E-A-T)؛ وگرنه برند
author: authorName
? { '@type': 'Person', name: authorName }
: { '@type': 'Organization', name: brand.name },
publisher: { '@type': 'Organization', name: brand.name }, publisher: { '@type': 'Organization', name: brand.name },
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href, mainEntityOfPage: pageUrl,
}; };
// پرسش‌وپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایه‌ای از دو اسکیما) const published = toPublishedISO(article.date);
const articleFaqs = article.faqs ?? []; if (published) articleSchema.datePublished = published;
if (articleFaqs.length) { const breadcrumbSchema = {
jsonLd = [ '@context': 'https://schema.org',
jsonLd, '@type': 'BreadcrumbList',
{ itemListElement: [
'@context': 'https://schema.org', { '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
'@type': 'FAQPage', { '@type': 'ListItem', position: 2, name: t('blog'), item: absolute(href('/blog')) },
mainEntity: articleFaqs { '@type': 'ListItem', position: 3, name: article.title, item: pageUrl },
.filter((f) => f.question.trim()) ],
.map((f) => ({ };
'@type': 'Question', // پرسش‌وپاسخ‌های پایان مقاله → FAQPage (برای موتورهای هوش مصنوعی)
name: f.question, const faqEntities = (article.faqs ?? [])
acceptedAnswer: { '@type': 'Answer', text: f.answer }, .filter((f) => (f.question ?? '').trim() && (f.answer ?? '').trim())
})), .map((f) => ({
}, '@type': 'Question',
]; name: f.question.trim(),
} acceptedAnswer: { '@type': 'Answer', text: f.answer.trim() },
}));
const faqSchema = faqEntities.length
? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqEntities }
: null;
applySeo({ applySeo({
title: `${article.title} | ${brand.name}`, title: `${article.title} | ${brand.name}`,
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده // توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description: article.meta_description || article.excerpt, description,
path: href(`/blog/${article.slug}`), path: href(`/blog/${article.slug}`),
image: article.image, image: article.image,
type: 'article', type: 'article',
lang, lang,
jsonLd, // alternate انگلیسی فقط با وجود ترجمهٔ واقعی
hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)),
jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])],
}); });
}, [article?.id, lang, href]); }, [article?.id, lang, href]);
@@ -94,9 +108,10 @@ export function ArticleDetail() {
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1> <h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p> <p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p>
<PremiumButton to={href('/blog')} className="mt-8"> <ButtonLink to={href('/blog')} variant="copper" className="mt-8">
{t('backToBlog')} {t('backToBlog')}
</PremiumButton> <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</section>); </section>);
} }
@@ -111,7 +126,7 @@ export function ArticleDetail() {
*/ */
const hasToc = toc.length > 0; const hasToc = toc.length > 0;
// در چیدمان راست‌به‌چپ ستون شماره ۱ سمت راست است؛ پس ستون‌ها صریح شماره می‌خورند // در چیدمان راست‌به‌چپ ستون شماره ۱ سمت راست است؛ پس ستون‌ها صریح شماره می‌خورند
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : ''; const gridColumns = hasToc ? 'lg:grid-cols-[21rem_minmax(0,1fr)]' : '';
const bodyColumns = 'lg:col-start-2'; const bodyColumns = 'lg:col-start-2';
const tocColumns = 'lg:col-start-1'; const tocColumns = 'lg:col-start-1';
@@ -123,6 +138,8 @@ export function ArticleDetail() {
alt={article.image_alt || article.title} alt={article.image_alt || article.title}
title={article.image_title || undefined} title={article.image_title || undefined}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
fetchPriority="high"
decoding="async"
/> />
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" /> <div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
<div <div
@@ -149,7 +166,7 @@ export function ArticleDetail() {
</div> </div>
</section> </section>
<article className="bg-ink py-20 lg:py-28"> <article className="bg-char py-20 lg:py-28">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div <div
dir={dir} dir={dir}
@@ -175,6 +192,13 @@ export function ArticleDetail() {
</Reveal> </Reveal>
) )
)} )}
{/* پرسش‌وپاسخ‌های پایان مقاله — دوزبانه (ستون فعال، با برگشت به فارسی) */}
{(article.faqs ?? []).length > 0 && (
<Reveal delay={0.05}>
<ArticleFaq faqs={article.faqs ?? []} />
</Reveal>
)}
</div> </div>
{/* فهرست مطالب — در دسکتاپ کنار متن می‌چسبد و همراه مقاله پایین می‌آید */} {/* فهرست مطالب — در دسکتاپ کنار متن می‌چسبد و همراه مقاله پایین می‌آید */}
@@ -183,22 +207,14 @@ export function ArticleDetail() {
dir={dir} dir={dir}
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`} className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
> >
<TableOfContents items={toc} className="lg:sticky lg:top-28" /> <TableOfContents items={toc} title={t('tableOfContents')} className="lg:sticky lg:top-28" />
</aside> </aside>
)} )}
</div> </div>
</div> </div>
</article> </article>
{/* پرسش‌وپاسخ‌های پایان مقاله — همان ظاهر آکاردئونی FAQ سایت */} <section className="bg-ink py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
<div className="bg-ink pb-4">
<div className="mx-auto max-w-5xl px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('fallbackFaqTitle')}</h2>
<ArticleFaq faqs={article.faqs ?? []} />
</div>
</div>
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2> <h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
+7 -7
View File
@@ -9,7 +9,7 @@ import { useSite } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable' import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext' import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date' import { formatJalali, formatEnDate } from '../utils/date'
import { toPersianDigits } from '../utils/digits' import { toPersianDigits } from '../utils/digits'
import { PlusIcon } from 'lucide-react' import { PlusIcon } from 'lucide-react'
@@ -26,7 +26,7 @@ function DraftBadge({ className = '' }: { className?: string }) {
export function Blog() { export function Blog() {
const { articles } = useSite() const { articles } = useSite()
const { t, href } = useLang() const { t, href, lang } = useLang()
const { isEditMode } = useAdmin() const { isEditMode } = useAdmin()
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود // دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود
@@ -46,7 +46,7 @@ export function Blog() {
return ( return (
<> <>
<PageHero pageKey="blog" /> <PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}> <section className="bg-char py-24 lg:py-32" aria-label={t('articlesAria')}>
<div className="mx-auto max-w-site px-6 text-center lg:px-10"> <div className="mx-auto max-w-site px-6 text-center lg:px-10">
<h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2> <h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2>
<p className="mt-4 text-sm font-light text-ivory/60"> <p className="mt-4 text-sm font-light text-ivory/60">
@@ -66,7 +66,7 @@ export function Blog() {
<> <>
<PageHero pageKey="blog" /> <PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}> <section className="bg-char py-24 lg:py-32" aria-label={t('articlesAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mb-10 flex justify-start">{writeButton}</div> <div className="mb-10 flex justify-start">{writeButton}</div>
<Reveal> <Reveal>
@@ -80,7 +80,7 @@ export function Blog() {
<img <img
src={featured.image} src={featured.image}
alt={`${t('articleImagePrefix')} ${featured.title}`} alt={`${t('articleImagePrefix')} ${featured.title}`}
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" fetchPriority="high" decoding="async"
/> />
</div> </div>
<div className="flex flex-col justify-center p-8 lg:p-12"> <div className="flex flex-col justify-center p-8 lg:p-12">
@@ -96,10 +96,10 @@ export function Blog() {
<p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p> <p className="mt-6 text-sm font-light leading-10 text-ivory/65">{featured.excerpt}</p>
<div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50"> <div className="mt-10 flex items-center justify-between border-t border-ivory/10 pt-8 text-xs font-light text-ivory/50">
<span className="flex items-center gap-4"> <span className="flex items-center gap-4">
<span>{formatJalali(featured.date)}</span> <span>{lang === 'en' ? formatEnDate(featured.date) : formatJalali(featured.date)}</span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<ClockIcon className="h-3.5 w-3.5" /> <ClockIcon className="h-3.5 w-3.5" />
{toPersianDigits(featured.read)} {lang === 'en' ? featured.read : toPersianDigits(featured.read)}
</span> </span>
</span> </span>
<span className="flex items-center gap-2 text-copper"> <span className="flex items-center gap-2 text-copper">
+31 -6
View File
@@ -1,18 +1,43 @@
import React from 'react' import React from 'react'
import { PageHero } from '../components/PageHero' import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { usePageHero } from '../api'
import { useLang } from '../i18n/lang' import { useLang } from '../i18n/lang'
/**
* صفحهٔ تماس — یک تصویر پیوسته برای کل صفحه: عنوان هیرو و فرم/اطلاعات
* هر دو روی همان پس‌زمینه می‌نشینند تا تصویر دو بار تکرار نشود.
*/
export function ContactPage() { export function ContactPage() {
const { t } = useLang() const { t } = useLang()
const hero = usePageHero('contact')
return ( return (
<> <div className="relative" aria-label={t('officesAria')}>
<PageHero pageKey="contact" /> {hero.image && (
<img
src={hero.image}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
fetchPriority="high"
decoding="async"
/>
)}
{/* فقط گرادیان خوانایی — بدون پردهٔ خاکستری روی تصویر */}
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(to top, #27221c 2%, rgba(39,34,28,0.55) 45%, rgba(39,34,28,0.35) 100%)',
}}
aria-hidden="true"
/>
<section className="bg-ink py-2" aria-label={t('officesAria')} /> <div className="relative">
<PageHero pageKey="contact" hideImage />
<ContactSection /> <ContactSection bare />
</> </div>
</div>
) )
} }
+1 -1
View File
@@ -8,7 +8,7 @@ export function FaqPage() {
<> <>
<PageHero pageKey="faq" /> <PageHero pageKey="faq" />
<Faq hideHeading={true} /> <Faq hideHeading={true} tone="black" />
<ContactSection /> <ContactSection />
</> </>
+1 -5
View File
@@ -5,10 +5,8 @@ import { Stats } from '../components/home/Stats';
import { Services } from '../components/home/Services'; import { Services } from '../components/home/Services';
import { BeforeAfter } from '../components/home/BeforeAfter'; import { BeforeAfter } from '../components/home/BeforeAfter';
import { FeaturedProjects } from '../components/home/FeaturedProjects'; import { FeaturedProjects } from '../components/home/FeaturedProjects';
import { Gallery } from '../components/home/Gallery';
import { Timeline } from '../components/home/Timeline'; import { Timeline } from '../components/home/Timeline';
import { WhyUs } from '../components/home/WhyUs'; import { WhyUs } from '../components/home/WhyUs';
import { Testimonials } from '../components/home/Testimonials';
import { Team } from '../components/home/Team'; import { Team } from '../components/home/Team';
import { Articles } from '../components/home/Articles'; import { Articles } from '../components/home/Articles';
import { Faq } from '../components/home/Faq'; import { Faq } from '../components/home/Faq';
@@ -20,14 +18,12 @@ export function Home() {
<> <>
<Hero /> <Hero />
<Stats /> <Stats />
<Story /> <Story showQuote={true} />
<Services /> <Services />
<BeforeAfter /> <BeforeAfter />
<FeaturedProjects /> <FeaturedProjects />
<Gallery />
<Timeline /> <Timeline />
<WhyUs /> <WhyUs />
<Testimonials />
<Team /> <Team />
<Articles /> <Articles />
<Faq /> <Faq />
+8 -12
View File
@@ -5,42 +5,38 @@ import { SectionHeading } from '../components/SectionHeading'
import { Stats } from '../components/home/Stats' import { Stats } from '../components/home/Stats'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { useSectionHeading, useSite } from '../api' import { useSectionHeading, useSite } from '../api'
import { useLang, formatNumber } from '../i18n/lang'
import { AddItemButton, EditableItem } from '../admin/Editable' import { AddItemButton, EditableItem } from '../admin/Editable'
export function Investment() { export function Investment() {
const { investment_models } = useSite() const { investment_models } = useSite()
const { lang } = useLang()
const heading = useSectionHeading('investment_models') const heading = useSectionHeading('investment_models')
return ( return (
<> <>
<PageHero pageKey="investment" /> <PageHero pageKey="investment" />
<section className="bg-ink py-24 lg:py-32" aria-labelledby="models-title"> <section className="bg-char py-24 lg:py-32" aria-labelledby="models-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading title={heading.title} lead={heading.lead} /> <SectionHeading title={heading.title} lead={heading.lead} align="center" />
<div className="mt-14 grid gap-6 lg:grid-cols-3"> <div className="mt-14 grid gap-6 lg:grid-cols-3">
{investment_models.map((model, i) => ( {investment_models.map((model, i) => (
<Reveal key={model.id} delay={0.06 * i} className="h-full"> <Reveal key={model.id} delay={0.06 * i} className="h-full">
<EditableItem resource="investment_models" item={model} className="h-full"> <EditableItem resource="investment_models" item={model} className="h-full">
<article className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-ivory/10 bg-char/40 p-10 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:bg-char/70 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] transform-gpu"> <article className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-ivory/10 bg-char/40 p-10 text-center backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:bg-char/70 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] transform-gpu">
{/* لایه نوری ظریف که در زمان هاور روشن می‌شود */} {/* لایه نوری ظریف که در زمان هاور روشن می‌شود */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<p className="relative z-10 text-sm font-bold text-copper/80">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</p>
<h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper"> <h3 className="relative z-10 text-xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{model.title} {model.title}
</h3> </h3>
<p className="relative z-10 mt-5 text-sm font-light leading-9 text-ivory/65">{model.desc}</p> <p className="relative z-10 mt-4 text-base font-light leading-9 text-ivory/60">{model.desc}</p>
<div className="relative z-10 mt-auto pt-10"> <div className="relative z-10 mt-auto pt-10">
<ul className="space-y-4 border-t border-ivory/10 pt-8 transition-colors duration-500 group-hover:border-copper/20"> <ul className="space-y-4 border-t border-ivory/10 pt-8 transition-colors duration-500 group-hover:border-copper/20">
{model.points.map((point) => ( {model.points.map((point) => (
<li key={point} className="flex items-start gap-3 text-sm font-light leading-8 text-ivory/70 transition-colors duration-300 group-hover:text-ivory"> <li key={point} className="flex items-center justify-center gap-3 text-sm font-light leading-8 text-ivory/70 transition-colors duration-300 group-hover:text-ivory">
<span className="mt-3.5 h-1.5 w-1.5 shrink-0 rounded-full bg-copper/60 transition-all duration-300 ease-lux group-hover:scale-125 group-hover:bg-copper group-hover:shadow-[0_0_10px_rgba(197,143,92,0.8)]" aria-hidden="true" /> <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-copper/60 transition-all duration-300 ease-lux group-hover:scale-125 group-hover:bg-copper group-hover:shadow-[0_0_10px_rgba(197,143,92,0.8)]" aria-hidden="true" />
{point} {point}
</li> </li>
))} ))}
@@ -55,7 +51,7 @@ export function Investment() {
</div> </div>
</section> </section>
<Stats /> <Stats tone="gray" />
<ContactSection /> <ContactSection />
</> </>
) )
+243 -146
View File
@@ -1,98 +1,122 @@
import { useEffect, useMemo, useState } from 'react' import React, { useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { ChevronLeftIcon } from 'lucide-react' import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'
import { Reveal } from '../components/Reveal' import { Reveal } from '../components/Reveal'
import { PremiumButton } from '../components/PremiumButton' import { ButtonLink } from '../components/Button'
import { ProjectCard } from '../components/ProjectCard' import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox' import { Lightbox } from '../components/Lightbox'
import { LazyProjectsMapSingle } from '../map/LazyMaps'; import { useEffect } from 'react';
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects'
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
import { useAdmin } from '../admin/AdminContext';
import { LazyProjectsMapSingle } from '../map/LazyMaps';
import { createPlaceholderProject, findMapProjectByName } from '../data/mapProjects';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { projectsData } from '../data/projectsData';
export function ProjectDetail() { export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite() const { projects, brand } = useSite()
const { t, href, lang } = useLang() const { t, href, lang } = useLang()
const { canManageSite } = useAdmin()
const currentProject = projectsData.find((p) => p.slug === slug) const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null) const [lightbox, setLightbox] = useState<number | null>(null)
if (!currentProject) { // مختصات پروژه از فهرست سنجاق‌های شیت؛ نبودش → محل جایگزین تا نقشه خالی نماند
return <div className="text-center py-20 text-ivory">Project not found</div> const mapLocation = project
? (findMapProjectByName(project.title) ?? createPlaceholderProject(project.title, project.summary))
: null
const mapImages = project && Array.isArray(project.gallery) ? project.gallery.filter(Boolean) : []
// عنوان تب، توضیح متا، OG و JSON-LD از خود پروژه — هم‌ارز نسخهٔ سمت سرور (seo_pages.py)
useEffect(() => {
if (!project) return;
const absolute = (path: string) => new URL(path, window.location.origin).href;
applySeo({
title: `${project.title} | ${brand.name}`,
description: project.summary,
path: href(`/projects/${project.slug}`),
image: project.image,
lang,
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
hasEn: !!(project.title_en?.trim() || project.summary_en?.trim()),
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'WebPage',
name: project.title,
description: project.summary,
image: project.image ? absolute(project.image) : undefined,
inLanguage: lang,
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
{ '@type': 'ListItem', position: 2, name: t('projects'), item: absolute(href('/projects')) },
{ '@type': 'ListItem', position: 3, name: project.title, item: absolute(href(`/projects/${project.slug}`)) },
],
},
],
});
}, [project?.id, lang, href]);
if (!project) {
return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1>
<p className="mt-4 text-sm font-light text-ivory/60">
{t('projectNotFoundText')}
</p>
<ButtonLink to={href('/projects')} variant="copper" className="mt-8">
{t('backToProjects')}
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" />
</ButtonLink>
</section>
)
} }
const backendProject = projects.find((p) => p.slug === slug) const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
// Dynamic bilingual text from projectsData /**
const displayTitle = currentProject.name[lang as 'en' | 'fa'] || currentProject.name.en; * پیمایش پروژه‌ها — همان ترتیبی که فهرست پروژه‌ها دارد (position از API).
const displayLocation = currentProject.location[lang as 'en' | 'fa'] || currentProject.location.en; * در ابتدا/انتهای فهرست، حلقه می‌زند تا کاوش هیچ‌وقت قطع نشود.
const displayArea = currentProject.area[lang as 'en' | 'fa'] || currentProject.area.en; */
const displayStatus = currentProject.status; const currentIndex = projects.findIndex((p) => p.slug === project.slug)
const total = projects.length
const statusLabel = useStatusLabel(displayStatus) const prevProject = total > 1 ? projects[(currentIndex + 1) % total] : undefined // قدیمی‌تر
const nextProject = total > 1 ? projects[(currentIndex - 1 + total) % total] : undefined // تازه‌تر
// Fallbacks for layout structure fields missing in projectsData
const summary = backendProject?.summary || (lang === 'en' ? 'A modern and luxurious architectural project built with high-quality materials and smart design.' : 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ.');
const body = backendProject?.body || (lang === 'en'
? ['Located in the heart of the city, this project offers unparalleled access to major urban hubs.', 'It features state-of-the-art facilities, green spaces, and a sustainable approach to modern living.']
: ['لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است.', 'چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.']);
const gallery = backendProject?.gallery?.length ? backendProject.gallery : [
'/seed-media/2026/09/1.png',
'/seed-media/2026/09/2.png',
'/seed-media/2026/09/3.png',
'/seed-media/2026/09/4.png'
];
const image = backendProject?.image || '/seed-media/2026/09/1.png';
const year = backendProject?.year || (lang === 'en' ? '2024' : '1402');
const floors = backendProject?.floors || '5';
const units = backendProject?.units || '20';
const client = backendProject?.client || (lang === 'en' ? 'Aramesh Group' : 'گروه آرامش');
// تصاویر پاپ‌آپ نقشه
const mapImages = useMemo(
() => [image, ...gallery].filter(Boolean),
[image, gallery]
)
// مختصات همین پروژه
const mapLocation = useMemo(
() => findMapProjectByName(displayTitle) ?? createPlaceholderProject(displayTitle, displayLocation),
[displayTitle, displayLocation]
)
// SEO
useEffect(() => {
applySeo({
title: `${displayTitle} | ${brand.name}`,
description: summary,
path: href(`/projects/${currentProject.slug}`),
image: image,
lang,
});
}, [currentProject.slug, lang, href, displayTitle, summary, image, brand.name]);
const related = projectsData.filter((p) => p.slug !== currentProject.slug).slice(0, 3)
// مشخصات هر پروژه — همان فهرستی که تیم داده دارد:
// وضعیت / سال / تعداد طبقات / واحدها / نوع پروژه / زیربنا / کاربری
// ردیف‌های خالی نمایش داده نمی‌شوند.
const facts = [ const facts = [
{ label: t('specLocation'), value: displayLocation }, { label: t('specStatus'), value: statusLabel },
{ label: t('specYear'), value: year }, { label: t('specYear'), value: project.year },
{ label: t('specArea'), value: displayArea }, { label: t('specLandArea'), value: project.land_area },
{ label: t('specFloors'), value: floors }, { label: t('specArea'), value: project.area },
{ label: t('specUnits'), value: units }, { label: t('specUsage'), value: project.usage },
{ label: t('specClient'), value: client }, { label: t('specPilotFloors'), value: project.pilot_floors },
] { label: t('specFloors'), value: project.floors },
{ label: t('specUnitsResidential'), value: project.units_residential },
{ label: t('specUnitsCommercial'), value: project.units_commercial },
{ label: t('specUnitsOffice'), value: project.units_office },
].filter((f) => String(f.value ?? '').trim() !== '')
return ( return (
<> <>
<section className="bg-char pt-24 pb-12 lg:pt-32 lg:pb-16" aria-label={t('projectDetailsAria')}> <section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
<div className="mx-auto max-w-site px-6 lg:px-10"> <img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
<nav aria-label={t('breadcrumbAria')} className="mb-6 flex items-center gap-2 text-xs font-light text-ivory/50"> <div
className="absolute inset-0"
style={{ background: 'linear-gradient(to top, #27221c 6%, rgba(39,34,28,0.15) 70%)' }}
aria-hidden="true"
/>
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper"> <Link to={href('/')} className="transition-colors hover:text-copper">
{t('home')} {t('home')}
</Link> </Link>
@@ -101,106 +125,179 @@ export function ProjectDetail() {
{t('projects')} {t('projects')}
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
<span className="text-copper">{displayTitle}</span> <span className="text-copper">{project.title}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
<p className="mt-5 max-w-2xl text-base font-light leading-9 text-ivory/70">{project.summary}</p>
{/* ویرایش این پروژه — فقط برای ادمین واردشده؛ هرگز برای بازدیدکننده رندر نشود */}
{canManageSite && (
<div className="mt-5">
<Link
to={`/admin/projects/${project.id}`}
data-admin-link
className="inline-flex items-center gap-2 rounded-full border border-copper/50 bg-ink/90 px-4 py-2 text-xs font-light text-copper backdrop-blur-sm transition-colors duration-200 hover:bg-copper hover:text-char"
>
ویرایش این پروژه (عنوان، مشخصات، گالری، متن)
</Link>
</div>
)}
</div>
</section>
<div className="mb-8"> {/* نقشهٔ مختصات پروژه — بلافاصله زیر هیرو */}
<h1 className="text-4xl lg:text-5xl font-bold text-ivory">{displayTitle}</h1> {mapLocation && (
<section className="bg-char py-10 lg:py-14" aria-label={t('mapAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<div className="w-full max-w-full overflow-hidden rounded-2xl border border-white/10">
<LazyProjectsMapSingle
location={mapLocation}
images={mapImages}
className="h-[320px] md:h-[420px] w-full"
/>
</div>
</div> </div>
</section>
)}
{/* Gallery Desktop: Grid. Mobile: Grid */} <section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}>
<div className="grid grid-cols-2 gap-2 md:gap-3 rounded-xl lg:rounded-2xl overflow-hidden lg:h-[500px] xl:h-[600px]"> <div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
<Reveal className="order-1">
<span className="flex items-center gap-3 text-sm font-light text-copper">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
{/* Main large image */} {project.body.map((para) => (
<button <p key={para} className="mt-5 text-justify text-base font-light leading-9 text-ivory/70">
type="button" {para}
className="col-span-2 lg:col-span-1 h-[250px] md:h-[400px] lg:h-full relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block" </p>
onClick={() => setLightbox(0)} ))}
>
<img src={mapImages[0]} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" />
</button>
{/* 2x2 Small images */} <dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
<div className="col-span-2 lg:col-span-1 grid grid-cols-2 gap-2 md:gap-3 lg:grid-rows-2 lg:h-full"> {facts.map((f) => (
{mapImages.slice(1, 5).map((img, i) => ( <div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right">
<button <dt className="text-xs font-light text-ivory/50">{f.label}</dt>
key={img} <dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
<div className="mt-10">
<ButtonLink to={href('/contact')} variant="outline" className="font-light">
{t('similarProjectCta')}
</ButtonLink>
</div>
</Reveal>
<Reveal delay={0.1} className="order-2">
<button
type="button"
onClick={() => setLightbox(0)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
>
<img
src={project.gallery[0] || project.image}
alt=""
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</button>
<div className="mt-4 grid grid-cols-3 gap-4">
{project.gallery.slice(1).map((img, i) => (
<button
key={img}
type="button" type="button"
className={`relative overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-copper group block h-[120px] md:h-[200px] lg:h-full ${i >= 2 ? 'hidden md:block' : ''}`}
onClick={() => setLightbox(i + 1)} onClick={() => setLightbox(i + 1)}
className="group block overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
> >
<img src={img} alt="" className="h-full w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" /> <img
src={img}
alt=""
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</button> </button>
))} ))}
</div> </div>
</Reveal>
</div>
{/* Smart Content Layout */}
<div className="max-w-4xl mx-auto mt-16 lg:mt-24 flex flex-col gap-16 lg:gap-20">
{/* Description */}
<Reveal>
<span className="flex items-center gap-3 text-sm font-light text-copper mb-6">
<span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel}
</span>
<h2 className="text-3xl font-bold text-ivory">{t('aboutProject')}</h2>
<p className="mt-8 text-xl font-medium leading-9 text-ivory/90">{summary}</p>
{body.map((para) => (
<p key={para} className="mt-6 text-base font-light leading-9 text-ivory/70">
{para}
</p>
))}
</Reveal>
{/* Specifications Table - Perfectly center-aligned */}
<Reveal>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-px border border-ivory/10 bg-ivory/10 rounded-xl overflow-hidden">
{facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 flex flex-col items-center justify-center text-center">
<dt className="text-xs font-light text-ivory/50 mb-2">{f.label}</dt>
<dd className="text-base font-bold text-ivory">{f.value}</dd>
</div>
))}
</dl>
</Reveal>
{/* Map (بدون Reveal) */}
{mapLocation && (
<div className="h-80 md:h-[400px] rounded-xl overflow-hidden border border-white/10 shadow-2xl">
<LazyProjectsMapSingle location={mapLocation} images={mapImages} className="h-full w-full" />
</div>
)}
</div>
</div> </div>
</section> </section>
{/* پیمایش پروژه‌ها — رفتن به پروژهٔ قبلی/بعدی بدون بازگشت به فهرست */}
{(prevProject || nextProject) && (
<nav
className="border-t border-ivory/10 bg-char py-10 lg:py-12"
aria-label={t('projectNavAria')}
>
<div className="mx-auto flex max-w-site items-stretch justify-between gap-3 px-6 lg:px-10">
{prevProject ? (
<div className="flex min-w-0 flex-1 items-center gap-3 md:max-w-[46%]">
{/* فلش بیرون کادر، روی لبهٔ بیرونی */}
<ArrowLeftIcon
className="h-5 w-5 shrink-0 -scale-x-100 text-copper transition-transform duration-300 group-hover/prev:-translate-x-0.5"
aria-hidden="true"
/>
<Link
to={href(`/projects/${prevProject.slug}`)}
className="group/prev flex min-w-0 flex-1 items-center justify-end rounded-2xl border border-ivory/12 bg-ink/40 px-4 py-3.5 transition-all duration-300 ease-lux hover:-translate-y-0.5 hover:border-copper/50 hover:bg-ink/70 md:px-6 md:py-5"
>
<span className="flex min-w-0 flex-col text-end">
<span className="text-[11px] font-light text-ivory/50">{t('prevProject')}</span>
<span className="mt-1 truncate text-sm font-bold text-ivory transition-colors duration-300 group-hover/prev:text-copper md:text-base">
{prevProject.title}
</span>
</span>
</Link>
</div>
) : (
<span className="flex-1" aria-hidden="true" />
)}
{nextProject && (
<div className="flex min-w-0 flex-1 items-center gap-3 md:max-w-[46%]">
<Link
to={href(`/projects/${nextProject.slug}`)}
className="group/next flex min-w-0 flex-1 items-center justify-start rounded-2xl border border-ivory/12 bg-ink/40 px-4 py-3.5 transition-all duration-300 ease-lux hover:-translate-y-0.5 hover:border-copper/50 hover:bg-ink/70 md:px-6 md:py-5"
>
<span className="flex min-w-0 flex-col text-start">
<span className="text-[11px] font-light text-ivory/50">{t('nextProject')}</span>
<span className="mt-1 truncate text-sm font-bold text-ivory transition-colors duration-300 group-hover/next:text-copper md:text-base">
{nextProject.title}
</span>
</span>
</Link>
{/* فلش بیرون کادر، روی لبهٔ بیرونی */}
<ArrowLeftIcon
className="h-5 w-5 shrink-0 text-copper transition-transform duration-300 group-hover/next:translate-x-0.5"
aria-hidden="true"
/>
</div>
)}
</div>
</nav>
)}
<section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}> <section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2> <h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => { {related.map((item, i) => (
const dummyImageName = `${(i % 6) + 1}.png`; <Reveal key={item.slug} delay={0.06 * i} className="h-full">
return ( <ProjectCard project={item} />
<Reveal key={item.slug} delay={0.06 * i} className="h-full"> </Reveal>
<ProjectCard ))}
project={item}
imageUrl={`/seed-media/2026/09/${dummyImageName}`}
detailHref={href(`/projects/${item.slug}`)}
/>
</Reveal>
);
})}
</div> </div>
</div> </div>
</section> </section>
<Lightbox <Lightbox
images={gallery} images={project.gallery}
index={lightbox} index={lightbox}
onClose={() => setLightbox(null)} onClose={() => setLightbox(null)}
onChange={setLightbox} onChange={setLightbox}
+103 -45
View File
@@ -1,22 +1,24 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { PageHero } from '../components/PageHero'; import { PageHero } from '../components/PageHero';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ProjectCard } from '../components/ProjectCard'; import { ProjectCard } from '../components/ProjectCard';
import { PremiumButton } from '../components/PremiumButton'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { Gallery } from '../components/home/Gallery'; import { toPersianDigits } from '../utils/digits';
import { ContactSection } from '../components/ContactSection'; import { ContactSection } from '../components/ContactSection';
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit'; import { InlineEdit } from '../components/admin/InlineEdit';
import { projectsData } from '../data/projectsData'; import { AddItemButton, EditableItem } from '../admin/Editable';
import { LazyProjectsMapAll } from '../map/LazyMaps'; import { LazyProjectsMapAll } from '../map/LazyMaps';
import { MAP_PROJECTS, type MapProject } from '../data/mapProjects'; import { MAP_PROJECTS, type MapProject } from '../data/mapProjects';
export function Projects() { export function Projects() {
const { status_labels } = useSite(); const { status_labels, projects: dbProjects } = useSite();
const { t } = useLang(); const { t, lang } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
/** لنگر ابتدای فهرست — پس از تغییر صفحه، نگاه کاربر به بالا برمی‌گردد */
const listTopRef = useRef<HTMLDivElement | null>(null);
const saveStatusLabel = (key: string, value: string) => { const saveStatusLabel = (key: string, value: string) => {
const label = status_labels.find((s) => s.key === key); const label = status_labels.find((s) => s.key === key);
@@ -35,45 +37,64 @@ export function Projects() {
]; ];
const [status, setStatus] = useState<'done' | 'progress'>('done'); const [status, setStatus] = useState<'done' | 'progress'>('done');
// Filtering and Pagination State // پروژه‌ها از دیتابیس خوانده می‌شوند تا ویرایش پنل همان‌جا دیده شود؛
// فایل استاتیک فقط وقتی دیتابیس خالی است جانشین می‌شود (محتوای قدیمی).
const cardProjects = useMemo(() => dbProjects, [dbProjects]);
// صفحه‌بندی شماره‌دار — هر صفحه ۱۲ پروژه
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const itemsPerPage = 12; const itemsPerPage = 12;
// Filter projects by status // Filter projects by status
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
return projectsData.filter((p) => p.status === status); return cardProjects.filter((p) => p.status === status);
}, [status]); }, [cardProjects, status]);
// Currently displayed projects // پروژه‌های همین صفحه
const totalPages = Math.max(1, Math.ceil(filteredProjects.length / itemsPerPage));
const displayedProjects = useMemo(() => { const displayedProjects = useMemo(() => {
return filteredProjects.slice(0, page * itemsPerPage); const start = (page - 1) * itemsPerPage;
return filteredProjects.slice(start, start + itemsPerPage);
}, [filteredProjects, page]); }, [filteredProjects, page]);
// با تغییر فیلتر یا کوتاه‌شدن فهرست، صفحه به بازهٔ معتبر برگردد
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
/** رفتن به صفحه و بازگرداندن نگاه کاربر به ابتدای فهرست */
const goToPage = (next: number) => {
const target = Math.min(Math.max(1, next), totalPages);
if (target === page) return;
setPage(target);
listTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Handle Tab Switch // Handle Tab Switch
const handleTabSwitch = (newStatus: 'done' | 'progress') => { const handleTabSwitch = (newStatus: 'done' | 'progress') => {
setStatus(newStatus); setStatus(newStatus);
setPage(1); // Reset pagination on tab switch setPage(1); // Reset pagination on tab switch
}; };
// Handle Load More
const handleLoadMore = () => {
setIsLoading(true);
// Brief loading state
setTimeout(() => {
setPage((prev) => prev + 1);
setIsLoading(false);
}, 400);
};
const BASE_IMG_URL = "/seed-media/2026/09/"; const BASE_IMG_URL = "/seed-media/2026/09/";
// Resolve a project href from a MapProject entry by matching against projectsData slugs // Resolve a project href from a MapProject entry by matching names — first in the
// database (so pins follow panel edits), then in the static legacy list.
const resolveProjectHref = (mapProject: MapProject): string | undefined => { const resolveProjectHref = (mapProject: MapProject): string | undefined => {
const match = projectsData.find( const dbMatch = dbProjects.find(
(p) => p.name.fa === mapProject.name || p.name.en === mapProject.name (p) => p.title === mapProject.name || p.title_en === mapProject.name,
); );
return match ? `/projects/${match.slug}` : undefined; return dbMatch ? `/projects/${dbMatch.slug}` : undefined;
};
// تصاویر پاپ‌آپ نقشه — فقط گالری پروژهٔ متناظر (تصویر شاخص در کارت‌های فهرست
// و صفحهٔ پروژه دیده می‌شود و اینجا تکرار نمی‌شود)؛ نبود گالری → قاب تصویر پنهان.
const imagesFor = (mapProject: MapProject): string[] => {
const dbMatch = dbProjects.find(
(p) => p.title === mapProject.name || p.title_en === mapProject.name,
);
if (!dbMatch || !Array.isArray(dbMatch.gallery)) return [];
return dbMatch.gallery.filter((src): src is string => !!src);
}; };
return ( return (
@@ -87,6 +108,7 @@ export function Projects() {
<LazyProjectsMapAll <LazyProjectsMapAll
projects={MAP_PROJECTS} projects={MAP_PROJECTS}
resolveProjectHref={resolveProjectHref} resolveProjectHref={resolveProjectHref}
imagesFor={imagesFor}
className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full" className="h-[350px] md:h-[450px] lg:h-[55vh] max-h-[600px] w-full"
/> />
</div> </div>
@@ -96,6 +118,8 @@ export function Projects() {
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}> <section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div ref={listTopRef} aria-hidden="true" />
{/* دکمه‌های فیلتر */} {/* دکمه‌های فیلتر */}
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}> <div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
{FILTERS.map((filter, index) => { {FILTERS.map((filter, index) => {
@@ -131,37 +155,71 @@ export function Projects() {
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{displayedProjects.map((project, index) => { {displayedProjects.map((project, index) => {
const dummyImageName = `${(index % 6) + 1}.png`; // تصویر رکورد دیتابیس اولویت دارد؛ فقط ردیف‌های بدون تصویر از
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`; // تصاویر جایگزین تکراری استفاده می‌کنند.
const card = (
<ProjectCard
project={project}
detailHref={`/projects/${project.slug}`}
/>
);
return ( return (
<Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full"> <Reveal key={project.id} delay={0.05 * (index % itemsPerPage)} className="h-full">
<ProjectCard <EditableItem resource="projects" item={project} editHref={`/admin/projects/${project.id}`} className="h-full">
project={project} {card}
imageUrl={imageUrl} </EditableItem>
detailHref={`/projects/${project.slug}`}
/>
</Reveal> </Reveal>
); );
})} })}
<AddItemButton resource="projects" editHrefBase="/admin/projects" />
</div> </div>
{/* Load More Button */} {/* صفحه‌بندی شماره‌دار */}
{displayedProjects.length < filteredProjects.length && ( {totalPages > 1 && (
<div className="mt-16 flex justify-center"> <nav className="mt-16 flex flex-wrap items-center justify-center gap-2" aria-label={t('paginationAria')}>
<PremiumButton <button
onClick={handleLoadMore} type="button"
disabled={isLoading} onClick={() => goToPage(page - 1)}
className="px-10 py-4 w-48" disabled={page === 1}
aria-label={t('previousPage')}
className="flex h-10 items-center gap-1.5 border border-ivory/20 px-4 text-xs font-light text-ivory/75 transition-colors duration-200 hover:border-copper hover:text-copper disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:border-ivory/20 disabled:hover:text-ivory/75"
> >
{isLoading ? 'در حال بارگذاری...' : 'مشاهده بیشتر'} <ChevronRightIcon className="h-3.5 w-3.5 rtl:rotate-0 ltr:rotate-180" />
</PremiumButton> {t('previousPage')}
</div> </button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button
key={n}
type="button"
onClick={() => goToPage(n)}
aria-current={n === page ? 'page' : undefined}
className={`flex h-10 w-10 items-center justify-center border text-xs font-bold transition-colors duration-200 ${
n === page
? 'border-copper bg-copper/15 text-copper'
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{lang === 'fa' ? toPersianDigits(String(n)) : n}
</button>
))}
<button
type="button"
onClick={() => goToPage(page + 1)}
disabled={page === totalPages}
aria-label={t('nextPage')}
className="flex h-10 items-center gap-1.5 border border-ivory/20 px-4 text-xs font-light text-ivory/75 transition-colors duration-200 hover:border-copper hover:text-copper disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:border-ivory/20 disabled:hover:text-ivory/75"
>
{t('nextPage')}
<ChevronLeftIcon className="h-3.5 w-3.5 rtl:rotate-0 ltr:rotate-180" />
</button>
</nav>
)} )}
</div> </div>
</section> </section>
<Gallery />
<ContactSection /> <ContactSection />
</> </>
); );

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